## Tag Input — Input Enter adds, backspace highlights then removes. Docs: https://www.interior.dev/docs/tag-input Reference: https://www.interior.dev/reference/tag-input License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/tag-input.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { TagInput } from "@/components/interior/tag-input"; export function SegmentForm() { const [topics, setTopics] = useState(["motion"]); return (
{ e.preventDefault(); void fetch("/api/segments", { method: "POST", body: JSON.stringify({ topics }), }); }} > candidate.length <= 24} hint="Enter adds · Backspace highlights, then removes" /> ); } ``` ### Props - `value` (`string[]`) Controlled list of tags. Omit to let the component own its state. - `defaultValue` (`string[]`) — default: `[]` Initial list when the component is uncontrolled. - `onChange` (`(tags: string[]) => void`) Fires once per committed change, never per keystroke. - `max` (`number`) Hard ceiling. Reaching it shows the limit message and reveals a counter whose width is reserved for the largest value. - `separators` (`string[]`) — default: `[","]` Characters that commit the draft on keypress and split pasted text. Enter always commits; newlines and tabs always split. - `allowDuplicates` (`boolean`) — default: `false` When false, a repeat entry is refused and the tag already holding that value is lit instead. - `validate` (`(candidate: string, tags: string[]) => boolean`) Runs on the trimmed, whitespace-collapsed candidate before it is added. - `label` (`string`) Rendered as a real label bound to the input. Without it the input falls back to aria-label. - `placeholder` (`string`) — default: `"Add a tag"` Placeholder for the draft field. - `hint` (`string`) — default: `"Enter adds · Backspace removes"` Persistent description, referenced by aria-describedby. Rejection messages cross-fade over it in the same grid cell. - `className` (`string`) — default: `""` Appended last to the wrapper so callers win. ### Behavior notes - Backspace on an empty field highlights the last tag before it removes anything, and a held Backspace is dropped until the key is released, so key repeat cannot chain-delete a list a user only meant to trim by one. - Enter is ignored while an IME composition is open, so confirming a Japanese or Korean candidate commits the word to the field instead of committing a half-typed tag to the list. - Pasted text is split on the configured separators plus newlines and tabs, so a copied comma list arrives as six tags rather than one tag six words long. - A refused duplicate lights the tag that already holds the value instead of only printing an error, and the hint and the error share one grid cell, so nothing below the field moves when a message appears. - The idle and highlighted labels are two layers in the same grid cell, so arming a tag never re-measures it; removals leave on opacity and x while the remaining tags close the gap with layout position, and the row is capped in height and scrolls rather than growing without bound. - Assistive technology gets one polite announcement per event — added, selected, removed, each with the running count — not a stream, and every path is reachable from the keyboard: arrow keys walk the tags, Delete removes the armed one, Escape only disarms so a surrounding dialog still closes. ### Source (`components/interior/tag-input.tsx`) ```tsx "use client"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; const LEAVE = [0.4, 0, 1, 1] as const; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; const CHIP = { type: "spring", stiffness: 700, damping: 46, mass: 0.5 } as const; const EXIT = { duration: 0.18, ease: LEAVE } as const; const INSTANT = { duration: 0 } as const; const clean = (raw: string) => raw.trim().replace(/\s+/g, " "); const splitter = (separators: string[]) => new RegExp(`[${separators.map((s) => s.replace(/[\\\]^-]/g, "\\$&")).join("")}\\n\\r\\t]+`); export type TagRejection = "duplicate" | "limit" | "invalid"; export type UseTagInputOptions = { value?: string[]; defaultValue?: string[]; onChange?: (tags: string[]) => void; max?: number; separators?: string[]; allowDuplicates?: boolean; validate?: (candidate: string, tags: string[]) => boolean; }; type Rejection = { reason: TagRejection; tag: string; visible: boolean }; export function useTagInput({ value, defaultValue, onChange, max, separators = [","], allowDuplicates = false, validate, }: UseTagInputOptions = {}) { const [internal, setInternal] = useState(() => defaultValue ?? []); const [draft, setDraft] = useState(""); const [armed, setArmed] = useState(-1); const [rejection, setRejection] = useState(null); const [flashed, setFlashed] = useState(null); const [announcement, setAnnouncement] = useState(""); const controlled = value !== undefined; const tags = value ?? internal; const armedIndex = armed >= tags.length ? -1 : armed; const emit = useRef(onChange); emit.current = onChange; const check = useRef(validate); check.current = validate; const rejectTimer = useRef | null>(null); const flashTimer = useRef | null>(null); useEffect( () => () => { if (rejectTimer.current) clearTimeout(rejectTimer.current); if (flashTimer.current) clearTimeout(flashTimer.current); }, [], ); const dismiss = useCallback(() => { if (rejectTimer.current) clearTimeout(rejectTimer.current); rejectTimer.current = null; setRejection((prev) => (prev && prev.visible ? { ...prev, visible: false } : prev)); }, []); const refuse = useCallback( (reason: TagRejection, tag: string) => { if (rejectTimer.current) clearTimeout(rejectTimer.current); setRejection({ reason, tag, visible: true }); rejectTimer.current = setTimeout(() => { setRejection((prev) => (prev ? { ...prev, visible: false } : prev)); }, 2400); setAnnouncement( reason === "duplicate" ? `${tag} is already in the list.` : reason === "limit" ? `That is the limit of ${max} tags.` : `${tag} is not allowed here.`, ); if (reason !== "duplicate") return; if (flashTimer.current) clearTimeout(flashTimer.current); setFlashed(tag); flashTimer.current = setTimeout(() => setFlashed(null), 460); }, [max], ); const apply = useCallback( (next: string[]) => { if (!controlled) setInternal(next); emit.current?.(next); }, [controlled], ); const add = useCallback( (raws: string[]) => { const next = [...tags]; let added = 0; let failure: { reason: TagRejection; tag: string } | null = null; for (const raw of raws) { const candidate = clean(raw); if (!candidate) continue; if (max !== undefined && next.length >= max) { failure = { reason: "limit", tag: candidate }; break; } if (!allowDuplicates) { const twin = next.find((t) => t.toLowerCase() === candidate.toLowerCase()); if (twin) { failure = { reason: "duplicate", tag: twin }; continue; } } if (check.current && !check.current(candidate, next)) { failure = { reason: "invalid", tag: candidate }; continue; } next.push(candidate); added += 1; } if (added > 0) { apply(next); setDraft(""); setArmed(-1); dismiss(); setAnnouncement( `${added === 1 ? next[next.length - 1] : `${added} tags`} added, ${next.length} total.`, ); } if (failure) refuse(failure.reason, failure.tag); return added > 0; }, [tags, max, allowDuplicates, apply, dismiss, refuse], ); const removeAt = useCallback( (index: number) => { if (index < 0 || index >= tags.length) return; const gone = tags[index]; const next = tags.filter((_, i) => i !== index); apply(next); setArmed(-1); dismiss(); setAnnouncement(`${gone} removed, ${next.length} left.`); }, [tags, apply, dismiss], ); const arm = useCallback( (index: number) => { setArmed(index); setAnnouncement(`${tags[index]} selected, press Backspace again to remove it.`); }, [tags], ); const inputProps = { value: draft, onChange: (e: React.ChangeEvent) => { setDraft(e.target.value); setArmed(-1); dismiss(); }, onKeyDown: (e: React.KeyboardEvent) => { if (e.nativeEvent.isComposing) return; if (e.key === "Enter" || separators.includes(e.key)) { e.preventDefault(); add([draft]); return; } if (e.key === "Backspace" && draft === "") { e.preventDefault(); if (e.repeat) return; if (armedIndex >= 0) removeAt(armedIndex); else if (tags.length > 0) arm(tags.length - 1); return; } if (e.key === "Delete" && armedIndex >= 0) { e.preventDefault(); if (e.repeat) return; removeAt(armedIndex); return; } if (e.key === "ArrowLeft") { const start = e.currentTarget.selectionStart; const end = e.currentTarget.selectionEnd; if (start !== 0 || end !== 0 || tags.length === 0) return; e.preventDefault(); arm(armedIndex < 0 ? tags.length - 1 : Math.max(0, armedIndex - 1)); return; } if (e.key === "ArrowRight" && armedIndex >= 0) { e.preventDefault(); if (armedIndex >= tags.length - 1) setArmed(-1); else arm(armedIndex + 1); return; } if (e.key === "Escape" && armedIndex >= 0) { e.preventDefault(); setArmed(-1); } }, onPaste: (e: React.ClipboardEvent) => { const text = e.clipboardData.getData("text"); const pattern = splitter(separators); if (!pattern.test(text)) return; e.preventDefault(); add(text.split(pattern)); }, onBlur: () => setArmed(-1), }; return { tags, draft, setDraft, armedIndex, flashed, rejection, announcement, inputProps, add, removeAt, max, }; } export type TagInputProps = UseTagInputOptions & { label?: string; placeholder?: string; hint?: string; className?: string; }; function CloseGlyph() { return ( ); } export function TagInput({ label, placeholder = "Add a tag", hint = "Enter adds · Backspace removes", className = "", ...options }: TagInputProps) { const { tags, draft, armedIndex, flashed, rejection, announcement, inputProps, removeAt, max } = useTagInput(options); const reduced = useReducedMotion(); const inputRef = useRef(null); const uid = useId(); const inputId = `${uid}-tag-input`; const hintId = `${uid}-tag-hint`; const rows = useMemo(() => { const seen = new Map(); return tags.map((tag) => { const n = seen.get(tag) ?? 0; seen.set(tag, n + 1); return { tag, key: n === 0 ? tag : `${tag}#${n}` }; }); }, [tags]); const message = !rejection ? "" : rejection.reason === "duplicate" ? `${rejection.tag} is already in the list` : rejection.reason === "limit" ? `That is the limit of ${max} tags` : `${rejection.tag} is not allowed here`; const showMessage = rejection?.visible === true; return (
{label ? ( ) : null}
    { if (e.target !== e.currentTarget) return; e.preventDefault(); inputRef.current?.focus(); }} className="relative flex max-h-[116px] min-h-10 list-none flex-wrap items-center gap-1.5 overflow-y-auto overscroll-contain rounded-[10px] border-2 border-stone-200 bg-stone-100/70 p-[4px] shadow-[inset_0_1px_2px_rgba(28,25,23,0.07)] transition-[background-color,border-color,box-shadow] duration-150 focus-within:border-[#4568FF] focus-within:bg-white focus-within:shadow-none dark:border-white/[0.08] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)] dark:focus-within:border-[#93B0FF] dark:focus-within:bg-[#252522]" > {rows.map(({ tag, key }, index) => { const lit = armedIndex === index || flashed === tag; return ( {tag} ); })} {draft || placeholder}
{hint} {message}
{max === undefined ? null : (

{max} {tags.length} / {max}

)}
{announcement}
); } ```