"use client"; import { PlusIcon, XIcon } from "lucide-react"; import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from "motion/react"; import { type ClipboardEvent, type KeyboardEvent, type ReactNode, useEffect, useId, useMemo, useRef, useState, } from "react"; import { cn } from "@/lib/utils"; const COLORS = { violet: { chip: "bg-violet-500/12 text-violet-700 ring-violet-500/20 dark:text-violet-300", dot: "bg-violet-500" }, sky: { chip: "bg-sky-500/12 text-sky-700 ring-sky-500/20 dark:text-sky-300", dot: "bg-sky-500" }, amber: { chip: "bg-amber-500/14 text-amber-800 ring-amber-500/24 dark:text-amber-300", dot: "bg-amber-500" }, rose: { chip: "bg-rose-500/12 text-rose-700 ring-rose-500/20 dark:text-rose-300", dot: "bg-rose-500" }, emerald: { chip: "bg-emerald-500/12 text-emerald-700 ring-emerald-500/20 dark:text-emerald-300", dot: "bg-emerald-500" }, neutral: { chip: "bg-muted text-foreground ring-border", dot: "bg-muted-foreground" }, } as const; export type TagColor = keyof typeof COLORS; export interface TagGroup { id: string; label: string; color: TagColor; options: string[]; } export interface Tag { value: string; group?: string; } export interface TagInputProps { value?: Tag[]; defaultValue?: Tag[]; onValueChange?: (tags: Tag[]) => void; groups?: TagGroup[]; label?: ReactNode; placeholder?: string; allowCustom?: boolean; max?: number; className?: string; } const normalise = (s: string) => s.trim().replace(/\s+/g, " "); const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); export function TagInput({ value: valueProp, defaultValue = [], onValueChange, groups = [], label = "Tags", placeholder = "Add a tag…", allowCustom = true, max, className, }: TagInputProps) { const [inner, setInner] = useState(defaultValue); const tags = valueProp ?? inner; const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const [active, setActive] = useState(0); const [shaking, setShaking] = useState(null); const inputRef = useRef(null); const listRef = useRef(null); const reduce = useReducedMotion(); const id = useId(); const listId = `${id}-list`; const hintId = `${id}-hint`; const full = max !== undefined && tags.length >= max; const groupOf = (tag: Tag) => groups.find((g) => g.id === tag.group); const findOption = (text: string) => { for (const g of groups) { const hit = g.options.find((o) => same(o, text)); if (hit) return { value: hit, group: g.id }; } return null; }; const q = normalise(query); const sections = useMemo(() => { const taken = (o: string) => tags.some((t) => same(t.value, o)); return groups .map((g) => { const options = g.options .filter((o) => !taken(o) && (!q || o.toLowerCase().includes(q.toLowerCase()))) .sort((a, b) => Number(!a.toLowerCase().startsWith(q.toLowerCase())) - Number(!b.toLowerCase().startsWith(q.toLowerCase()))); return { group: g, options }; }) .filter((s) => s.options.length); }, [groups, tags, q]); const exact = q ? findOption(q) : null; const canCreate = allowCustom && q.length > 0 && !exact && !tags.some((t) => same(t.value, q)); const flat = [ ...sections.flatMap((s) => s.options.map((o) => ({ value: o, group: s.group.id }) as Tag)), ...(canCreate ? [{ value: q } as Tag] : []), ]; const showList = open && !full && flat.length > 0; useEffect(() => setActive(0), [q]); useEffect(() => { listRef.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" }); }, [active]); useEffect(() => { if (!shaking) return; const t = window.setTimeout(() => setShaking(null), 400); return () => window.clearTimeout(t); }, [shaking]); const commit = (next: Tag[]) => { if (valueProp === undefined) setInner(next); onValueChange?.(next); }; const add = (incoming: Tag[]) => { let next = tags; for (const raw of incoming) { const text = normalise(raw.value); if (!text) continue; const existing = next.find((t) => same(t.value, text)); if (existing) { setShaking(existing.value); continue; } if (max !== undefined && next.length >= max) break; const known = raw.group ? { value: text, group: raw.group } : findOption(text); if (!known && !allowCustom) continue; next = [...next, known ?? { value: text }]; } if (next !== tags) commit(next); setQuery(""); }; const remove = (tag: Tag) => { commit(tags.filter((t) => t !== tag)); inputRef.current?.focus(); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); setOpen(true); if (flat.length) setActive((a) => (a + (event.key === "ArrowDown" ? 1 : -1) + flat.length) % flat.length); } else if (event.key === "Enter") { event.preventDefault(); if (showList && flat[active]) add([flat[active]]); else if (q) add([{ value: q }]); } else if (event.key === "Tab" && q && showList && flat[active]) { event.preventDefault(); add([flat[active]]); } else if (event.key === "," || event.key === ";") { event.preventDefault(); if (q) add([exact ?? { value: q }]); } else if (event.key === "Backspace" && !query && tags.length) { event.preventDefault(); const last = tags[tags.length - 1]; commit(tags.slice(0, -1)); setQuery(last.value); setOpen(true); } else if (event.key === "Escape") { setOpen(false); } }; const onPaste = (event: ClipboardEvent) => { const text = event.clipboardData.getData("text"); if (!/[,;\n\t]/.test(text)) return; event.preventDefault(); add(text.split(/[,;\n\t]+/).map((value) => ({ value }))); }; let index = -1; const option = (tag: Tag, content: ReactNode) => { index += 1; const i = index; return (
event.preventDefault()} onClick={() => add([tag])} onMouseMove={() => setActive(i)} role="option" > {content}
); }; return (
{max !== undefined && ( {tags.length}/{max} )}
inputRef.current?.focus()} > {tags.map((tag) => ( remove(tag)} reduce={!!reduce} shake={shaking === tag.value} tag={tag} /> ))} setOpen(false)} onChange={(event) => { setQuery(event.target.value); setOpen(true); }} onFocus={() => setOpen(true)} onKeyDown={onKeyDown} onPaste={onPaste} placeholder={full ? "Limit reached" : tags.length ? "" : placeholder} ref={inputRef} role="combobox" value={query} />
{showList && ( {sections.map(({ group, options }) => (
{group.label}
{options.map((o) => option( { value: o, group: group.id }, <> , ), )}
))} {canCreate && (
0 && "mt-1 border-t pt-1")} role="presentation"> {option( { value: q }, <> Create “{q}” , )}
)}
)}

Enter or comma adds, Backspace edits the last. Paste a list to add many.

); } function Chip({ tag, color, shake, reduce, onRemove, }: { tag: Tag; color: TagColor; shake: boolean; reduce: boolean; onRemove: () => void; }) { const controls = useAnimationControls(); useEffect(() => { if (shake && !reduce) controls.start({ x: [0, -4, 4, -2, 0], transition: { duration: 0.32 } }); }, [shake, reduce, controls]); return ( {tag.value} ); } function Highlight({ text, query }: { text: string; query: string }) { const at = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1; if (at < 0) return {text}; return ( {text.slice(0, at)} {text.slice(at, at + query.length)} {text.slice(at + query.length)} ); }