Skip to main content

ns-ui

Tag Input Tear

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.

Usage

registry/core/tag-input-tear/demo.tsx
"use client";

import { TearTab } from "./component";

export default function TearTabDemo() {
  return (
    <main className="flex min-h-screen items-center justify-center bg-background p-6">
      <div className="w-full max-w-md">
        <p className="mb-4 font-mono text-xs tracking-widest text-ns-muted">
          ns-ui / tag-input-tear
        </p>
        <TearTab
          label="Recipients"
          placeholder="Add a recipient…"
          defaultTags={["design", "engineering", "growth", "on-call", "p1"]}
        />
        <p className="mt-3 font-mono text-[11px] text-ns-muted">
          Drag a chip down past the perforation to tear it off — release early
          and it springs back. Delete/Backspace on a focused chip removes it
          instantly; Ctrl+Z undoes.
        </p>
      </div>
    </main>
  );
}

Component

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 {
  /** initial tag list */
  defaultTags?: string[];
  /** placeholder shown only while the list is empty */
  placeholder?: string;
  /** accessible name for the chip listbox */
  label?: string;
  /** called with the full tag list after an add or remove */
  onChange?: (tags: string[]) => void;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

function Perforation({
  progress,
  jitterIndex,
  armed,
}: {
  progress: number;
  jitterIndex: number | null;
  armed: boolean;
}) {
  const stroke = armed ? "var(--ns-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-ns-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-ns-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-ns-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>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/tag-input-tear.tsx, so point the import there.

Use when removal should be a graded physical commitment the user can abandon halfway, dragging a chip down to tear its perforation dash by dash with a spring-back if released before 80%, while Delete on a focused chip stays an instant no-theater removal with undo; pick tag-input-pull instead for a shorter one-shot stretch-and-detach past a 64px threshold with no partial state to back out of, or tag-input-backspace when the accidental removals happen at the keyboard rather than the pointer.

Install

npx shadcn add https://design.helpmarq.com/r/tag-input-tear.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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.

Props

PropTypeDefaultDescription
defaultTags?string[][]initial tag list
placeholder?string"Add a tag…"placeholder shown only while the list is empty
label?string"Tags"accessible name for the chip listbox
onChange?(tags: string[]) => void—called with the full tag list after an add or remove
className?string—extra classes merged onto the rendered root element