09입력값과 비슷한 순서로 후보를 보여주는 칩 입력
입력한 글자와 후보 목록 각각을 겹치는 글자 수로 비교해 점수를 매기고, 점수가 높은 순서로 후보를 다시 늘어놓아 위쪽 후보를 클릭하면 그대로 칩이 된다.
쓰임기존 태그 재사용을 유도하는 블로그·CMS 태그 입력
scoreTOP_NrenderList

<main class="stage">
<div class="as is-demo" id="as">
<div class="as__chips" id="asChips"></div>
<div class="as__row">
<input type="text" class="as__input" id="asInput" aria-label="태그 검색, 겹치는 글자 순 추천" placeholder="태그 검색">
</div>
<ul class="as__list" id="asList" role="listbox" aria-label="추천 태그"></ul>
<p class="as__sr" role="status" aria-live="polite" id="asLive"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: score() 가 입력 글자 풀과 후보 글자를 하나씩 대조해 겹치는 개수를 점수로 매기고,
// renderList() 가 점수 내림차순으로 후보를 다시 늘어놓는다. 맨 위 후보를 클릭(또는 Enter)하면 칩이 된다.
@include stage($stage-ink);
.as { width: min(100%, 320px); display: flex; flex-direction: column; gap: $sp-2; }
.as__chips {
display: flex; flex-wrap: wrap; gap: $sp-1;
min-height: 24px;
}
.as__chip {
display: inline-flex; align-items: center; gap: $sp-1; height: 24px; padding: 0 $sp-1 0 $sp-3;
background: $subject-blue-deep; color: $subject-cream; border-radius: $r-pill;
font-size: 12px; font-weight: 700; white-space: nowrap;
transform: scale(1); opacity: 1;
transition: transform $dur-base $ease-spring, opacity $dur-base $ease-out;
}
.as__chip--enter { transform: scale(.4); opacity: 0; }
.as__chipX {
width: 16px; height: 16px; border: none; border-radius: 50%; background: rgba($subject-cream, .22);
color: $subject-cream; font-size: 11px; line-height: 1; cursor: pointer; display: grid; place-items: center;
}
.as__chipX:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.as__row {
display: flex; align-items: center;
background: rgba($subject-cream, .08); border: 1px solid rgba($subject-cream, .2);
border-radius: $r-control; padding: $sp-2 $sp-3;
}
.as__input {
flex: 1 1 auto; border: none; background: transparent; color: $subject-cream;
font-size: 12px; outline: none;
}
.as__input::placeholder { color: rgba($subject-cream, .55); }
.as__list {
list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: $sp-1;
}
.as__opt {
display: inline-flex; align-items: center; height: 24px; padding: 0 $sp-3;
background: rgba($subject-cream, .1); color: $subject-cream; border-radius: $r-pill;
font-size: 12px; font-weight: 600; cursor: pointer;
transition: transform $dur-quick $ease-spring, background $dur-quick $ease-out;
}
.as__opt:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.as__opt--lead {
background: $subject-blue-light; color: $subject-ink; font-weight: 700;
transform: scale(1.06);
}
.as__sr {
position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); margin: -1px; padding: 0; border: 0;
}
@media (max-width: 340px) {
.as { width: 100%; }
.as__row { padding: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.as__chip { transition: none; }
.as__opt { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.as {
width: min(100%, 320px);
display: flex;
flex-direction: column;
gap: 8px;
}
.as__chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-height: 24px;
}
.as__chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 4px 0 12px;
background: #0a4fe8;
color: #fff7e6;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
transform: scale(1);
opacity: 1;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms ease-out;
}
.as__chip--enter {
transform: scale(0.4);
opacity: 0;
}
.as__chipX {
width: 16px;
height: 16px;
border: none;
border-radius: 50%;
background: rgba(255, 247, 230, 0.22);
color: #fff7e6;
font-size: 11px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.as__chipX:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.as__row {
display: flex;
align-items: center;
background: rgba(255, 247, 230, 0.08);
border: 1px solid rgba(255, 247, 230, 0.2);
border-radius: 12px;
padding: 8px 12px;
}
.as__input {
flex: 1 1 auto;
border: none;
background: transparent;
color: #fff7e6;
font-size: 12px;
outline: none;
}
.as__input::placeholder {
color: rgba(255, 247, 230, 0.55);
}
.as__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.as__opt {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 12px;
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms ease-out;
}
.as__opt:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.as__opt--lead {
background: #5b8cf8;
color: #17141a;
font-weight: 700;
transform: scale(1.06);
}
.as__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
margin: -1px;
padding: 0;
border: 0;
}
@media (max-width: 340px) {
.as {
width: 100%;
}
.as__row {
padding: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.as__chip {
transition: none;
}
.as__opt {
transition: none;
}
}(function () {
var CANDIDATES = ['빨강', '파랑', '초록', '노랑', '보라', '주황', '하양', '검정'];
var TOP_N = 4;
var TYPE_SEQUENCE = ['파랑', '보라'];
var TYPE_MS = 130, HOLD_MS = 500, PAUSE_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('as');
var chipsWrap = document.getElementById('asChips');
var input = document.getElementById('asInput');
var list = document.getElementById('asList');
var live = document.getElementById('asLive');
var seq = 0;
function human() { if (root.classList.contains('is-demo')) root.classList.remove('is-demo'); }
// 계산 겹: 입력 글자와 후보 글자를 하나씩 겹치는 개수로 세어 점수를 매기고, 점수 내림차순으로 다시 늘어놓는다.
function score(query, cand) {
if (!query) return 0;
var pool = {};
for (var i = 0; i < query.length; i++) { var c = query[i]; pool[c] = (pool[c] || 0) + 1; }
var s = 0;
for (var j = 0; j < cand.length; j++) {
var ch = cand[j];
if (pool[ch] > 0) { s++; pool[ch]--; }
}
return s;
}
function addChip(text) {
seq++;
var chip = document.createElement('span');
chip.className = 'as__chip as__chip--enter';
chip.id = 'asChip' + seq;
chip.innerHTML = '<span class="as__chipText"></span><button type="button" class="as__chipX">×</button>';
chip.querySelector('.as__chipText').textContent = text;
chip.querySelector('.as__chipX').setAttribute('aria-label', '태그 삭제: ' + text);
chip.querySelector('.as__chipX').addEventListener('click', function () { human(); chip.remove(); });
chipsWrap.appendChild(chip);
requestAnimationFrame(function () { chip.classList.remove('as__chip--enter'); });
}
function commit(text) {
addChip(text);
input.value = '';
renderList('');
live.textContent = text + ' 태그 추가';
}
function renderList(query) {
var scored = CANDIDATES.map(function (c) { return { text: c, score: score(query, c) }; });
scored.sort(function (a, b) { return b.score - a.score; });
var top = scored.slice(0, TOP_N);
list.innerHTML = '';
top.forEach(function (item, i) {
var li = document.createElement('li');
li.className = 'as__opt' + (i === 0 && item.score > 0 ? ' as__opt--lead' : '');
li.setAttribute('role', 'option');
li.setAttribute('tabindex', '0');
li.textContent = item.text;
li.addEventListener('click', function (e) { if (e.isTrusted) human(); commit(item.text); });
li.addEventListener('keydown', function (e) { if (e.key === 'Enter') { human(); commit(item.text); } });
list.appendChild(li);
});
}
input.addEventListener('input', function (e) { if (e.isTrusted) human(); renderList(input.value); });
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && input.value.trim()) { human(); commit(input.value.trim()); }
});
var timer = 0;
function schedule(fn, ms) { timer = window.setTimeout(fn, ms); }
function setInputValue(v) {
input.value = v;
renderList(v);
}
function typeWord(word, i, cb) {
if (!root.classList.contains('is-demo')) return;
if (i <= word.length) {
setInputValue(word.slice(0, i));
schedule(function () { typeWord(word, i + 1, cb); }, TYPE_MS);
} else {
schedule(cb, HOLD_MS);
}
}
function loop(i) {
if (!root.classList.contains('is-demo')) return;
if (i < TYPE_SEQUENCE.length) {
typeWord(TYPE_SEQUENCE[i], 0, function () {
var lead = list.querySelector('.as__opt--lead');
var text = lead ? lead.textContent : TYPE_SEQUENCE[i];
commit(text);
schedule(function () { loop(i + 1); }, HOLD_MS);
});
} else {
chipsWrap.querySelectorAll('.as__chip').forEach(function (c) { c.remove(); });
schedule(function () { loop(0); }, PAUSE_MS);
}
}
renderList('');
if (!reduce) {
schedule(function () { loop(0); }, 300);
} else {
root.classList.remove('is-demo');
}
})();// TagAutocompleteSuggestChip — vanilla 판(run/397/demo/09-tag-input-autocomplete-suggest-chip/index.html) 의 React 포트.
// 계산 겹(score: 겹치는 글자 수 계산 → 점수 내림차순 재정렬)은 vanilla 와 같은 값.
import { useEffect, useRef, useState } from "react";
import styles from "./TagAutocompleteSuggestChip.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string };
type ChangeEvt = { target: { value: string } };
type ClickEvt = { isTrusted?: boolean };
const CANDIDATES = ["빨강", "파랑", "초록", "노랑", "보라", "주황", "하양", "검정"];
const TOP_N = 4;
const TYPE_SEQUENCE = ["파랑", "보라"];
const TYPE_MS = 130, HOLD_MS = 500, PAUSE_MS = 900;
function score(query: string, cand: string) {
if (!query) return 0;
const pool: { [k: string]: number } = {};
for (let i = 0; i < query.length; i++) { const c = query[i]; pool[c] = (pool[c] || 0) + 1; }
let s = 0;
for (let j = 0; j < cand.length; j++) {
const ch = cand[j];
if (pool[ch] > 0) { s++; pool[ch]--; }
}
return s;
}
type Chip = { id: number; text: string; enter: boolean };
export default function TagAutocompleteSuggestChip() {
const timerRef = useRef(0) as unknown as Box<number>;
const seqRef = useRef(1) as unknown as Box<number>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const queryRef = useRef("") as unknown as Box<string>;
const [chips, setChips] = useState<Chip[]>([]);
const [query, setQuery] = useState("");
const [isDemo, setIsDemo] = useState(true);
const [live, setLive] = useState("");
function human() {
if (isDemoRef.current) { isDemoRef.current = false; setIsDemo(false); }
}
const scored = CANDIDATES.map((c) => ({ text: c, score: score(query, c) }))
.sort((a, b) => b.score - a.score)
.slice(0, TOP_N);
function addChip(text: string) {
const id = seqRef.current++;
setChips((cs) => [...cs, { id, text, enter: true }]);
window.setTimeout(() => {
setChips((cs) => cs.map((c) => (c.id === id ? { ...c, enter: false } : c)));
}, 20);
}
function removeChip(id: number) {
human();
setChips((cs) => cs.filter((c) => c.id !== id));
}
function commit(text: string) {
addChip(text);
setQuery("");
queryRef.current = "";
setLive(text + " 태그 추가");
}
function onInputChange(e: ChangeEvt) {
human();
setQuery(e.target.value);
queryRef.current = e.target.value;
}
function onKeyDown(e: KeyEvt) {
if (e.key === "Enter" && query.trim()) { human(); commit(query.trim()); }
}
function onOptClick(text: string, e: ClickEvt) {
if (e.isTrusted) human();
commit(text);
}
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function setInputValue(v: string) {
queryRef.current = v;
setQuery(v);
}
function typeWord(word: string, i: number, cb: () => void) {
if (!isDemoRef.current) return;
if (i <= word.length) {
setInputValue(word.slice(0, i));
timerRef.current = window.setTimeout(() => typeWord(word, i + 1, cb), TYPE_MS);
} else {
timerRef.current = window.setTimeout(cb, HOLD_MS);
}
}
function loop(i: number) {
if (!isDemoRef.current) return;
if (i < TYPE_SEQUENCE.length) {
typeWord(TYPE_SEQUENCE[i], 0, () => {
const leadList = CANDIDATES.map((c) => ({ text: c, score: score(queryRef.current, c) }))
.sort((a, b) => b.score - a.score);
const lead = leadList[0] && leadList[0].score > 0 ? leadList[0].text : TYPE_SEQUENCE[i];
commit(lead);
timerRef.current = window.setTimeout(() => loop(i + 1), HOLD_MS);
});
} else {
setChips([]);
timerRef.current = window.setTimeout(() => loop(0), PAUSE_MS);
}
}
if (!reduce) {
timerRef.current = window.setTimeout(() => loop(0), 300);
} else {
isDemoRef.current = false;
setIsDemo(false);
}
return () => window.clearTimeout(timerRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.as + " " + (isDemo ? styles.isDemo : "")}>
<div className={styles.chips}>
{chips.map((c) => (
<span key={c.id} className={styles.chip + " " + (c.enter ? styles.chipEnter : "")}>
<span>{c.text}</span>
<button type="button" className={styles.chipX} aria-label={"태그 삭제: " + c.text} onClick={() => removeChip(c.id)}>×</button>
</span>
))}
</div>
<div className={styles.row}>
<input
type="text" className={styles.input} aria-label="태그 검색, 겹치는 글자 순 추천"
placeholder="태그 검색" value={query} onChange={onInputChange} onKeyDown={onKeyDown}
/>
</div>
<ul className={styles.list} role="listbox" aria-label="추천 태그">
{scored.map((item, i) => (
<li
key={item.text}
className={styles.opt + " " + (i === 0 && item.score > 0 ? styles.optLead : "")}
role="option" tabIndex={0}
onClick={(e: ClickEvt) => onOptClick(item.text, e)}
onKeyDown={(e: KeyEvt) => { if (e.key === "Enter") { human(); commit(item.text); } }}
>
{item.text}
</li>
))}
</ul>
<p className={styles.sr} role="status">{live}</p>
</div>
</main>
);
}09 입력값과 비슷한 순서로 후보를 보여주는 칩 입력 — 측정 누적면적 3.226% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 태그 입력 ui 9가지 — 칩 UI 코드로 만들기
이 데모가 속한 글
태그 입력 ui 9가지 — 칩 UI 코드로 만들기태그 입력 UI 아홉 가지를 나란히 놓고 보면, 칩 목록 끝에 「+3」 표시가 뜨는 지점부터 본다 — 줄 너비 값이 칩 개수를 계산해 넘친 만큼 접고, 쉼표로 붙여넣은 문자열은 구분자 값으로 나눈 개수만큼 칩을 한 이 글의 데모 전부 보기


