"use client";
import * as React from "react";
import { AnimatePresence, motion, useAnimate } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export interface TagInputProps
extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue" | "children"> {
/** Controlled list of tags. */
value?: string[];
/** Initial tags when uncontrolled. */
defaultValue?: string[];
/** Called with the full list whenever a tag is added or removed. */
onChange?: (tags: string[]) => void;
/** Visible label, rendered as a real <label> for the text input. */
label?: React.ReactNode;
/** Helper text below the field (linked via aria-describedby). */
hint?: React.ReactNode;
/** Error message. Any truthy value marks the field invalid; a node is also shown and announced. */
error?: React.ReactNode;
placeholder?: string;
/** Maximum number of tags. 0 means no limit. */
maxTags?: number;
/** Optional suggestions, shown in an accessible listbox while typing (or on ArrowDown). */
suggestions?: string[];
/** Treat "React" and "react" as different tags. */
caseSensitive?: boolean;
/** Turn pending text into a tag when the field loses focus. */
addOnBlur?: boolean;
/** Focus colour, chip highlight and caret (any CSS colour). */
accent?: string;
disabled?: boolean;
/** When set, a hidden input submits the tags joined with `delimiter` in native forms. */
name?: string;
/** Separator used for the hidden form value. */
delimiter?: string;
/** id for the text input (generated when omitted). */
inputId?: string;
}
/** Characters that split pasted or typed text into several tags. */
const SPLIT = /[,\n\r\t]+/;
const MAX_SUGGESTIONS = 8;
/**
* A chips input. Enter, comma or Tab adds the typed text; Backspace on an empty field selects
* the last chip and a second press removes it; arrows move between chips. Pasted lists are
* split on commas and new lines, duplicates are rejected with a shake, and optional suggestions
* use the ARIA combobox pattern with aria-activedescendant.
*/
export function TagInput({
value,
defaultValue,
onChange,
label = "Tags",
hint,
error,
placeholder = "Add a tag…",
maxTags = 8,
suggestions,
caseSensitive = false,
addOnBlur = false,
accent = "#FF7448",
disabled = false,
name,
delimiter = ",",
inputId: inputIdProp,
className = "",
style,
...props
}: TagInputProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const inputId = inputIdProp ?? `tag-${uid}`;
const labelId = `${inputId}-label`;
const hintId = `${inputId}-hint`;
const errorId = `${inputId}-error`;
const helpId = `${inputId}-help`;
const listboxId = `${inputId}-listbox`;
const optionId = (i: number) => `${inputId}-option-${i}`;
const [inner, setInner] = React.useState<string[]>(() => defaultValue ?? []);
const tags = value ?? inner;
const [text, setText] = React.useState("");
const [open, setOpen] = React.useState(false);
const [activeIndex, setActiveIndex] = React.useState(-1);
const [announcement, setAnnouncement] = React.useState("");
const [flash, setFlash] = React.useState<string | null>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLUListElement>(null);
const chipButtons = React.useRef<(HTMLButtonElement | null)[]>([]);
const flashTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [scope, animate] = useAnimate<HTMLDivElement>();
React.useEffect(() => () => clearTimeout(flashTimer.current), []);
const limit = maxTags > 0 ? maxTags : Infinity;
const atLimit = tags.length >= limit;
const keyOf = (t: string) => (caseSensitive ? t : t.toLowerCase());
const indexOfTag = (t: string, list: string[] = tags) => list.findIndex((x) => keyOf(x) === keyOf(t));
const invalid = Boolean(error);
const hasErrorText = invalid && error !== true;
// Suggestions are derived during render: unused entries that contain the query.
const query = text.trim().toLowerCase();
const matches = (suggestions ?? [])
.filter((s) => indexOfTag(s) === -1 && (!query || s.toLowerCase().includes(query)))
.slice(0, MAX_SUGGESTIONS);
const listOpen = open && !disabled && !atLimit && matches.length > 0;
const active = listOpen && activeIndex < matches.length ? activeIndex : -1;
/** Polite announcement; a trailing space toggles so repeated messages are re-read. */
const announce = (message: string) => setAnnouncement((prev) => (prev === message ? `${message} ` : message));
const setTags = (next: string[]) => {
if (value === undefined) setInner(next);
onChange?.(next);
};
const shake = (target: Element | null) => {
if (!target) return;
if (!reduceMotion) animate(target, { x: [0, -7, 6, -4, 3, -1, 0] }, { duration: 0.4, ease: "easeOut" });
};
/** Highlights (and shakes) an existing chip, e.g. when its duplicate is rejected. */
const flashTag = (tag: string) => {
setFlash(keyOf(tag));
clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => setFlash(null), 700);
shake(scope.current?.querySelector(`[data-chip-key="${CSS.escape(keyOf(tag))}"]`) ?? null);
};
/** Adds every candidate it can. Returns true if the pending text was fully consumed. */
const addTags = (candidates: string[]) => {
const next = [...tags];
const added: string[] = [];
const duplicates: string[] = [];
let overLimit = 0;
for (const raw of candidates) {
const tag = raw.trim();
if (!tag) continue;
if (indexOfTag(tag, next) !== -1) {
duplicates.push(tag);
continue;
}
if (next.length >= limit) {
overLimit++;
continue;
}
next.push(tag);
added.push(tag);
}
if (added.length) setTags(next);
const parts: string[] = [];
if (added.length === 1) parts.push(`Added ${added[0]}.`);
else if (added.length > 1) parts.push(`Added ${added.length} tags.`);
if (duplicates.length) {
parts.push(duplicates.length === 1 ? `${duplicates[0]} is already added.` : `${duplicates.length} duplicates skipped.`);
flashTag(duplicates[0]);
}
if (overLimit) {
parts.push(`Limit of ${limit} tags reached.`);
shake(scope.current);
}
if (parts.length) announce(parts.join(" "));
return added.length > 0 || duplicates.length > 0;
};
const commitText = () => {
if (!text.trim()) return false;
const consumed = addTags([text]);
// Keep the text when the limit blocked it, so nothing typed is lost.
if (consumed) setText("");
setActiveIndex(-1);
return true;
};
const chooseSuggestion = (index: number) => {
const s = matches[index];
if (s === undefined) return;
addTags([s]);
setText("");
setActiveIndex(-1);
inputRef.current?.focus();
};
const removeAt = (index: number, focusAfter: "previous" | "next" | "input" = "input") => {
const tag = tags[index];
if (tag === undefined || disabled) return;
const next = tags.filter((_, i) => i !== index);
setTags(next);
announce(`Removed ${tag}.`);
// Focus moves after React commits; the chip list has already shrunk by then.
requestAnimationFrame(() => {
if (focusAfter === "previous" && index > 0) chipButtons.current[index - 1]?.focus();
else if (focusAfter === "next" && index < next.length) chipButtons.current[index]?.focus();
else inputRef.current?.focus();
});
};
const focusChip = (index: number) => {
if (index < 0) return;
if (index >= tags.length) inputRef.current?.focus();
else chipButtons.current[index]?.focus();
};
const scrollOptionIntoView = (index: number) => {
const list = listRef.current;
const option = list?.children[index] as HTMLElement | undefined;
if (!list || !option) return;
// Scroll the listbox only; scrollIntoView would also move the page.
if (option.offsetTop < list.scrollTop) list.scrollTop = option.offsetTop;
else if (option.offsetTop + option.offsetHeight > list.scrollTop + list.clientHeight)
list.scrollTop = option.offsetTop + option.offsetHeight - list.clientHeight;
};
const moveActive = (delta: number) => {
if (!matches.length) return;
const next = active === -1 ? (delta > 0 ? 0 : matches.length - 1) : (active + delta + matches.length) % matches.length;
setOpen(true);
setActiveIndex(next);
scrollOptionIntoView(next);
};
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing) return;
const caretAtStart = event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd === 0;
switch (event.key) {
case "Enter":
if (active !== -1) {
event.preventDefault();
chooseSuggestion(active);
} else if (text.trim()) {
event.preventDefault();
commitText();
}
break;
case ",":
event.preventDefault();
commitText();
break;
case "Tab":
if (event.shiftKey) break;
if (active !== -1) {
event.preventDefault();
chooseSuggestion(active);
} else if (text.trim()) {
event.preventDefault();
commitText();
}
break;
case "ArrowDown":
if (matches.length) {
event.preventDefault();
moveActive(1);
}
break;
case "ArrowUp":
if (listOpen) {
event.preventDefault();
moveActive(-1);
}
break;
case "Escape":
if (listOpen) {
event.preventDefault();
setOpen(false);
setActiveIndex(-1);
} else if (text) {
event.preventDefault();
setText("");
}
break;
case "Backspace":
// First press selects the last chip, the next press (on the chip) removes it.
if (!text && tags.length) {
event.preventDefault();
focusChip(tags.length - 1);
}
break;
case "ArrowLeft":
if (caretAtStart && tags.length) {
event.preventDefault();
focusChip(tags.length - 1);
}
break;
case "Home":
if (caretAtStart && tags.length) {
event.preventDefault();
focusChip(0);
}
break;
}
};
const handleChipKeyDown = (index: number, event: React.KeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
case "Backspace":
event.preventDefault();
removeAt(index, index > 0 ? "previous" : "next");
break;
case "Delete":
event.preventDefault();
removeAt(index, "next");
break;
case "ArrowLeft":
event.preventDefault();
focusChip(Math.max(0, index - 1));
break;
case "ArrowRight":
event.preventDefault();
focusChip(index + 1);
break;
case "Home":
event.preventDefault();
focusChip(0);
break;
case "End":
case "Escape":
event.preventDefault();
inputRef.current?.focus();
break;
default:
// Typing on a chip returns to the text field; the keystroke lands there.
if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) inputRef.current?.focus();
}
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const raw = event.target.value;
// Soft keyboards often report "," as an unidentified key: split here as well.
if (SPLIT.test(raw)) {
const parts = raw.split(SPLIT);
const rest = parts.pop() ?? "";
addTags(parts);
setText(rest);
} else {
setText(raw);
}
setOpen(true);
setActiveIndex(-1);
};
const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
const pasted = event.clipboardData.getData("text");
if (!SPLIT.test(pasted)) return; // a single word pastes normally
event.preventDefault();
addTags([text + pasted.split(SPLIT)[0], ...pasted.split(SPLIT).slice(1)]);
setText("");
};
const describedBy =
[helpId, hint ? hintId : null, hasErrorText ? errorId : null, props["aria-describedby"]].filter(Boolean).join(" ") || undefined;
return (
<div
className={`relative w-full ${className}`}
style={{ ["--ti-accent" as string]: accent, ...style }}
{...props}
aria-describedby={undefined}
>
<div className="mb-2 flex items-baseline justify-between gap-3">
<label
id={labelId}
htmlFor={inputId}
className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400"
>
{label}
</label>
{Number.isFinite(limit) && (
<span aria-hidden="true" className="font-mono text-[10px] tabular-nums tracking-[0.14em] text-zinc-400 dark:text-zinc-500">
{tags.length}/{limit}
</span>
)}
</div>
<div
ref={scope}
onMouseDown={(event) => {
// Clicking empty space in the field focuses the text input.
if (event.target === event.currentTarget) {
event.preventDefault();
inputRef.current?.focus();
}
}}
className={`relative flex min-h-12 w-full cursor-text flex-wrap items-center gap-1.5 rounded-xl border bg-white px-2 py-1.5 transition-[border-color,box-shadow] duration-150 dark:bg-zinc-950 motion-reduce:transition-none ${
invalid
? "border-rose-500/80 dark:border-rose-400/80"
: "border-zinc-900/15 hover:border-zinc-900/30 focus-within:border-[var(--ti-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ti-accent)_22%,transparent)] dark:border-white/15 dark:hover:border-white/30"
} ${disabled ? "pointer-events-none opacity-50" : ""}`}
>
{/* display:contents keeps chips and the input in one wrapping row; role="list" is explicit for Safari. */}
<ul role="list" aria-label="Added tags" className="contents">
<AnimatePresence initial={false} mode="popLayout">
{tags.map((tag, i) => {
const key = keyOf(tag);
return (
<motion.li
key={key}
layout={reduceMotion ? false : "position"}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
// Exiting chips stop taking clicks so a stale remove button can't fire twice.
exit={reduceMotion ? { opacity: 0, pointerEvents: "none" } : { opacity: 0, scale: 0.6, pointerEvents: "none" }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 32, mass: 0.6 }}
className="flex"
>
<span
data-chip-key={key}
className={`group/chip inline-flex h-8 max-w-[16rem] items-center gap-1 rounded-lg border pl-2.5 pr-1 text-[13px] font-medium text-zinc-900 transition-[border-color,background-color] duration-150 has-[:focus-visible]:border-[var(--ti-accent)] has-[:focus-visible]:bg-[color-mix(in_srgb,var(--ti-accent)_14%,transparent)] dark:text-zinc-100 motion-reduce:transition-none ${
flash === key
? "border-[var(--ti-accent)] bg-[color-mix(in_srgb,var(--ti-accent)_18%,transparent)]"
: "border-zinc-900/10 bg-zinc-900/[0.04] dark:border-white/10 dark:bg-white/[0.06]"
}`}
>
<span className="truncate">{tag}</span>
<button
ref={(el) => {
chipButtons.current[i] = el;
}}
type="button"
tabIndex={-1}
disabled={disabled}
aria-label={`Remove ${tag}`}
onClick={() => removeAt(i)}
onKeyDown={(e) => handleChipKeyDown(i, e)}
className="grid size-6 shrink-0 place-items-center rounded-md text-zinc-500 outline-none transition-colors hover:bg-zinc-900/10 hover:text-zinc-900 focus-visible:bg-[var(--ti-accent)] focus-visible:text-white dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-white dark:focus-visible:text-zinc-950"
>
<svg aria-hidden="true" viewBox="0 0 12 12" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
</span>
</motion.li>
);
})}
</AnimatePresence>
</ul>
<input
ref={inputRef}
id={inputId}
type="text"
role={suggestions ? "combobox" : undefined}
aria-expanded={suggestions ? listOpen : undefined}
aria-controls={suggestions ? listboxId : undefined}
aria-autocomplete={suggestions ? "list" : "none"}
aria-activedescendant={active !== -1 ? optionId(active) : undefined}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
enterKeyHint="enter"
disabled={disabled}
value={text}
placeholder={atLimit ? "Limit reached" : tags.length ? "" : placeholder}
onChange={handleChange}
onKeyDown={handleInputKeyDown}
onPaste={handlePaste}
onClick={() => setOpen(true)}
onBlur={(event) => {
setOpen(false);
setActiveIndex(-1);
// Moving to a chip in the same field is not "leaving" it.
const toChip = event.relatedTarget instanceof Node && scope.current?.contains(event.relatedTarget);
if (addOnBlur && !toChip) commitText();
}}
className="h-8 min-w-[8ch] flex-1 bg-transparent px-1.5 text-[14px] text-zinc-950 caret-[var(--ti-accent)] outline-none placeholder:text-zinc-400 disabled:cursor-not-allowed dark:text-zinc-50 dark:placeholder:text-zinc-500"
/>
<AnimatePresence>
{listOpen && (
<motion.ul
ref={listRef}
id={listboxId}
role="listbox"
aria-label="Suggestions"
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, transition: { duration: 0.1 } }}
transition={{ duration: reduceMotion ? 0 : 0.16, ease: [0.2, 0.7, 0.2, 1] }}
className="absolute inset-x-0 top-[calc(100%+6px)] z-20 max-h-60 overflow-y-auto rounded-xl border border-zinc-900/10 bg-white p-1 shadow-[0_12px_32px_-12px_rgba(0,0,0,0.25)] dark:border-white/10 dark:bg-zinc-900"
>
{matches.map((s, i) => {
const at = query ? s.toLowerCase().indexOf(query) : -1;
return (
<li
key={s}
id={optionId(i)}
role="option"
aria-selected={i === active}
// Keep focus in the input while choosing with the mouse.
onMouseDown={(e) => e.preventDefault()}
onClick={() => chooseSuggestion(i)}
onPointerMove={() => {
if (i !== active) setActiveIndex(i);
}}
className={`flex cursor-pointer items-center justify-between rounded-lg px-2.5 py-2 text-[13px] text-zinc-700 dark:text-zinc-300 ${
i === active ? "bg-zinc-900/[0.06] text-zinc-950 dark:bg-white/[0.08] dark:text-white" : ""
}`}
>
<span className="truncate">
{at === -1 ? (
s
) : (
<>
{s.slice(0, at)}
<mark className="bg-transparent font-semibold text-zinc-950 underline decoration-[var(--ti-accent)] decoration-2 underline-offset-2 dark:text-white">
{s.slice(at, at + query.length)}
</mark>
{s.slice(at + query.length)}
</>
)}
</span>
{i === active && (
<span aria-hidden="true" className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400">
Enter
</span>
)}
</li>
);
})}
</motion.ul>
)}
</AnimatePresence>
</div>
<span id={helpId} className="sr-only">
{tags.length} {tags.length === 1 ? "tag" : "tags"}
{Number.isFinite(limit) ? ` of ${limit}` : ""}. Press Enter or comma to add. Backspace or Left arrow reaches added tags; Backspace or
Delete removes the selected tag.
</span>
{hint && (
<p id={hintId} className="mt-1.5 text-[13px] leading-snug text-zinc-500 dark:text-zinc-400">
{hint}
</p>
)}
<div aria-live="polite">
<AnimatePresence initial={false}>
{hasErrorText && (
<motion.p
key="error"
id={errorId}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}
animate={reduceMotion ? { opacity: 1 } : { opacity: 1, height: "auto", y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}
transition={{ duration: reduceMotion ? 0 : 0.2, ease: [0.2, 0.7, 0.2, 1] }}
className="overflow-hidden text-[13px] font-medium leading-snug text-rose-600 dark:text-rose-400"
>
<span className="block pt-1.5">{error}</span>
</motion.p>
)}
</AnimatePresence>
</div>
{/* Add/remove/duplicate announcements. */}
<span className="sr-only" aria-live="polite" aria-atomic="true">
{announcement}
</span>
{name && <input type="hidden" name={name} value={tags.join(delimiter)} />}
</div>
);
}