09Tag Input That Ranks Suggestions by Closeness to What You Typed
Each candidate is scored against the typed text by counting overlapping characters, the list is re-sorted by that score, and picking a top candidate turns it straight into a chip.
Best forBlog/CMS tag inputs that encourage reusing existing tags
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="Tag search, suggestions ranked by overlapping letters" placeholder="Search tags">
</div>
<ul class="as__list" id="asList" role="listbox" aria-label="Suggested tags"></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 = ['Red', 'Blue', 'Green', 'Yellow', 'Purple', 'Orange', 'White', 'Black'];
var TOP_N = 4;
var TYPE_SEQUENCE = ['Blue', 'Purple'];
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', 'Remove tag: ' + 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 + ' tag added';
}
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 Tag Input That Ranks Suggestions by Closeness to What You Typed — Measured area 3.226% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · 9 Tag Input UI Patterns With CSS Chips
The post this demo lives in
9 Tag Input UI Patterns With CSS ChipsIt starts where a chip list shows a +3 badge -- a row-width value collapses the overflow, a pasted string splits by a de See all demos in this post
If you like this effect - similar demos
Inputs03 Search That Ranks by a Similarity Score Between Typed Text and NameEmoji Picker: 9 Patterns by One Value
Inputs09 Inline leave check that guards what you typed9 Multi Step Form UI Patterns — Branch and Resume
Inputs01 Match highlight — locating the typed letters inside each suggestion and bolding that spanAutocomplete Input: 9 Search Dropdowns