GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

9 Tag Input UI Patterns With CSS Chips

It starts where a chip list shows a +3 badge -- a row-width value collapses the overflow, a pasted string splits by a delimiter value, and a character-count

Auto-plays · click a tile to jump to its section · all nine in one zip

None of the nine tag inputs below fetch, save, or remember anything real — there's no localStorage, no real clock, and nothing calls Math.random. The autocomplete candidates in pattern 09 are a fixed array baked into the HTML, pattern 07's colors come from summing character codes, and pattern 05's automatic paste demo dispatches a fake paste event with a string already attached to it rather than touching the real Clipboard API. Nothing here reaches for a library or a CDN script either — every one of the nine is a single index.html a browser can run cold.

How Nine css Tag Input Patterns Turn One Keystroke Into a Chip

Type into any of the nine fields and the same shape repeats: something is measured or counted, a chip element is created or changed, and a short CSS transition carries the visible part. What differs is the thing being measured. Pattern 01 measures the pixel width of the text you just typed with canvas.measureText. Pattern 02 counts how many chips already exist. Pattern 06 measures where your pointer sits relative to a neighboring chip's center. A tag input ui built this way isn't nine coats of the same enter-animation — it's nine different values deciding what the chip does next, with the CSS transition only ever asked to interpolate between two states that JavaScript already computed.

Six of the nine (01, 02, 03, 06, 07, 08) run a requestAnimationFrame call at least once, usually to apply a measured value on the next paint or to redraw a drag position; 09 also schedules its preview loop through requestAnimationFrame. Patterns 04 and 05 skip the frame loop entirely — a Backspace press or a paste event updates state and calls render once, synchronously, with the CSS transition on the chip's own class handling the visible motion.

How Nine Tag Chip Patterns Compare on Area, Intensity, and Moved Frames

These numbers come from this article's own render pass over the nine 480×300 demos, frame by frame: cumulative area is how much of the frame changed pixel-by-pixel across the whole run, average intensity is the mean size of that change where it happened, and moved frames is how many of the 23 frame-to-frame comparisons actually differed from the one before. A component bar like a chip list isn't meant to fill the frame the way a full-screen scene does — motion here only fires at the moments a chip enters, merges, shakes, or reflows, so a low cumulative area next to a high moved-frame count usually means frequent, small updates rather than one long continuous animation.

# Pattern Cumulative area Avg intensity Moved frames
01 Width autosize 1.5% 93.0 10/23
02 Max-count shake 13.4% 119.6 15/23
03 Duplicate merge 11.5% 80.4 9/23
04 Two-step backspace 3.4% 81.3 16/23
05 Paste split 14.6% 66.1 17/23
06 Drag reorder 7.9% 99.7 16/23
07 Color hash 11.9% 114.5 9/23
08 Overflow badge 2.4% 121.3 6/23
09 Autocomplete rank 3.2% 163.7 6/23

Pattern 05 tops the table because a single paste replaces the whole row at once — several chips appear in one frame instead of one at a time. Patterns 08 and 09 have the lowest moved-frame counts on purpose: an overflow badge only has something to recompute when the row width or the chip count changes, and a ranked suggestion list only re-sorts on a keystroke, so most of their 23 comparisons are identical frames with nothing to report.

Code Per Pattern, Copied Straight Out of index.html

Each block below is the exact function that owns that pattern's calculated value, pulled from its own index.html with nothing merged, renamed, or reflowed.

01Character-count Value Widens a Typing Chip

Every keystroke first estimates the draft chip's width from text.length * WIDTH_PER_CHAR, an approximation cheap enough to apply in the same frame it's typed. A hidden <canvas> context then measures the real glyph width with measureText, and a requestAnimationFrame callback swaps in that precise width one frame later, so the guess never lingers long enough to look wrong.

Tag Chip That Widens As You Type More Characters
Open in a new tab

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 · See the password

WIDTH_PER_CHARmeasureTextMAX_W
  function measureWidth(text) {
    // calc layer: canvas measureText reads the real glyph width — not a character-count multiplication
    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;
  }

02Chip-count Value Shakes an Input Past Its Limit

count() re-reads the live .tm__chip elements in the row on every add attempt instead of trusting a cached number, and that count is compared against MAX_COUNT before a chip is ever created. Once the field is full, block() removes and immediately re-adds the same shake class — forcing a reflow with void row.offsetWidth in between — so pressing Enter at the limit replays the same short shake every time instead of animating only once.

Tag Input That Shakes and Blocks Past Its Chip Limit
Open in a new tab

02 · Tag Input That Shakes and Blocks Past Its Chip Limit — Measured area 13.369% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

MAX_COUNTblockcount
  function count() { return row.querySelectorAll('.tm__chip').length; }

  function render() {
    countEl.textContent = count();
    warnEl.hidden = count() < MAX_COUNT;
  }

  function human() { if (root.classList.contains('is-demo')) root.classList.remove('is-demo'); }

  function block() {
    // calc layer: compares the count against MAX_COUNT and, past the limit, replays the spring shake instead of adding a chip
    row.classList.remove('tm__row--shake');
    void row.offsetWidth; // reflow — reapplying the same class needs one or the animation won't replay
    row.classList.add('tm__row--shake');
    live.textContent = 'Tags ' + MAX_COUNT + ' limit reached';
    if (reduce) render();
  }

03Normalized-text Value Flashes a Duplicate Chip Instead of Adding One

findDup runs the typed text and every existing chip's stored text through the same norm() function — trim, then lowercase — before comparing them, so "Draft" and " draft " both match a chip already reading "draft". When a match turns up, merge() never touches the DOM chip count; it just re-triggers a fade-and-return pulse on the existing chip through the same remove-class/reflow/add-class sequence pattern 02 uses for its shake.

Tag Input Where a Repeated Word Flashes Its Existing Chip
Open in a new tab

03 · Tag Input Where a Repeated Word Flashes Its Existing Chip — Measured area 11.464% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

normfindDupmerge
  function norm(text) { return text.trim().toLowerCase(); }
  function chips() { return Array.prototype.slice.call(row.querySelectorAll('.td__chip')); }
  function render() { countEl.textContent = chips().length; }
  function human() { if (root.classList.contains('is-demo')) root.classList.remove('is-demo'); }

  function findDup(text) {
    // calc layer: compares the normalized text against every existing chip — case and surrounding space don't count as different
    var key = norm(text);
    var list = chips();
    for (var i = 0; i < list.length; i++) {
      if (norm(list[i].dataset.text) === key) return list[i];
    }
    return null;
  }

  function merge(chip) {
    // calc layer: no new chip is made — the matching one gets a brief spring pulse instead (reflow guarantees it replays)
    chip.classList.remove('td__chip--merge');
    void chip.offsetWidth;
    chip.classList.add('td__chip--merge');
    dupEl.hidden = false;
    live.textContent = chip.dataset.text + ' already exists';
    if (reduce) render();
  }

04Two-step Backspace Value Selects Before It Deletes a Chip

backspaceStep() only fires when the text field is already empty — a Backspace with text still in the field falls through to the browser's own default deletion. From there, the function checks whether the last chip is already the selected one: the first empty-field Backspace calls select() and stops, the second calls remove(). Nothing is deleted on the first press; the chip's own class just switches to a selected state so the next keystroke has somewhere to land.

Tag Input That Needs Two Backspaces to Delete a Chip
Open in a new tab

04 · Tag Input That Needs Two Backspaces to Delete a Chip — Measured area 3.374% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

backspaceStepselectlastChip
  function remove(chip) {
    // calc layer, step 2: only the selected chip gets the exit animation (rotate + shrink), then it leaves the DOM once that finishes
    selectedId = null;
    chip.classList.add('tb__chip--exit');
    var text = chip.dataset.text;
    window.setTimeout(function () {
      chip.remove();
      hint.textContent = 'Backspace on empty field — first press selects';
      live.textContent = text + ' removed · tags ' + chips().length + ' ';
    }, reduce ? 0 : 320);
  }

  function backspaceStep() {
    // calc layer: only reacts when the field is empty — text still in the field falls through to the browser's own delete
    if (input.value) return;
    var last = lastChip();
    if (!last) return;
    if (selectedId === last.id) { remove(last); }
    else { select(last); }
  }

05Delimiter-pattern Value Splits a Paste Into Many Chips

handlePaste reads e.clipboardData.getData('text/plain') and calls preventDefault() so the raw text never lands in the input box itself. DELIM_PATTERN then splits that string on commas and newlines in one pass, .filter(Boolean) drops any empty pieces a trailing delimiter would leave behind, and every surviving piece becomes its own staggered chip in the same tick. The automatic demo doesn't actually touch your system clipboard — it builds a synthetic ClipboardEvent-like object with a fixed string already attached and dispatches that, since a script can't silently read or write the real clipboard without a user gesture.

Tag Input That Splits a Pasted List Into Chips at Once
Open in a new tab

05 · Tag Input That Splits a Pasted List Into Chips at Once — Measured area 14.601% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

DELIM_PATTERNsplitfilter
  function handlePaste(e) {
    // calc layer: pulls text from the paste event's clipboardData and splits it by the delimiter — navigator.clipboard is never read
    // e.isTrusted tells a real user paste apart from the automatic demo's dispatched imitation event
    if (e.isTrusted) human();
    e.preventDefault();
    var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
    var parts = text.split(DELIM_PATTERN).map(function (s) { return s.trim(); }).filter(Boolean);
    if (parts.length > 1) splitCount++;
    parts.forEach(function (p, i) { addChip(p, i); });
    input.value = '';
    render();
    live.textContent = parts.length + ' tags split';
  }

06Drag-position Value Swaps Two Chips Mid-reorder

While a chip is held, checkSwap() compares the dragged chip's current horizontal center against its immediate neighbors' centers on every pointermove. Crossing a neighbor's center — not its edge — triggers doSwap(), which reorders the two elements in the DOM immediately and then measures the pixel difference the swap caused (oldLeft - newLeft) to fold into swapCompensation, so the dragged chip's translateX stays glued to the pointer instead of visibly jumping when the DOM order changes underneath it.

Tag Input Whose Chips Reorder by Drag
Open in a new tab

06 · Tag Input Whose Chips Reorder by Drag — Measured area 7.892% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

checkSwapdoSwapswapCompensation
  function checkSwap() {
    var dr = dragEl.getBoundingClientRect();
    var dragCenter = dr.left + dr.width / 2;
    var prev = dragEl.previousElementSibling;
    if (prev) {
      var pr = prev.getBoundingClientRect();
      if (dragCenter < pr.left + pr.width / 2) { doSwap(prev, true); return; }
    }
    var next = dragEl.nextElementSibling;
    if (next) {
      var nr = next.getBoundingClientRect();
      if (dragCenter > nr.left + nr.width / 2) { doSwap(next, false); return; }
    }
  }

  function doSwap(sibling, moveBefore) {
    var oldLeft = dragEl.offsetLeft;
    if (moveBefore) { row.insertBefore(dragEl, sibling); }
    else { row.insertBefore(sibling, dragEl); }
    var newLeft = dragEl.offsetLeft;
    swapCompensation += (oldLeft - newLeft);
    applyTransform(lastDx);
    renderOrder();
    live.textContent = orderText();
  }

07Character-hash Value Colors a Chip From Its Own Text

hashIndex multiplies each character's code point by its position, sums the results, and takes the remainder against PALETTE_LEN. That remainder becomes a class suffix — tc__chip--c0 through tc__chip--c5 — so the color comes entirely from the text itself: retype the same word tomorrow and hashIndex returns the same remainder, which is why this needs no stored color map and no Math.random anywhere in the file.

Tag Input Whose Chip Color Comes From Its Text Alone
Open in a new tab

07 · Tag Input Whose Chip Color Comes From Its Text Alone — Measured area 11.941% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

hashIndexPALETTE_LENhash-c0
  // calc layer: sums character codes into one number and takes it modulo the palette size — the same text always lands on the same remainder
  function hashIndex(text) {
    var h = 0;
    for (var i = 0; i < text.length; i++) { h += text.charCodeAt(i) * (i + 1); }
    return h % PALETTE_LEN;
  }

  function addChip(text) {
    seq++;
    var idx = hashIndex(text);
    var chip = document.createElement('span');
    chip.className = 'tc__chip tc__chip--enter tc__chip--c' + idx;
    chip.id = 'tcChip' + seq;
    chip.innerHTML = '<span class="tc__chipText"></span><button type="button" class="tc__chipX">×</button>';
    chip.querySelector('.tc__chipText').textContent = text;
    chip.querySelector('.tc__chipX').setAttribute('aria-label', 'Remove tag: ' + text);
    chip.querySelector('.tc__chipX').addEventListener('click', function () { human(); chip.remove(); render(); });
    row.insertBefore(chip, input);
    requestAnimationFrame(function () { chip.classList.remove('tc__chip--enter'); });
    render();
    live.textContent = text + ' tag, ' + (idx + 1) + 'th color';
  }

08Row-width Value Folds Overflow Chips Into a Badge

relayout() clears every chip's display first so every width can be measured fresh, then walks the chips again, adding each one's rendered width plus a gap to a running sum. Before accepting a chip it also checks whether a reserved width for the "+N" badge (BADGE_RESERVE) still fits in what's left of the row — a chip only counts as shown if the chip and that reserve both clear the container's width. Everything past the break point gets display: none, and the badge text is written from a plain subtraction: chip count minus shown count.

Tag Input That Collapses Overflow Chips Into a +N Badge
Open in a new tab

08 · Tag Input That Collapses Overflow Chips Into a +N Badge — Measured area 2.418% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

BADGE_RESERVEGAPrelayout
    var sum = 0, shown = 0;
    for (var i = 0; i < chips.length; i++) {
      chips[i].style.display = '';
    }
    for (i = 0; i < chips.length; i++) {
      var w = chips[i].getBoundingClientRect().width;
      var addGap = shown > 0 ? GAP : 0;
      var withThis = sum + addGap + w;
      var remaining = chips.length - (i + 1);
      var reserve = remaining > 0 ? GAP + BADGE_RESERVE : 0;
      if (withThis + reserve <= containerWidth) { sum = withThis; shown++; }
      else { break; }
    }
    for (var j = shown; j < chips.length; j++) { chips[j].style.display = 'none'; }
    var hidden = chips.length - shown;
    if (hidden > 0) {
      badge.textContent = '+' + hidden;
      badge.classList.add('ov__badge--show');
    } else {
      badge.classList.remove('ov__badge--show');
    }
    widthEl.textContent = Math.round(containerWidth);

09Overlap-score Value Ranks Autocomplete Chips

score() counts how many characters the typed query and a candidate word share, consuming each matched character from a small frequency table so a repeated letter can't be counted twice. renderList runs every fixed candidate through that score, sorts the array by score descending, and keeps only the top TOP_N — so the list above the input isn't just filtered, it's reordered on every keystroke, and the top match gets a --lead class the moment its score clears zero.

Tag Input That Ranks Suggestions by Closeness to What You Typed
Open in a new tab

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 · See the password

scoreTOP_NrenderList
  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);
    });
  }

What Two Backspace Taps and an aria-live Count Give Back to Accessibility

Pattern 04's two-step Backspace exists for keyboard users specifically: a mouse user can already see which chip they're about to remove, but a keyboard user pressing Backspace in an empty field has no such preview, so the first press has to make the target visible (select() adds a selected class and updates a hint line) before the second press is allowed to act on it. Every one of the nine also carries a screen-reader-only live region — eight use role="status" aria-live="polite" for routine count updates ("3 tags," "split into 4 tags"), while pattern 02 switches to role="alert" for its limit message specifically, since a blocked add is closer to an error than a status update.

Reduced motion is checked once per pattern with window.matchMedia('(prefers-reduced-motion: reduce)').matches, stored in a reduce flag read for the rest of the file rather than re-queried on every event. When it's true, the automatic preview sequence never runs at all, and the moment a real input happens the pattern renders its end state once and stops — pattern 02's shake and pattern 03's merge pulse both skip straight to render() instead of playing the class-toggle animation, so the count and the duplicate warning still update, just without the motion carrying the news.

The full set — SCSS tokens, the React port of all nine, and the three variant builds — sits in a zip that opens with kq4849c2, typed in by hand rather than copied from wherever it's shown in plain text nearby.

Where a Search Filter, an Email Field, and a Blog Editor Actually Use This

Pattern 01's width-fit chip suits a live search filter or a small metadata field where every character of screen space matters — a fixed-width chip either truncates a long tag or wastes space around a short one, and neither looks intentional. Pattern 05's paste-split earns its keep anywhere someone arrives with a list already made: pasting a spreadsheet column of emails or SKUs into a single field and getting nine chips back in one paste beats typing Enter nine times, and it's the one pattern here that has to reckon with real clipboard content instead of typed keystrokes.

Pattern 08's overflow badge belongs in exactly the places screen space is fixed and the tag count isn't — a product card, a narrow sidebar filter, a mobile list row — where showing every tag would either wrap the layout or crowd out the content next to it. And pattern 09's ranked suggestions solve a different problem than autocomplete usually does: it isn't trying to complete what you're typing, it's trying to steer you back toward tags that already exist, which matters most in a CMS or a blog editor where "photography" and "photos" as separate tags quietly fragment a whole tagging system.

Where a Fixed Delimiter List Breaks a Semicolon-joined Paste

DELIM_PATTERN in pattern 05 only recognizes commas and newlines by default — paste in a list joined with semicolons instead and the whole string lands as one long chip, because nothing in the regex tells it a semicolon means "stop here." The fix (see the variant table below) is one character added to the character class, not a rewrite of the split logic; the same one-character fix applies for teams whose data uses pipes or tabs instead. Pattern 03's norm() has a narrower version of the same trap: it only trims whitespace and lowercases, so it treats "co-working" and "coworking" as different tags even though a human reader would likely mean the same thing by both — catching that needs an explicit synonym list, which none of these nine ships with on purpose, since guessing at synonyms wrong is worse than not guessing at all.

What Changes When Chip Width, Delimiter, or Badge Reserve Moves

Variant Changed value What it feels like
Adjust the chip width-per-character value 01 WIDTH_PER_CHAR 6.4 → 9.5 WIDTH_PER_CHAR is the per-character px guess used for the very first paint before canvas measureText arrives -- raising it from 6.4 to 9.5 makes the chip render wider at that first instant, and the next-frame measureText correction still narrows it to the real glyph width.
Adjust the paste delimiter-pattern value 05 DELIM_PATTERN /[,\n]+/ → /[,;\n]+/ DELIM_PATTERN is the regex that splits pasted text into chips -- adding a semicolon alongside comma and newline means semicolon-joined text also splits into multiple chips on the same paste.
Adjust the overflow row-threshold value 08 BADGE_RESERVE 34 → 54 BADGE_RESERVE is the width (px) relayout() reserves for the "+N" badge -- raising it from 34 to 54 leaves less room in the same row for chips before they fold, so fewer chips stay visible and the badge appears sooner.

None of the three touches layout or event wiring — each is one constant inside a function that already exists, and the calculation it feeds keeps working exactly the same way with the new number.

How These Nine Differ From an Input Field and a Select Dropdown, and Where to Find More Tagging UI Examples

CSS Input Design: 9 Form Widgets You Can Paste In covers a single text field reacting to its own state — focus, validity, a floating label — never producing more than one committed value at a time. A tag input ui design turns that around: every one of these nine is really managing a small list, not a single field, and most of the calculated value (width, index, score) exists to answer "where does this new item go relative to the ones already there," a question a plain input field never has to ask.

9 Select Dropdown Design Patterns That Really Open and Autocomplete Input: 9 Search Dropdowns are the closer relatives — pattern 09's ranked suggestion list is a sibling of the fuzzy-matching work in that autocomplete piece, just scored by raw character overlap instead of edit distance, and committing a suggestion here creates a chip instead of filling a single field. Readers comparing tagging ui examples across all three posts will notice the dropdown pieces solve "which one option did you mean," while this one keeps solving "how many did you mean, and in what shape."

Questions Worth Answering Before You Wire Up a Tag Input UI

Does pattern 05 actually read my clipboard?

Only during a real paste. The event handler reads e.clipboardData, which the browser fills in only in response to an actual paste gesture — the automatic demo on this page fakes that same event with a fixed string attached, since a script has no way to read or write your system clipboard on its own.

Why does pattern 03 flash the old chip instead of just ignoring the duplicate?

Silently ignoring it would leave someone wondering whether their keystroke registered at all. merge() replaying a brief pulse on the chip that already exists answers the question without adding a second element that would make the tag count wrong.

Can pattern 08's badge count be wrong after a window resize?

No — relayout() re-measures every chip's getBoundingClientRect() width against the current containerWidth on every call, and a resize listener calls it again whenever the viewport changes, so the shown-versus-hidden split and the badge number are recomputed from scratch rather than cached from the last layout.

MDN's paste event reference documents the clipboardData property pattern 05 reads before splitting on DELIM_PATTERN.

Enter the archive password

The password is inside this article. You will find it as you read.