Tag input where Backspace on an empty field arms the last tag with a depleting bar instead of deleting it — a second Backspace inside the window removes it.
npx shadcn add https://design.helpmarq.com /r/tag-input-backspace.jsonregistry/core/tag-input-backspace/component.tsx"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent } from "react";
// A tag field whose one deviation from every other tag field is what Backspace
// does to an empty input. It does not delete the last tag — it *arms* it: the
// pill takes the accent border and a 2px bar inside it depletes left-to-right
// over exactly `armDuration`. A second Backspace inside that window removes it;
// any other key, any click, a blur, or the bar running out disarms. That kills
// the classic accident of destroying three tags because you over-backspaced
// while typing fast, and the depleting bar is what makes the two-step rule
// discoverable instead of mysterious.
//
// The armed state is the whole risk surface, so it is disarmed from four
// independent directions: the timer, the input's own key/change/blur handlers,
// a document pointerdown listener that exists only while something is armed,
// and a guard that drops `armedId` the moment it stops matching a tag that is
// actually in the list (the mouse-reaches-for-another-tag case, where the list
// shifts under a pending timer). Any one of those alone leaves a pill stranded
// wearing the accent ring forever.
//
// Hover is tracked in React rather than :hover so the landing-card autoplay
// driver's synthetic pointer events reach it. One useRef timer, no state-held
// ids, everything cleared on unmount. Colors are tokens only, so both themes
// read correctly. prefers-reduced-motion drops the tag entrance and freezes
// the depletion bar at full width — the armed state stays unmistakable without
// motion, and the live region carries the timing instead.
const DEFAULT_COMMIT_KEYS = ["Enter", ","];
const ENTER_MS = 160;
const ENTER_EASE = "cubic-bezier(0.34, 1.56, 0.64, 1)";
/** Identity of a tag *at its position*. A list edit anywhere before it changes
* the id, which is exactly what the stale-arm guard needs to notice. */
const tagKey = (tag: string, i: number) => `${i} ${tag}`;
export interface LooseThreadProps {
/** Required. Rendered as a real <label> bound to the input. */
label: string;
/** Controlled tag list. Presence of this prop decides the mode, once. */
value?: string[];
/** Initial tag list when uncontrolled. */
defaultValue?: string[];
onChange?: (tags: string[]) => void;
/** Shown only while the field is empty. Default "Add a tag…". */
placeholder?: string;
/** Hard cap on tag count. No default — unlimited. */
max?: number;
allowDuplicates?: boolean;
/** Return the normalized value to accept, or null to reject the entry. */
validate?: (raw: string) => string | null;
/** Keys that commit the draft. Default ["Enter", ","]. */
commitKeys?: string[];
/** ms the armed tag stays armed. Default 2000. */
armDuration?: number;
disabled?: boolean;
id?: string;
className?: string;
}
export function LooseThread({
label,
value,
defaultValue,
onChange,
placeholder = "Add a tag…",
max,
allowDuplicates = false,
validate,
commitKeys = DEFAULT_COMMIT_KEYS,
armDuration = 2000,
disabled = false,
id,
className = "",
}: LooseThreadProps) {
// resolved once, never mirrored into state
const isControlled = value !== undefined;
const [internal, setInternal] = useState<string[]>(() => defaultValue ?? []);
const tags = isControlled ? (value as string[]) : internal;
const [draft, setDraft] = useState("");
const [armedId, setArmedId] = useState<string | null>(null);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [focused, setFocused] = useState(false);
const [invalid, setInvalid] = useState(false);
const [rove, setRove] = useState(0);
const [live, setLive] = useState("");
const timerRef = useRef(0);
const inputRef = useRef<HTMLInputElement>(null);
const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);
const pendingFocus = useRef<number | null>(null);
const autoId = useId();
const inputId = id ?? `${autoId}-input`;
const labelId = `${autoId}-label`;
const hintId = `${autoId}-hint`;
// positional ids — arming identity, deliberately invalidated by any shift
const keys = useMemo(() => tags.map(tagKey), [tags]);
// value ids — React keys and hover, stable when a *different* tag is removed,
// so removing one pill doesn't replay the entrance animation on all the rest
const rkeys = useMemo(() => {
const seen = new Map<string, number>();
return tags.map((tag) => {
const n = seen.get(tag) ?? 0;
seen.set(tag, n + 1);
return `${tag}#${n}`;
});
}, [tags]);
const disarm = useCallback(() => {
clearTimeout(timerRef.current);
setArmedId((prev) => (prev === null ? prev : null));
}, []);
// Defense 1 of 3: the id armed no longer names a tag that exists at that
// position — the list moved under a pending arm. Drop it immediately.
useEffect(() => {
if (armedId !== null && !keys.includes(armedId)) disarm();
}, [keys, armedId, disarm]);
// Defense 2 of 3: any pointer press anywhere, including on another tag's
// remove button, which never reaches the input's key handlers. Listener only
// exists while something is armed, and is capture-phase so it lands first.
useEffect(() => {
if (armedId === null) return;
const onDown = () => disarm();
document.addEventListener("pointerdown", onDown, true);
return () => document.removeEventListener("pointerdown", onDown, true);
}, [armedId, disarm]);
useEffect(() => () => clearTimeout(timerRef.current), []);
// focus lands where the removed tag was, or on the input if nothing is left
useEffect(() => {
const target = pendingFocus.current;
if (target === null) return;
pendingFocus.current = null;
if (target < 0 || tags.length === 0) inputRef.current?.focus();
else btnRefs.current[Math.min(target, tags.length - 1)]?.focus();
}, [tags]);
const setTags = useCallback(
(next: string[]) => {
if (!isControlled) setInternal(next);
onChange?.(next);
},
[isControlled, onChange]
);
const removeAt = useCallback(
(index: number, refocus: number | null) => {
const tag = tags[index];
if (tag === undefined) return;
disarm();
pendingFocus.current = refocus;
setTags(tags.filter((_, i) => i !== index));
setInvalid(false); // the hint line goes back to being a hint
setLive(`Removed ${tag}`);
},
[tags, setTags, disarm]
);
const commit = useCallback(
(raw: string) => {
const trimmed = raw.trim();
if (!trimmed) return; // empty commit is a no-op, not an error
const normalized = validate ? validate(trimmed) : trimmed;
if (normalized === null || normalized === "") {
setInvalid(true);
setLive(`${trimmed} is not a valid tag`);
return;
}
if (max !== undefined && tags.length >= max) {
setInvalid(true);
setLive(`Limit of ${max} tags reached`);
return;
}
if (!allowDuplicates && tags.includes(normalized)) {
setInvalid(true);
setLive(`${normalized} is already added`);
return;
}
setTags([...tags, normalized]);
setDraft("");
setInvalid(false);
setLive(`Added ${normalized}`);
},
[validate, max, allowDuplicates, tags, setTags]
);
const onInputKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
const empty = e.currentTarget.value === "";
if (e.key === "Backspace" && empty) {
if (tags.length === 0) return;
e.preventDefault();
const lastIndex = tags.length - 1;
const lastId = keys[lastIndex];
if (armedId === lastId) {
removeAt(lastIndex, null); // second press inside the window: gone
return;
}
clearTimeout(timerRef.current);
setArmedId(lastId);
setLive(`Press backspace again to remove ${tags[lastIndex]}`);
timerRef.current = window.setTimeout(() => setArmedId(null), armDuration);
return;
}
if (commitKeys.includes(e.key)) {
e.preventDefault();
disarm();
commit(e.currentTarget.value);
return;
}
if (e.key === "ArrowLeft" && empty && tags.length > 0) {
e.preventDefault();
disarm();
setRove(tags.length - 1);
btnRefs.current[tags.length - 1]?.focus();
return;
}
// every other keystroke, Escape included, disarms and throws nothing
disarm();
};
const onTagKeyDown = (e: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {
if (disabled) return;
const last = tags.length - 1;
if (e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "Home" || e.key === "End") {
e.preventDefault();
const next =
e.key === "Home"
? 0
: e.key === "End"
? last
: Math.min(last, Math.max(0, index + (e.key === "ArrowLeft" ? -1 : 1)));
if (e.key === "ArrowRight" && index === last) {
inputRef.current?.focus();
return;
}
setRove(next);
btnRefs.current[next]?.focus();
return;
}
if (e.key === "Escape") {
e.preventDefault();
inputRef.current?.focus();
return;
}
// focusing a tag is explicit intent — no arming step here
if (e.key === "Enter" || e.key === " " || e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
removeAt(index, tags.length === 1 ? -1 : Math.max(0, index - 1));
}
};
const hint = max !== undefined ? `Press Enter to add · ${tags.length} / ${max}` : "Press Enter to add";
// the rejection is only useful where the eye already is: under the field
const hintText = invalid && live ? live : hint;
const caretOn = tags.length > 0 && !focused && !disabled && draft === "";
return (
<div className={["flex w-full flex-col gap-2", className].join(" ")}>
<style>{`
.ns-lt-tag{animation:ns-lt-in ${ENTER_MS}ms ${ENTER_EASE} both}
@keyframes ns-lt-in{from{opacity:0;transform:scale(.92)}to{opacity:1;transform:scale(1)}}
@keyframes ns-lt-deplete{from{transform:scaleX(1)}to{transform:scaleX(0)}}
@keyframes ns-lt-caret{0%,49%{opacity:1}50%,100%{opacity:0}}
.ns-lt-bar{transform-origin:left;animation:ns-lt-deplete var(--ns-lt-arm) linear forwards}
.ns-lt-caret{animation:ns-lt-caret 1.06s step-end infinite}
.ns-lt-field:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in oklab,var(--accent) 16%,transparent)}
.ns-lt-tag[data-armed]{border-color:var(--accent);box-shadow:0 0 0 2px color-mix(in oklab,var(--accent) 22%,transparent)}
.ns-lt-x[data-hover]{background:color-mix(in oklab,var(--foreground) 14%,transparent);color:var(--foreground)}
@media (prefers-reduced-motion: reduce){
.ns-lt-tag{animation:none}
.ns-lt-bar{animation:none;transform:scaleX(1)}
.ns-lt-caret{animation:none}
.ns-lt-field,.ns-lt-x{transition:none}
}`}</style>
<label
id={labelId}
htmlFor={inputId}
className="text-[13px] font-medium tracking-tight text-foreground"
>
{label}
</label>
<div
role="group"
aria-labelledby={labelId}
onMouseDown={(e) => {
// clicking anywhere that isn't a remove button or the input itself
// puts the caret in the input, the way a real field behaves
const target = e.target as HTMLElement;
if (disabled || target === inputRef.current || target.closest("button")) return;
e.preventDefault();
inputRef.current?.focus();
}}
className={[
"ns-lt-field flex flex-wrap items-center gap-[6px] rounded-sm border border-border px-2 py-[6px]",
"bg-surface transition-[border-color,box-shadow] duration-150 ease-out",
disabled ? "cursor-not-allowed opacity-55" : "cursor-text",
].join(" ")}
>
{tags.map((tag, i) => {
const rkey = rkeys[i];
const armed = armedId === keys[i];
return (
<span
key={rkey}
data-armed={armed || undefined}
style={armed ? ({ "--ns-lt-arm": `${armDuration}ms` } as CSSProperties) : undefined}
className={[
"ns-lt-tag relative flex items-center overflow-hidden rounded-full border border-border",
"py-[2px] pl-[10px] pr-[4px]",
"bg-background text-[13px] leading-5 text-foreground",
"transition-[border-color,box-shadow] duration-150 ease-out",
].join(" ")}
>
{tag}
<button
ref={(el) => {
btnRefs.current[i] = el;
}}
type="button"
tabIndex={i === Math.min(rove, tags.length - 1) ? 0 : -1}
disabled={disabled}
data-hover={hoveredId === rkey || undefined}
aria-label={`Remove ${tag}`}
onPointerEnter={() => setHoveredId(rkey)}
onPointerLeave={() => setHoveredId((prev) => (prev === rkey ? null : prev))}
onFocus={() => setRove(i)}
onKeyDown={(e) => onTagKeyDown(e, i)}
onClick={() => removeAt(i, i === 0 ? -1 : i - 1)}
className={[
"ns-lt-x ml-[4px] flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full",
"text-muted transition-[color,background-color] duration-150 ease-out",
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent",
].join(" ")}
>
<svg viewBox="0 0 12 12" aria-hidden className="h-[10px] w-[10px]">
<path
d="M3 3l6 6M9 3l-6 6"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
</button>
{armed ? (
<span
aria-hidden
className="ns-lt-bar pointer-events-none absolute bottom-0 left-0 right-0 h-[2px] bg-accent"
/>
) : null}
</span>
);
})}
{/* resting caret: the field reads as a live text field in a still frame,
and hands off to the real caret the moment it is focused */}
<span
aria-hidden
className={[
"h-[15px] w-px shrink-0 bg-foreground",
caretOn ? "ns-lt-caret" : "opacity-0",
].join(" ")}
/>
<input
ref={inputRef}
id={inputId}
type="text"
value={draft}
disabled={disabled}
placeholder={tags.length === 0 ? placeholder : ""}
aria-describedby={hintId}
aria-invalid={invalid || undefined}
autoComplete="off"
onChange={(e) => {
disarm();
setInvalid(false);
setDraft(e.target.value);
}}
onFocus={() => setFocused(true)}
onBlur={() => {
setFocused(false);
disarm(); // Defense 3 of 3: the pointer left for somewhere else
}}
onKeyDown={onInputKeyDown}
className={[
"min-w-[8ch] flex-1 border-0 bg-transparent p-0 text-[13px] leading-5 text-foreground",
"outline-none placeholder:text-muted disabled:cursor-not-allowed",
].join(" ")}
/>
</div>
<p id={hintId} className="text-[12px] leading-4 text-muted">
{hintText}
</p>
<span className="sr-only" role="status" aria-live="polite">
{live}
</span>
</div>
);
}
A tag / chip multi-input — a bordered field of rounded pills each with an 18px circular remove button, a borderless flex-1 text input, and a muted hint line below that doubles as the rejection message. Its one deviation from every other tag field is what Backspace does to an empty input: it does not delete the last tag, it arms it. The armed pill takes an accent border and a 2px accent bar inside it depletes left-to-right over exactly armDuration (default 2000ms); a second Backspace inside that window removes it, and any other keystroke, any pointer press anywhere, a blur, or the bar running out disarms. That removes the classic accident of destroying three tags because you over-backspaced while typing fast, and the depleting bar makes the two-step rule discoverable rather than mysterious. The armed state is disarmed from four independent directions — the timer, the input's key/change/blur handlers, a capture-phase document pointerdown listener that exists only while something is armed, and a guard that drops the armed id the moment it stops matching a tag actually present at that position — because the reach-for-the-mouse case shifts the tag list under a pending timer and would otherwise strand a pill wearing the accent ring forever. Controlled or uncontrolled, resolved once from whether `value` is passed and never mirrored into state. Commit keys default to Enter and comma; entries pass through an optional validate that returns a normalized string or null to reject, with duplicates and a max cap rejected the same way and announced. Full keyboard model: clicking the field focuses the input, ArrowLeft from an empty input jumps to the last tag, remove buttons use roving tabindex with ArrowLeft/ArrowRight/Home/End, Enter, Space, Delete or Backspace on a focused tag removes it immediately (explicit focus is explicit intent, so no arming there), Escape returns to the input, and a visually-hidden polite live region announces additions, removals and the arming prompt. Hover is tracked in React rather than :hover so synthetic pointer events reach it. Zero dependencies, one useRef timer cleared on unmount, colors from --background, --foreground, --surface, --border, --muted and --accent only, so both themes read correctly. prefers-reduced-motion drops the tag entrance and the caret blink and freezes the depletion bar at full width, leaving the armed state unmistakable without motion while the live region carries the timing.