Skip to main content

ns-ui

Tag Input Cord

Tag input as beads threaded onto a cord: committing a tag cinches a bead into place with one overshoot squeeze while its knot draws on; Backspace on an empty input unravels the last bead with a drop-and-twist. Duplicate commits shudder the existing bead instead of re-threading it.

Usage

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

import { CinchBead } from "./component";

export default function CinchBeadDemo() {
  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-cord
        </p>
        <CinchBead
          label="Issue labels"
          placeholder="Add a label…"
          defaultTags={["bug", "regression", "p1"]}
        />
        <p className="mt-3 font-mono text-[11px] text-ns-muted">
          Enter or comma cinches a bead onto the cord — Backspace on an empty
          input unravels the last one
        </p>
      </div>
    </main>
  );
}

Component

registry/core/tag-input-cord/component.tsx
"use client";

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

// Tag input as beads cinched onto a cord: committed tags thread onto a wire
// of short cord segments, each arriving with a cinch — a slide in from the
// input with one overshoot squeeze — while its knot draws on. Backspace on an
// empty input unravels the last bead (a drop-and-twist exit) before it leaves
// the cord. Duplicates don't re-thread; the existing bead shudders instead.
// Pure DOM+SVG+CSS keyframes; the only JS timers park exiting beads for the
// length of their unravel animation and are cleared on unmount.

export interface CinchBeadProps {
  /** initial tag list */
  defaultTags?: string[];
  /** placeholder shown only while the list is empty */
  placeholder?: string;
  /** accessible name for the text input */
  label?: string;
  /** hard cap on tag count. No default — unlimited. */
  maxTags?: number;
  /** 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 Knot() {
  return (
    <svg aria-hidden width={10} height={10} viewBox="0 0 10 10" className="shrink-0">
      <circle
        cx={5}
        cy={5}
        r={3.2}
        fill="none"
        stroke="var(--ns-muted)"
        strokeWidth={1.5}
        pathLength={1}
        className="cinch-knot"
      />
    </svg>
  );
}

function Cord({ className = "" }: { className?: string }) {
  return <span aria-hidden className={["h-px shrink-0 bg-border", className].join(" ")} />;
}

export function CinchBead({
  defaultTags = [],
  placeholder = "Add a tag…",
  label = "Tags",
  maxTags = Infinity,
  onChange,
  className = "",
}: CinchBeadProps) {
  const [tags, setTags] = useState<string[]>(defaultTags);
  const [leaving, setLeaving] = useState<string[]>([]);
  const [shaking, setShaking] = useState<string | null>(null);
  const [draft, setDraft] = useState("");
  const inputRef = useRef<HTMLInputElement>(null);
  const timersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
  const reduced = useRef(
    typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches,
  ).current;

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

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

  const commit = (raw: string) => {
    const tag = raw.trim();
    if (!tag) return;
    if (tags.includes(tag)) {
      // already threaded — shudder the existing bead instead
      setShaking(tag);
      park(() => setShaking(null), 300);
      setDraft("");
      return;
    }
    if (tags.length >= maxTags) return;
    const next = [...tags, tag];
    setTags(next);
    setDraft("");
    onChange?.(next);
  };

  const unravel = (tag: string) => {
    if (leaving.includes(tag)) return;
    setLeaving((l) => [...l, tag]);
    const drop = () => {
      setLeaving((l) => l.filter((t) => t !== tag));
      setTags((prev) => {
        const next = prev.filter((t) => t !== tag);
        onChange?.(next);
        return next;
      });
    };
    reduced ? drop() : park(drop, 240);
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter" || e.key === ",") {
      e.preventDefault();
      commit(draft);
    } else if (e.key === "Backspace" && draft === "" && tags.length > 0) {
      const last = [...tags].reverse().find((t) => !leaving.includes(t));
      if (last) unravel(last);
    }
  };

  return (
    <div
      onClick={() => inputRef.current?.focus()}
      className={[
        "flex w-full cursor-text flex-wrap items-center rounded-md border border-border bg-surface px-3 py-2.5",
        "transition-colors duration-150 focus-within:border-ns-muted",
        className,
      ].join(" ")}
    >
      <style>{`
        @keyframes cinch-in {
          0% { opacity: 0; transform: translateX(18px) scaleX(1.1); }
          62% { opacity: 1; transform: translateX(-2px) scaleX(0.97); }
          100% { opacity: 1; transform: translateX(0) scaleX(1); }
        }
        @keyframes cinch-knot-draw {
          from { stroke-dashoffset: 1; }
          to { stroke-dashoffset: 0; }
        }
        @keyframes cinch-unravel {
          to { opacity: 0; transform: translateY(12px) rotate(8deg); }
        }
        @keyframes cinch-shudder {
          0%, 100% { transform: translateX(0); }
          25% { transform: translateX(-3px); }
          55% { transform: translateX(2.5px); }
          80% { transform: translateX(-1.5px); }
        }
        .tag-input-cord-chip { animation: cinch-in 280ms cubic-bezier(0.22, 1, 0.36, 1) backwards; }
        .tag-input-cord-chip .cinch-knot {
          stroke-dasharray: 1;
          animation: cinch-knot-draw 320ms 120ms ease-out backwards;
        }
        .tag-input-cord-chip[data-leaving="true"] {
          animation: cinch-unravel 240ms cubic-bezier(0.55, 0, 0.55, 0.2) forwards;
        }
        .tag-input-cord-chip[data-shaking="true"] { animation: cinch-shudder 280ms ease-in-out; }
        @media (prefers-reduced-motion: reduce) {
          .tag-input-cord-chip, .tag-input-cord-chip .cinch-knot,
          .tag-input-cord-chip[data-leaving="true"],
          .tag-input-cord-chip[data-shaking="true"] { animation: none; }
        }
      `}</style>
      <Cord className="w-2" />
      {tags.map((tag) => (
        <span key={tag} className="flex items-center">
          <span
            data-leaving={leaving.includes(tag) || undefined}
            data-shaking={shaking === tag || undefined}
            className="tag-input-cord-chip flex items-center gap-1.5 rounded-full border border-border bg-background py-1 pl-2 pr-1"
          >
            <Knot />
            <span className="max-w-[16ch] truncate text-xs text-foreground">{tag}</span>
            <button
              type="button"
              aria-label={`Remove ${tag}`}
              onClick={(e) => {
                e.stopPropagation();
                unravel(tag);
              }}
              className="grid size-4 cursor-pointer place-items-center rounded-full text-ns-muted transition-colors duration-150 hover:bg-border hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ns-accent"
            >
              <svg aria-hidden width={8} height={8} viewBox="0 0 8 8">
                <path
                  d="M 1.5 1.5 L 6.5 6.5 M 6.5 1.5 L 1.5 6.5"
                  stroke="currentColor"
                  strokeWidth={1.4}
                  strokeLinecap="round"
                />
              </svg>
            </button>
          </span>
          <Cord className="w-3" />
        </span>
      ))}
      <input
        ref={inputRef}
        type="text"
        aria-label={label}
        value={draft}
        placeholder={tags.length === 0 ? placeholder : ""}
        onChange={(e) => setDraft(e.target.value)}
        onKeyDown={onKeyDown}
        className="ml-1.5 min-w-[8ch] flex-1 bg-transparent py-0.5 text-sm text-foreground outline-none placeholder:text-ns-muted"
      />
      <span aria-live="polite" className="sr-only">
        {tags.length} tags
      </span>
    </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-cord.tsx, so point the import there.

Use when entry is the moment worth designing, since each accepted tag cinches into place as a bead on a visible cord and a duplicate shudders the existing bead instead of silently adding nothing, so rejection and repetition are legible where the user is typing; pick tag-input-pull or tag-input-tear instead when the interesting problem is removal, or tag-input-backspace when accidental Backspace deletion is the specific complaint.

Install

npx shadcn add https://design.helpmarq.com/r/tag-input-cord.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 input whose chips read as beads on a wire. STRUCTURE: a click-to-focus bg-surface field (cursor-text, focus-within border shift to muted) laid out as a wrapping flex row: a short cord stub (1px-tall bg-border span), then per tag a pill chip followed by a 12px cord segment, then the flex-1 text input — the h-px segments between pills are what makes the row read as one threaded cord, and they wrap naturally with the chips. Each chip is a rounded-full bg-background bordered pill holding a 10px SVG knot (circle, stroke var(--ns-muted), pathLength 1), the truncated tag text, and a 16px remove button (inline SVG x, aria-label 'Remove {tag}', hover bg-border, focus-visible accent outline). MOTION, all CSS keyframes scoped in a <style> tag: entry 'cinch-in' slides the chip from translateX(18px) scaleX(1.1) through a -2px/0.97 overshoot at 62% into rest (280ms ease-out-back cubic-bezier(0.22,1,0.36,1), fill backwards) while the knot circle draws on via stroke-dashoffset 1->0 delayed 120ms; exit 'cinch-unravel' drops translateY(12px) rotate(8deg) to opacity 0 over 240ms ease-in (fill forwards) — the tag is parked in a 'leaving' list and removed by a setTimeout matching the animation, all timers tracked in a Set and cleared on unmount; duplicate commit fires 'cinch-shudder', a +-3px decaying x-shake on the EXISTING bead, instead of adding. INTERACTION: Enter or comma commits (preventDefault so the comma never lands in the value), trims, ignores empty; Backspace on an empty input unravels the newest non-leaving bead; each remove button unravels its own. onChange fires with the new tag array on every add/remove; a visually-hidden aria-live span announces the count. maxTags caps additions silently. Reduced motion: a media query kills all four animations and the leaving-park executes synchronously. INK: tokens only (background, surface, border, muted, foreground, accent for focus rings); no canvas, no observers.

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 text input
maxTags?numberInfinityhard cap on tag count. No default — unlimited.
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