Tag Input Tear

Tag input

Tag input whose chips hang from a perforated edge instead of an x icon: dragging a chip down snaps its dashes into torn stubs top-to-bottom as a 0-1 tear progress crosses each one, and past 80% releasing drops the chip with a 4deg rotate and gravity while siblings close the gap via FLIP; releasing early springs everything back and re-knits the perforation in reverse.

Install
npx shadcn add https://design.helpmarq.com/r/tag-input-tear.json
Source
registry/core/tag-input-tear/component.tsx
"use client";

import { useEffect, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// TearTab — a tag input where every chip hangs from a perforated edge (a
// column of short SVG dashes, stroke --border) instead of carrying an x
// icon. At rest the perforation itself reads "removable." Deletion has two
// paths that both end at the same place (tag gone, siblings closed the gap,
// action announced, Ctrl+Z reverses it):
//
//   - Pointer sugar: drag a chip down. Pointer distance maps 1:1 to a 0-1
//     tear progress; each dash flips from one intact line to two offset
//     stubs the instant progress passes its own index/DASH_COUNT threshold,
//     with a brief 1px translate jitter on the dash that just snapped. On
//     release, progress >= 0.8 commits the tear — the chip rotates 4deg and
//     drops (ease-in, gravity-flavored) via a short-lived fixed-position
//     ghost while the real chip is already gone from flow; progress < 0.8
//     springs the whole thing back to 0 and the dashes re-knit in reverse,
//     for free, because "broken" is just `progress >= threshold` — no
//     separate reverse-animation path to keep in sync with forward.
//   - Keyboard, the only required path: Tab/arrow keys rove focus across
//     chips (role=option in a role=listbox), Delete/Backspace on a focused
//     chip removes it via a fast 150ms opacity fade, no tear theater. This
//     is also where reduced-motion and a duplicate-safety net both land —
//     see the reducedRef guards below.
//
// Sibling reflow uses a hand-rolled FLIP: before every tags-array mutation
// (add, either removal path, undo) the surviving chips' rects are snapshotted;
// after React commits the new layout, each chip is offset back to its old
// position and released on a spring-approximating cubic-bezier (the same
// honest-approximation call this registry already made in drill-down-spines/
// slider-range-shear — a real spring integrator wasn't worth it for a one-shot
// settle). Ctrl+Z / Cmd+Z pops a small undo stack (tag + its removal index)
// and re-inserts, reusing the same FLIP path. Pure DOM + inline SVG + CSS;
// no canvas, no dependencies.
// ---------------------------------------------------------------------------

const DASH_COUNT = 7;
const CHIP_H = 28; // px, fixed — keeps the SVG viewBox in real px, no scaling
const TEAR_DISTANCE = 64; // px of downward drag for progress 0 -> 1
const RELEASE_AT = 0.8;
const FALL_MS = 340;
const FADE_MS = 150;
const FLIP_MS = 380;
const JITTER_MS = 120;
const SPRING_K = 260; // s^-2
const SPRING_ZETA = 0.5;

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));

interface UndoEntry {
  tag: string;
  index: number;
}

interface GhostRect {
  tag: string;
  left: number;
  top: number;
  width: number;
}

export interface TearTabProps {
  defaultTags?: string[];
  placeholder?: string;
  /** accessible name for the chip listbox */
  label?: string;
  onChange?: (tags: string[]) => void;
  className?: string;
}

function Perforation({
  progress,
  jitterIndex,
  armed,
}: {
  progress: number;
  jitterIndex: number | null;
  armed: boolean;
}) {
  const stroke = armed ? "var(--accent)" : "var(--border)";
  return (
    <svg
      aria-hidden
      width={9}
      height={CHIP_H}
      viewBox={`0 0 9 ${CHIP_H}`}
      className="block shrink-0"
    >
      {Array.from({ length: DASH_COUNT }, (_, i) => {
        const cy = 4 + (i * (CHIP_H - 8)) / (DASH_COUNT - 1);
        const threshold = (i + 1) / DASH_COUNT;
        const torn = progress >= threshold;
        const jittering = jitterIndex === i;
        return torn ? (
          <g key={i} className={jittering ? "tag-input-tear-jitter" : undefined}>
            <line x1={1.5} x2={3.6} y1={cy - 1} y2={cy - 1} stroke={stroke} strokeWidth={1.3} strokeLinecap="round" />
            <line x1={4.4} x2={7.2} y1={cy + 1} y2={cy + 1} stroke={stroke} strokeWidth={1.3} strokeLinecap="round" />
          </g>
        ) : (
          <line key={i} x1={1.5} x2={7.2} y1={cy} y2={cy} stroke={stroke} strokeWidth={1.3} strokeLinecap="round" />
        );
      })}
    </svg>
  );
}

export function TearTab({
  defaultTags = [],
  placeholder = "Add a tag…",
  label = "Tags",
  onChange,
  className = "",
}: TearTabProps) {
  const [tags, setTagsState] = useState<string[]>(defaultTags);
  const [draft, setDraft] = useState("");
  const [fadingIds, setFadingIds] = useState<Set<string>>(new Set());
  const [ghost, setGhost] = useState<GhostRect | null>(null);
  const [announce, setAnnounce] = useState("");
  const [focusedTag, setFocusedTag] = useState<string | null>(null);

  // hot-path drag state lives in refs; dragProgress is mirrored to state
  // purely so the dragged chip re-renders as the pointer moves
  const [dragId, setDragId] = useState<string | null>(null);
  const [dragProgress, setDragProgressState] = useState(0);
  const [jitterIndex, setJitterIndex] = useState<number | null>(null);

  const tagsRef = useRef(tags);
  tagsRef.current = tags;
  const dragIdRef = useRef<string | null>(null);
  const dragProgressRef = useRef(0);
  const dragStartYRef = useRef(0);
  const prevBrokenRef = useRef<boolean[]>(new Array(DASH_COUNT).fill(false));
  const undoStackRef = useRef<UndoEntry[]>([]);
  const chipRefs = useRef(new Map<string, HTMLLIElement>());
  const prevRectsRef = useRef(new Map<string, DOMRect>());
  const inputRef = useRef<HTMLInputElement>(null);
  const timersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
  const rafRef = useRef(0);
  const jitterTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  // window-level pointermove/up/cancel handlers, kept fresh via refs so a
  // single mount-time listener never goes stale — see the drag effect below
  // for why this has to live on window instead of the chip element.
  const onWindowPointerMoveRef = useRef<(e: PointerEvent) => void>(() => {});
  const onWindowPointerUpRef = useRef<(e: PointerEvent) => void>(() => {});
  const onWindowPointerCancelRef = useRef<(e: PointerEvent) => void>(() => {});

  const reducedRef = useRef(
    typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches,
  );

  useEffect(() => {
    const timers = timersRef.current;
    return () => {
      timers.forEach(clearTimeout);
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
      if (jitterTimerRef.current) clearTimeout(jitterTimerRef.current);
    };
  }, []);

  const track = (ms: number, fn: () => void) => {
    const t = setTimeout(() => {
      timersRef.current.delete(t);
      fn();
    }, ms);
    timersRef.current.add(t);
    return t;
  };

  const snapshotRects = (excludeTag?: string) => {
    const map = new Map<string, DOMRect>();
    chipRefs.current.forEach((el, id) => {
      if (id === excludeTag) return;
      map.set(id, el.getBoundingClientRect());
    });
    prevRectsRef.current = map;
  };

  // FLIP: replays position deltas captured just before the last tags mutation
  useEffect(() => {
    const prev = prevRectsRef.current;
    if (prev.size === 0) return;
    prevRectsRef.current = new Map();
    if (reducedRef.current) return;
    chipRefs.current.forEach((el, id) => {
      const before = prev.get(id);
      if (!before) return;
      const after = el.getBoundingClientRect();
      const dx = before.left - after.left;
      const dy = before.top - after.top;
      if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;
      el.style.transition = "none";
      el.style.transform = `translate(${dx}px, ${dy}px)`;
      void el.offsetHeight; // force reflow before releasing the transition
      el.style.transition = `transform ${FLIP_MS}ms cubic-bezier(0.34, 1.56, 0.64, 1)`;
      el.style.transform = "";
      const clear = () => {
        el.style.transition = "";
      };
      el.addEventListener("transitionend", clear, { once: true });
    });
  }, [tags]);

  const setTags = (next: string[]) => {
    setTagsState(next);
    onChange?.(next);
  };

  const focusNeighborOf = (tag: string) => {
    const idx = tagsRef.current.indexOf(tag);
    const neighbor = tagsRef.current[idx + 1] ?? tagsRef.current[idx - 1] ?? null;
    return () => {
      if (neighbor) chipRefs.current.get(neighbor)?.focus({ preventScroll: true });
      else inputRef.current?.focus({ preventScroll: true });
    };
  };

  const removeTagNow = (tag: string, excludeFromFlip: boolean) => {
    const idx = tagsRef.current.indexOf(tag);
    if (idx === -1) return;
    snapshotRects(excludeFromFlip ? tag : undefined);
    const next = tagsRef.current.filter((t) => t !== tag);
    setTags(next);
    undoStackRef.current.push({ tag, index: idx });
    if (undoStackRef.current.length > 20) undoStackRef.current.shift();
    setAnnounce(
      `removed ${tag}, ${next.length} tag${next.length === 1 ? "" : "s"} remain${next.length === 1 ? "s" : ""}`,
    );
  };

  const fastFadeRemove = (tag: string) => {
    if (fadingIds.has(tag)) return;
    const focusNeighbor = focusNeighborOf(tag);
    setFadingIds((prev) => new Set(prev).add(tag));
    track(reducedRef.current ? 0 : FADE_MS, () => {
      setFadingIds((prev) => {
        const n = new Set(prev);
        n.delete(tag);
        return n;
      });
      removeTagNow(tag, false);
      requestAnimationFrame(focusNeighbor);
    });
  };

  const clearDrag = () => {
    dragIdRef.current = null;
    setDragId(null);
    dragProgressRef.current = 0;
    setDragProgressState(0);
    prevBrokenRef.current = new Array(DASH_COUNT).fill(false);
  };

  const commitDrop = (tag: string) => {
    const el = chipRefs.current.get(tag);
    const rect = el?.getBoundingClientRect() ?? null;
    clearDrag();
    if (reducedRef.current || !rect) {
      fastFadeRemove(tag);
      return;
    }
    const focusNeighbor = focusNeighborOf(tag);
    setGhost({ tag, left: rect.left, top: rect.top, width: rect.width });
    removeTagNow(tag, true);
    track(FALL_MS, () => setGhost(null));
    requestAnimationFrame(focusNeighbor);
  };

  const springBack = () => {
    if (reducedRef.current) {
      clearDrag();
      return;
    }
    if (rafRef.current) cancelAnimationFrame(rafRef.current);
    let vel = 0;
    let last = performance.now();
    const tick = (now: number) => {
      const dt = Math.min(0.032, Math.max(0, (now - last) / 1000));
      last = now;
      const c = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);
      vel += (-SPRING_K * dragProgressRef.current - c * vel) * dt;
      dragProgressRef.current += vel * dt;
      if (Math.abs(dragProgressRef.current) < 0.004 && Math.abs(vel) < 0.02) {
        rafRef.current = 0;
        clearDrag();
        return;
      }
      applyProgress(clamp(dragProgressRef.current, 0, 1));
      rafRef.current = requestAnimationFrame(tick);
    };
    rafRef.current = requestAnimationFrame(tick);
  };

  function applyProgress(p: number) {
    dragProgressRef.current = p;
    setDragProgressState(p);
    for (let i = 0; i < DASH_COUNT; i++) {
      const broken = p >= (i + 1) / DASH_COUNT;
      if (broken !== prevBrokenRef.current[i]) {
        prevBrokenRef.current[i] = broken;
        if (!reducedRef.current) {
          setJitterIndex(i);
          if (jitterTimerRef.current) clearTimeout(jitterTimerRef.current);
          jitterTimerRef.current = setTimeout(() => setJitterIndex(null), JITTER_MS);
        }
      }
    }
  }

  const onChipPointerDown = (tag: string) => (e: React.PointerEvent<HTMLLIElement>) => {
    if (e.button !== 0) return;
    const el = e.currentTarget;
    el.focus({ preventScroll: true });
    setFocusedTag(tag);
    el.setPointerCapture(e.pointerId);
    dragIdRef.current = tag;
    setDragId(tag);
    dragStartYRef.current = e.clientY;
    prevBrokenRef.current = new Array(DASH_COUNT).fill(false);
    applyProgress(0);
    e.preventDefault();
  };

  // Drag tracking lives on window, not the chip: a chip-scoped pointermove
  // only fires while the pointer stays over the chip's own box (or, for a
  // real mouse, while setPointerCapture is honored — which it isn't for the
  // synthetic pointer events the registry's autoplay preview dispatches).
  // Once the cursor crosses the chip's bottom edge mid-tear, a chip-scoped
  // listener stops receiving events and progress sticks at its last value.
  // Listening on window guarantees delivery regardless of where the pointer
  // physically is, for both real and synthetic pointers. The handlers are
  // kept fresh via refs (updated every render, no dependency array) and
  // wired to window exactly once on mount, so the listener identity never
  // changes and no event is ever dropped between remove/re-add.
  useEffect(() => {
    onWindowPointerMoveRef.current = (e: PointerEvent) => {
      if (!dragIdRef.current) return;
      const dy = Math.max(0, e.clientY - dragStartYRef.current);
      applyProgress(clamp(dy / TEAR_DISTANCE, 0, 1));
    };
    onWindowPointerUpRef.current = () => {
      const tag = dragIdRef.current;
      if (!tag) return;
      if (dragProgressRef.current >= RELEASE_AT) commitDrop(tag);
      else springBack();
    };
    onWindowPointerCancelRef.current = () => {
      if (dragIdRef.current) springBack();
    };
  });

  useEffect(() => {
    const move = (e: PointerEvent) => onWindowPointerMoveRef.current(e);
    const up = (e: PointerEvent) => onWindowPointerUpRef.current(e);
    const cancel = (e: PointerEvent) => onWindowPointerCancelRef.current(e);
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
    window.addEventListener("pointercancel", cancel);
    return () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      window.removeEventListener("pointercancel", cancel);
    };
  }, []);

  const undo = () => {
    const entry = undoStackRef.current.pop();
    if (!entry || tagsRef.current.includes(entry.tag)) return;
    snapshotRects();
    const next = [...tagsRef.current];
    next.splice(Math.min(entry.index, next.length), 0, entry.tag);
    setTags(next);
    setAnnounce(`restored ${entry.tag}`);
  };

  const commitDraft = () => {
    const tag = draft.trim();
    setDraft("");
    if (!tag || tagsRef.current.includes(tag)) return;
    snapshotRects();
    const next = [...tagsRef.current, tag];
    setTags(next);
  };

  const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter" || e.key === ",") {
      e.preventDefault();
      commitDraft();
    }
  };

  const chipIndex = (tag: string) => tagsRef.current.indexOf(tag);

  const onChipKeyDown = (tag: string) => (e: React.KeyboardEvent<HTMLLIElement>) => {
    if (e.key === "Delete" || e.key === "Backspace") {
      e.preventDefault();
      fastFadeRemove(tag);
      return;
    }
    const i = chipIndex(tag);
    if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
      e.preventDefault();
      const dir = e.key === "ArrowLeft" ? -1 : 1;
      const ni = clamp(i + dir, 0, tagsRef.current.length - 1);
      if (dir > 0 && i === tagsRef.current.length - 1) {
        inputRef.current?.focus();
        return;
      }
      const nextTag = tagsRef.current[ni];
      setFocusedTag(nextTag);
      chipRefs.current.get(nextTag)?.focus({ preventScroll: true });
    } else if (e.key === "Home") {
      e.preventDefault();
      const first = tagsRef.current[0];
      if (first) {
        setFocusedTag(first);
        chipRefs.current.get(first)?.focus({ preventScroll: true });
      }
    } else if (e.key === "End") {
      e.preventDefault();
      const last = tagsRef.current[tagsRef.current.length - 1];
      if (last) {
        setFocusedTag(last);
        chipRefs.current.get(last)?.focus({ preventScroll: true });
      }
    }
  };

  const onRootKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if ((e.key === "z" || e.key === "Z") && (e.ctrlKey || e.metaKey) && !e.altKey) {
      if (undoStackRef.current.length > 0) {
        e.preventDefault();
        undo();
      }
    }
  };

  return (
    <div className={`w-full ${className}`} onKeyDown={onRootKeyDown}>
      <style>{`
        @keyframes tag-input-tear-in {
          from { opacity: 0; transform: translateY(4px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @keyframes tag-input-tear-jitter {
          0% { transform: translate(0, 0); }
          30% { transform: translate(0, -1px); }
          60% { transform: translate(0, 1px); }
          100% { transform: translate(0, 0); }
        }
        @keyframes tag-input-tear-fall {
          from { transform: translateY(0) rotate(0deg); opacity: 1; }
          to { transform: translateY(48px) rotate(4deg); opacity: 0; }
        }
        .tag-input-tear-chip { animation: tag-input-tear-in 180ms ease-out backwards; }
        .tag-input-tear-jitter { animation: tag-input-tear-jitter ${JITTER_MS}ms ease-out; }
        .tag-input-tear-fading { transition: opacity ${FADE_MS}ms ease, transform ${FADE_MS}ms ease; opacity: 0; transform: scale(0.94); }
        .tag-input-tear-ghost { animation: tag-input-tear-fall ${FALL_MS}ms cubic-bezier(0.55, 0, 1, 0.45) forwards; }
        @media (prefers-reduced-motion: reduce) {
          .tag-input-tear-chip, .tag-input-tear-jitter, .tag-input-tear-ghost { animation: none; }
          .tag-input-tear-fading { transition: none; opacity: 0; }
        }
      `}</style>

      <div
        onClick={(e) => {
          if (!(e.target as HTMLElement).closest('[role="option"]')) inputRef.current?.focus();
        }}
        className="w-full cursor-text rounded-md border border-border bg-surface px-3 py-2.5 transition-colors duration-150 focus-within:border-muted"
      >
        <ul
          role="listbox"
          aria-label={label}
          className={`flex flex-wrap items-center gap-x-1.5 gap-y-2 ${tags.length > 0 ? "mb-2" : ""}`}
        >
          {tags.map((tag) => {
            const dragging = dragId === tag;
            const progress = dragging ? dragProgress : 0;
            const armed = dragging && progress >= RELEASE_AT;
            const isFading = fadingIds.has(tag);
            const isFocused = focusedTag === tag || (focusedTag === null && chipIndex(tag) === 0);
            return (
              <li
                key={tag}
                ref={(el) => {
                  if (el) chipRefs.current.set(tag, el);
                  else chipRefs.current.delete(tag);
                }}
                role="option"
                tabIndex={isFocused ? 0 : -1}
                onFocus={() => setFocusedTag(tag)}
                onPointerDown={onChipPointerDown(tag)}
                onKeyDown={onChipKeyDown(tag)}
                data-tear-first={chipIndex(tag) === 0 || undefined}
                className={[
                  "tag-input-tear-chip inline-flex h-7 shrink-0 cursor-grab touch-none select-none items-stretch",
                  "overflow-visible rounded-md border border-l-0 border-border bg-background",
                  "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
                  isFading ? "tag-input-tear-fading" : "",
                ].join(" ")}
                style={{
                  transform: dragging
                    ? `translateY(${progress * 14}px) rotate(${progress * 2}deg)`
                    : undefined,
                  transition: dragging ? "none" : undefined,
                }}
              >
                <Perforation progress={progress} jitterIndex={dragging ? jitterIndex : null} armed={armed} />
                <span
                  aria-hidden
                  style={{ width: 4 + progress * 8 }}
                  className="shrink-0"
                />
                <span className="flex min-w-0 max-w-[16ch] items-center truncate pr-2.5 text-xs text-foreground">
                  {tag}
                </span>
              </li>
            );
          })}
        </ul>
        <input
          ref={inputRef}
          type="text"
          aria-label={placeholder}
          value={draft}
          placeholder={placeholder}
          onChange={(e) => setDraft(e.target.value)}
          onKeyDown={onInputKeyDown}
          className="w-full min-w-[8ch] bg-transparent py-0.5 text-sm text-foreground outline-none placeholder:text-muted"
        />
      </div>

      <p role="status" aria-live="polite" className="sr-only">
        {announce}
      </p>

      {ghost ? (
        <div
          aria-hidden
          className="tag-input-tear-ghost pointer-events-none fixed z-50 flex h-7 items-center overflow-hidden rounded-md border border-border bg-background"
          style={{ left: ghost.left, top: ghost.top, width: ghost.width }}
        >
          <Perforation progress={1} jitterIndex={null} armed />
          <span className="truncate px-2.5 text-xs text-foreground">{ghost.tag}</span>
        </div>
      ) : null}
    </div>
  );
}
Use when

Tag input whose chips hang from a perforated edge instead of an x icon: dragging a chip down snaps its dashes into torn stubs top-to-bottom as a 0-1 tear progress crosses each one, and past 80% releasing drops the chip with a 4deg rotate and gravity while siblings close the gap via FLIP; releasing early springs everything back and re-knits the perforation in reverse. Delete/Backspace on a focused chip removes it instantly through a plain 150ms fade, no tear theater, and Ctrl+Z undoes any removal.

Build spec

Build a tag/chip input where every chip is attached by a perforated left edge instead of carrying a delete icon. STRUCTURE: a click-to-focus bg-surface field (cursor-text, focus-within border shift to muted) laid out as a wrapping flex row containing a <ul role=listbox aria-label={label}> of chips (className=contents so its <li> children share the outer flex row) followed by the free-text <input>. Each chip <li role=option> is a fixed h-7 (28px) pill: border on top/right/bottom only (border-l-0, the perforation stands in for the left edge), rounded-md, bg-background, holding an inline SVG strip (viewBox in real px so stroke width never scales) of 7 short horizontal dashes stroke var(--border) evenly spaced down the chip, then a spacer whose width grows with drag progress, then the truncated tag text (max 16ch). DRAG-TO-TEAR (pointer sugar, not a required flow): pointerdown on a chip captures the pointer and focuses it; pointermove maps clamped downward-only dy over a 64px throw to a 0-1 tear progress, mirrored through a ref for the hot path. Each dash's broken state is a pure function of progress (progress >= (index+1)/7) so re-render alone flips it from one intact line to two 1px-offset stubs — no separate reverse-animation path needed for re-knitting. The instant a dash's broken state flips (either direction) it gets a brief 1px vertical translate jitter (a short CSS keyframe class applied only to that dash for ~120ms). While held, the chip translates down and rotates up to 2deg proportional to progress (visual tension); once progress crosses 0.8 the dash stroke color swaps border->accent as an 'armed' cue. On release: progress >= 0.8 commits — the real chip is removed from state immediately (so siblings FLIP-close the gap) while a short-lived fixed-position ghost clone (same rect, captured via getBoundingClientRect before removal) plays a 340ms ease-in rotate-to-4deg + translateY(48px) + fade-out fall, then unmounts; progress < 0.8 (or pointercancel) runs an underdamped spring back to progress 0 (rAF integrator, ~260 s^-2 stiffness, zeta 0.5), which re-knits the dashes in reverse via the same pure-function render, then focus returns to a neighbor chip or the input. FLIP: any tags-array mutation (either removal path, add, undo) first snapshots every surviving chip's getBoundingClientRect via a ref map; a useEffect keyed on the tags array then offsets each surviving chip from its old position back to zero on a spring-approximating cubic-bezier(0.34,1.56,0.64,1) over 380ms, skipped entirely under reduced motion (chips just snap). KEYBOARD (the only required path, drag adds nothing to it): chips carry roving tabindex (one tabIndex=0 at a time, defaulting to the first chip) so Tab reaches the list; ArrowLeft/ArrowRight/Home/End move focus among chips (ArrowRight off the last chip moves into the text input); Delete or Backspace on a focused chip removes it via a fast 150ms opacity+scale fade (no rotate, no perforation theater) and moves focus to a neighbor or the input. Enter or comma in the text input commits the draft as a new tag (trimmed, ignored if empty or a duplicate). A visually-hidden aria-live=polite region announces 'removed {tag}, {n} tags remain' on every removal and 'restored {tag}' on undo. Ctrl+Z / Cmd+Z (checked at the root on keydown) pops a small undo stack of {tag, removalIndex} entries and re-inserts the tag at its original index, reusing the FLIP path so its neighbors slide over for it; new/restored chips play a 180ms fade+rise-in entrance. Reduced motion: the jitter, the fall-ghost, the FLIP replay and the release spring are all skipped; a drag that crosses 0.8 and releases routes straight through the same fast-fade removal keyboard deletion uses, so every removal is either instant-with-fade or nothing. INK: tokens only (background, surface, border, muted, foreground, accent for the armed-dash cue and focus rings); pure DOM + inline SVG + CSS, no canvas, no dependencies.

Tags
tag-inputchipsinputformsvgdraglistboxmicro-interaction