01Tag Chip That Widens As You Type More Characters
Every keystroke re-measures the chip's inner width from the current character count, so the chip grows to fit its text instead of staying a fixed narrow box.
Best forSearch-filter tags and email recipient fields
WIDTH_PER_CHARmeasureTextMAX_W

<main class="stage">
<div class="ti is-demo" id="ti">
<div class="ti__row" id="tiRow">
<span class="ti__chip" id="tiChip0"><span class="ti__chipText">Research</span><button type="button" class="ti__chipX" aria-label="Remove tag: Research">×</button></span>
<span class="ti__draft" id="tiDraft" aria-hidden="true"><span class="ti__draftText" id="tiDraftText"></span><span class="ti__caret"></span></span>
<input type="text" class="ti__input" id="tiInput" aria-label="Tag input, chip width grows with character count" placeholder="Tag input">
</div>
<p class="ti__meta">Measured width <b id="tiW">0</b>px · chars <b id="tiN">0</b> chars</p>
<p class="ti__sr" role="status" aria-live="polite" id="tiLive"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html applyWidth() 가 키 입력마다 두 단계로 폭을 정한다 — ① WIDTH_PER_CHAR 어림값을 즉시 적용해
// 입력이 끊기지 않게 하고 ② 다음 애니메이션 프레임에 canvas measureText 로 잰 실제 글리프 폭으로 보정한다.
// .ti__draft 의 width 전환만 애니메이션하고 레이아웃은 flex 로 흐른다.
@include stage($stage-ink);
.ti { width: min(100%, 320px); display: flex; flex-direction: column; gap: $sp-2; }
.ti__row {
display: flex; flex-wrap: wrap; align-items: center; gap: $sp-2;
background: rgba($subject-cream, .08); border: 1px solid rgba($subject-cream, .18);
border-radius: $r-control; padding: $sp-2 $sp-3;
}
.ti__chip {
display: inline-flex; align-items: center; gap: $sp-1; height: 26px; 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-quick $ease-pop, opacity $dur-quick $ease-out;
}
.ti__chip--enter { transform: scale(.6); opacity: 0; }
.ti__chipX {
width: 18px; height: 18px; border: none; border-radius: 50%; background: rgba($subject-cream, .22);
color: $subject-cream; font-size: 12px; line-height: 1; cursor: pointer; display: grid; place-items: center;
}
.ti__chipX:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.ti__draft {
display: inline-flex; align-items: center; height: 26px; padding: 0 $sp-3; box-sizing: border-box;
background: rgba($subject-cream, .14); border: 1px dashed rgba($subject-cream, .5); border-radius: $r-pill;
color: $subject-cream; font-size: 12px; font-weight: 700; overflow: hidden;
width: 32px; transition: width $dur-quick $ease-out;
}
.ti__draftText { white-space: nowrap; }
.ti__caret { width: 1px; height: 13px; background: $subject-cream; margin-left: $sp-1; animation: tiBlink 1s steps(1, end) infinite; }
.ti__input {
flex: 1 1 80px; min-width: 60px; border: none; background: transparent; color: $subject-cream;
font-size: 12px; outline: none; caret-color: transparent;
}
.ti__input::placeholder { color: rgba($subject-cream, .55); }
.ti__meta { margin: 0; font-size: 11px; color: rgba($subject-cream, .72); }
.ti__meta b { color: $subject-cream; font-weight: 700; }
.ti__sr {
position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); margin: -1px; padding: 0; border: 0;
}
@keyframes tiBlink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } }
@media (max-width: 340px) {
.ti { width: 100%; }
.ti__row { padding: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.ti__draft { transition: none; }
.ti__chip { transition: none; }
.ti__caret { animation: 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);
}
.ti {
width: min(100%, 320px);
display: flex;
flex-direction: column;
gap: 8px;
}
.ti__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
background: rgba(255, 247, 230, 0.08);
border: 1px solid rgba(255, 247, 230, 0.18);
border-radius: 12px;
padding: 8px 12px;
}
.ti__chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
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 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms ease-out;
}
.ti__chip--enter {
transform: scale(0.6);
opacity: 0;
}
.ti__chipX {
width: 18px;
height: 18px;
border: none;
border-radius: 50%;
background: rgba(255, 247, 230, 0.22);
color: #fff7e6;
font-size: 12px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.ti__chipX:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.ti__draft {
display: inline-flex;
align-items: center;
height: 26px;
padding: 0 12px;
box-sizing: border-box;
background: rgba(255, 247, 230, 0.14);
border: 1px dashed rgba(255, 247, 230, 0.5);
border-radius: 999px;
color: #fff7e6;
font-size: 12px;
font-weight: 700;
overflow: hidden;
width: 32px;
transition: width 220ms ease-out;
}
.ti__draftText {
white-space: nowrap;
}
.ti__caret {
width: 1px;
height: 13px;
background: #fff7e6;
margin-left: 4px;
animation: tiBlink 1s steps(1, end) infinite;
}
.ti__input {
flex: 1 1 80px;
min-width: 60px;
border: none;
background: transparent;
color: #fff7e6;
font-size: 12px;
outline: none;
caret-color: transparent;
}
.ti__input::placeholder {
color: rgba(255, 247, 230, 0.55);
}
.ti__meta {
margin: 0;
font-size: 11px;
color: rgba(255, 247, 230, 0.72);
}
.ti__meta b {
color: #fff7e6;
font-weight: 700;
}
.ti__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
margin: -1px;
padding: 0;
border: 0;
}
@keyframes tiBlink {
0%, 50% {
opacity: 1;
}
50.01%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.ti {
width: 100%;
}
.ti__row {
padding: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ti__draft {
transition: none;
}
.ti__chip {
transition: none;
}
.ti__caret {
animation: none;
}
}(function () {
var WIDTH_PER_CHAR = 6.4; // 계산 겹 상수: 정밀 측정 전에 즉시 보여줄 어림 Width(글자당 px) — 다음 프레임에 canvas measureText 실측값으로 보정된다
var CH_PAD = 24; // 좌우 안쪽 여백 합
var MIN_W = 32, MAX_W = 208;
var WORDS = ['Prototype', 'Wireframe', 'Draft'];
var TYPE_MS = 130, HOLD_MS = 560, GAP_MS = 420;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('ti');
var draft = document.getElementById('tiDraft');
var draftText = document.getElementById('tiDraftText');
var input = document.getElementById('tiInput');
var wOut = document.getElementById('tiW');
var nOut = document.getElementById('tiN');
var live = document.getElementById('tiLive');
var row = document.getElementById('tiRow');
var chipSeq = 0;
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
function syncFont() {
var cs = window.getComputedStyle(draftText);
ctx.font = cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily;
}
function measureWidth(text) {
// 계산 겹: canvas measureText 로 실제 글리프 폭을 잰다 — 글자 수 곱셈 추정이 아니다
return ctx.measureText(text).width;
}
var target = '';
function applyWidth(text, precise) {
var guess = Math.min(MAX_W, Math.max(MIN_W, text.length * WIDTH_PER_CHAR + CH_PAD));
draft.style.width = guess.toFixed(1) + 'px';
if (precise) {
syncFont();
var real = Math.min(MAX_W, Math.max(MIN_W, measureWidth(text) + CH_PAD));
requestAnimationFrame(function () {
draft.style.width = real.toFixed(1) + 'px';
wOut.textContent = Math.round(real);
});
} else {
wOut.textContent = Math.round(guess);
}
nOut.textContent = text.length;
}
function render() {
draftText.textContent = target;
applyWidth(target, true);
}
function commit(text) {
if (!text) return;
chipSeq++;
var chip = document.createElement('span');
chip.className = 'ti__chip ti__chip--enter';
chip.id = 'tiChip' + chipSeq;
var t = document.createElement('span');
t.className = 'ti__chipText';
t.textContent = text;
var x = document.createElement('button');
x.type = 'button';
x.className = 'ti__chipX';
x.setAttribute('aria-label', 'Remove tag: ' + text);
x.addEventListener('click', function () { human(); chip.remove(); announce(); });
chip.appendChild(t);
chip.appendChild(x);
row.insertBefore(chip, draft);
requestAnimationFrame(function () { chip.classList.remove('ti__chip--enter'); });
announce();
}
function announce() {
live.textContent = 'Tags ' + row.querySelectorAll('.ti__chip').length + ' ';
}
function human() {
if (root.classList.contains('is-demo')) { root.classList.remove('is-demo'); }
}
input.addEventListener('input', function () {
human();
target = input.value;
render();
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && input.value.trim()) {
human();
commit(input.value.trim());
input.value = '';
target = '';
render();
}
});
var timer = 0;
function schedule(fn, ms) { timer = window.setTimeout(fn, ms); }
function typeLoop(wi, ci) {
if (!root.classList.contains('is-demo')) return;
var word = WORDS[wi % WORDS.length];
if (ci <= word.length) {
target = word.slice(0, ci);
render();
schedule(function () { typeLoop(wi, ci + 1); }, TYPE_MS);
} else {
schedule(function () {
if (!root.classList.contains('is-demo')) return;
commit(word);
target = '';
render();
schedule(function () { typeLoop(wi + 1, 0); }, GAP_MS);
}, HOLD_MS);
}
}
render();
announce();
if (!reduce) {
schedule(function () { typeLoop(0, 0); }, 320);
} else {
root.classList.remove('is-demo');
}
})();// TagChipWidthAutosize — vanilla 판(run/397/demo/01-tag-input-chip-width-autosize/index.html) 의 React 포트.
// applyWidth() 가 키 입력마다 WIDTH_PER_CHAR 어림값을 즉시 적용하고, 다음 프레임에 canvas measureText 로
// 잰 실제 글리프 폭으로 보정한다. reduce 에서도 input/keydown 은 render() 를 즉시 한 번 부른다.
import { useEffect, useRef, useState } from "react";
import styles from "./TagChipWidthAutosize.module.scss";
type Box<T> = { current: T };
type ChangeEvt = { target: { value: string } };
type KeyEvt = { key: string };
const WIDTH_PER_CHAR = 6.4; // 계산 겹 상수: 정밀 측정 전 즉시 보여줄 어림 폭(글자당 px)
const CH_PAD = 24;
const MIN_W = 32, MAX_W = 208;
const WORDS = ["프로토타입", "와이어프레임", "초안"];
const TYPE_MS = 130, HOLD_MS = 560, GAP_MS = 420;
type Chip = { id: number; text: string; entering: boolean };
export default function TagChipWidthAutosize() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const draftTextRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const inputRef = useRef(null) as unknown as Box<HTMLInputElement>;
const canvasRef = useRef(null) as unknown as Box<HTMLCanvasElement | null>;
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 [chips, setChips] = useState<Chip[]>([{ id: 0, text: "리서치", entering: false }]);
const [draft, setDraft] = useState("");
const [draftW, setDraftW] = useState(MIN_W);
const [measuredW, setMeasuredW] = useState(0);
const [isDemo, setIsDemo] = useState(true);
const [live, setLive] = useState("태그 1개");
function measureWidth(text: string): number {
if (!canvasRef.current) canvasRef.current = document.createElement("canvas");
const ctx = canvasRef.current.getContext("2d");
if (!ctx || !draftTextRef.current) return text.length * WIDTH_PER_CHAR;
const cs = window.getComputedStyle(draftTextRef.current);
ctx.font = cs.fontWeight + " " + cs.fontSize + " " + cs.fontFamily;
return ctx.measureText(text).width;
}
function render(text: string) {
const guess = Math.min(MAX_W, Math.max(MIN_W, text.length * WIDTH_PER_CHAR + CH_PAD));
setDraft(text);
setDraftW(guess);
setMeasuredW(Math.round(guess));
requestAnimationFrame(() => {
const real = Math.min(MAX_W, Math.max(MIN_W, measureWidth(text) + CH_PAD));
setDraftW(real);
setMeasuredW(Math.round(real));
});
}
function human() {
if (isDemoRef.current) { isDemoRef.current = false; setIsDemo(false); }
}
function commit(text: string) {
if (!text) return;
const id = seqRef.current++;
setChips((cs) => [...cs, { id, text, entering: true }]);
window.setTimeout(() => {
setChips((cs) => cs.map((c) => (c.id === id ? { ...c, entering: false } : c)));
}, 20);
}
useEffect(() => {
setLive("태그 " + chips.length + "개");
}, [chips.length]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function typeLoop(wi: number, ci: number) {
if (!isDemoRef.current) return;
const word = WORDS[wi % WORDS.length];
if (ci <= word.length) {
render(word.slice(0, ci));
timerRef.current = window.setTimeout(() => typeLoop(wi, ci + 1), TYPE_MS);
} else {
timerRef.current = window.setTimeout(() => {
if (!isDemoRef.current) return;
commit(word);
render("");
timerRef.current = window.setTimeout(() => typeLoop(wi + 1, 0), GAP_MS);
}, HOLD_MS);
}
}
if (!reduce) {
timerRef.current = window.setTimeout(() => typeLoop(0, 0), 320);
} else {
isDemoRef.current = false;
setIsDemo(false);
}
return () => window.clearTimeout(timerRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function onInput(e: ChangeEvt) {
human();
render(e.target.value);
}
function onKeyDown(e: KeyEvt) {
if (e.key === "Enter" && draft.trim()) {
human();
commit(draft.trim());
render("");
}
}
function removeChip(id: number) {
human();
setChips((cs) => cs.filter((c) => c.id !== id));
}
return (
<main className={styles.stage}>
<div className={styles.ti + " " + (isDemo ? styles.isDemo : "")} ref={rootRef}>
<div className={styles.row}>
{chips.map((c) => (
<span key={c.id} className={styles.chip + (c.entering ? " " + styles.chipEnter : "")}>
<span className={styles.chipText}>{c.text}</span>
<button type="button" className={styles.chipX} aria-label={"태그 삭제: " + c.text} onClick={() => removeChip(c.id)}>×</button>
</span>
))}
<span className={styles.draft} style={{ width: draftW + "px" }} aria-hidden="true">
<span className={styles.draftText} ref={draftTextRef}>{draft}</span>
<span className={styles.caret} />
</span>
<input
type="text" className={styles.input} aria-label="태그 입력, 글자 수만큼 칩 폭이 늘어난다"
placeholder="태그 입력" ref={inputRef} value={draft} onChange={onInput} onKeyDown={onKeyDown}
/>
</div>
<p className={styles.meta}>측정 폭 <b>{measuredW}</b>px · 글자 <b>{draft.length}</b>자</p>
<p className={styles.sr} role="status" aria-live="polite">{live}</p>
</div>
</main>
);
}01 Tag Chip That Widens As You Type More Characters — Measured area 1.497% · moving frames 10/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


