Inputs & Formsv0.1.0
Tag Input
Chips pop in and out with layout animation.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/tag-input.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/tag-input/tag-input.tsx"use client"; import { useId, useRef, useState, type ComponentProps } from "react"; import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; import { ease, spring } from "@/lib/motion"; export interface TagInputProps extends Omit< ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "size" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd" > { /** Controlled tags. */ value?: string[]; /** Initial tags when uncontrolled. @default [] */ defaultValue?: string[]; /** Called with the full list whenever a tag is added or removed. */ onValueChange?: (tags: string[]) => void; /** Maximum number of tags; a counter appears when set. */ maxTags?: number; /** Keys that turn the typed text into a tag. Single characters also split pasted text. @default ["Enter", ","] */ separators?: string[]; /** Return false to reject a tag. The field shakes and the text stays so it can be fixed. */ validate?: (tag: string) => boolean; /** Allow the same tag more than once (case-insensitive). @default false */ allowDuplicates?: boolean; /** Turn leftover text into a tag when the field loses focus. @default false */ addOnBlur?: boolean; /** Classes for the outer field. */ className?: string; } const SHAKE = { x: [0, -6, 6, -4, 4, 0] }; export function TagInput({ value, defaultValue = [], onValueChange, maxTags, separators = ["Enter", ","], validate, allowDuplicates = false, addOnBlur = false, className, placeholder = "Add a tag…", disabled, onKeyDown, onBlur, onPaste, "aria-describedby": describedBy, ...props }: TagInputProps) { const [internal, setInternal] = useState(defaultValue); const tags = value ?? internal; const [draft, setDraft] = useState(""); const [armed, setArmed] = useState(false); const [announcement, setAnnouncement] = useState(""); const [scope, animate] = useAnimate<HTMLDivElement>(); const inputRef = useRef<HTMLInputElement>(null); const reduceMotion = useReducedMotion(); const hintId = useId(); const splitChars = separators.filter((s) => s.length === 1); const escaped = splitChars.map((ch) => ch.replace(/[\\\]^-]/g, "\\$&")).join(""); // Single-character separators, newlines and tabs all split typed or pasted text. const splitPattern = new RegExp(`[\\n\\t${escaped}]+`); const full = maxTags !== undefined && tags.length >= maxTags; function commit(next: string[]) { if (value === undefined) setInternal(next); onValueChange?.(next); } function shake(element: Element | null | undefined) { if (!reduceMotion && element) animate(element, SHAKE, { duration: 0.35 }); } /** Adds candidates; returns false if the last one was rejected so its text can stay. */ function add(candidates: string[]) { const next = [...tags]; let accepted = true; for (const raw of candidates) { const tag = raw.trim(); if (!tag) continue; if (maxTags !== undefined && next.length >= maxTags) { shake(scope.current); setAnnouncement(`You can add up to ${maxTags} tags`); accepted = false; break; } const existing = next.findIndex((t) => t.toLowerCase() === tag.toLowerCase()); if (!allowDuplicates && existing !== -1) { shake(scope.current?.querySelector(`[data-tag-index="${existing}"]`)); setAnnouncement(`${tag} is already added`); continue; } if (validate && !validate(tag)) { shake(scope.current); setAnnouncement(`${tag} isn't valid`); accepted = false; continue; } next.push(tag); setAnnouncement(`Added ${tag}`); } if (next.length !== tags.length) commit(next); return accepted; } function remove(index: number) { const tag = tags[index]; commit(tags.filter((_, i) => i !== index)); setAnnouncement(`Removed ${tag}`); setArmed(false); inputRef.current?.focus(); } function handleDraft(text: string) { const splitter = splitChars.find((ch) => text.includes(ch)); if (!splitter) { setDraft(text); return; } // Typing (or a mobile keyboard) produced a separator: everything before the last one becomes tags. const parts = text.split(splitPattern); const rest = parts.pop() ?? ""; const ok = add(parts); setDraft(ok ? rest : parts.join(splitter) + rest); } const occurrences = new Map<string, number>(); return ( <div ref={scope} onClick={(event) => { if (event.target === event.currentTarget) inputRef.current?.focus(); }} className={cn( "flex min-h-10 w-full cursor-text flex-wrap items-center gap-1.5 rounded-md border border-input bg-background p-1.5 shadow-xs", "transition-[border-color,box-shadow] duration-150 focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/20", disabled && "pointer-events-none opacity-50", className, )} > <AnimatePresence mode="popLayout" initial={false}> {tags.map((tag, index) => { const seen = occurrences.get(tag) ?? 0; occurrences.set(tag, seen + 1); const highlighted = armed && index === tags.length - 1; return ( <motion.span key={`${tag}::${seen}`} data-tag-index={index} layout={!reduceMotion} initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: "blur(4px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={ reduceMotion ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, scale: 0.6, filter: "blur(4px)", transition: { duration: 0.16, ease: ease.out } } } transition={ reduceMotion ? { duration: 0.12 } : { ...spring.bouncy, opacity: { duration: 0.15 }, filter: { duration: 0.2 }, layout: spring.snappy } } className={cn( "inline-flex h-7 max-w-full cursor-default items-center gap-0.5 rounded-[7px] border bg-secondary pr-0.5 pl-2.5 text-[13px] font-medium text-secondary-foreground transition-[border-color,box-shadow] duration-150", highlighted ? "border-ring ring-2 ring-ring/25" : "border-border", )} > <span className="truncate">{tag}</span> <button type="button" aria-label={`Remove ${tag}`} onClick={() => remove(index)} className="relative inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-[5px] text-muted-foreground outline-none after:absolute after:-inset-1 hover:bg-foreground/[0.07] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" > <X className="size-3" aria-hidden="true" /> </button> </motion.span> ); })} </AnimatePresence> <motion.input ref={inputRef} layout={reduceMotion ? false : "position"} transition={spring.snappy} value={draft} disabled={disabled} placeholder={full ? "" : placeholder} aria-describedby={[hintId, describedBy].filter(Boolean).join(" ")} onChange={(event) => { setArmed(false); handleDraft(event.target.value); }} onKeyDown={(event) => { onKeyDown?.(event); if (event.defaultPrevented || event.nativeEvent.isComposing) return; if (event.key === "Enter" && separators.includes("Enter") && draft.trim()) { event.preventDefault(); if (add([draft])) setDraft(""); } else if (event.key === "Backspace" && draft === "" && tags.length > 0) { // First press highlights the last tag, the second removes it. event.preventDefault(); if (armed) remove(tags.length - 1); else setArmed(true); } else if (armed && event.key !== "Shift") { setArmed(false); } }} onPaste={(event) => { onPaste?.(event); const text = event.clipboardData.getData("text"); if (!splitPattern.test(text)) return; event.preventDefault(); add(text.split(splitPattern)); }} onBlur={(event) => { onBlur?.(event); setArmed(false); if (addOnBlur && draft.trim() && add([draft])) setDraft(""); }} className="h-7 min-w-24 flex-1 bg-transparent px-1.5 text-sm text-foreground outline-none placeholder:text-muted-foreground" {...props} /> {maxTags !== undefined ? ( <span className="ml-auto shrink-0 px-1.5 font-mono text-[11px] text-muted-foreground tabular-nums" aria-hidden="true"> {tags.length}/{maxTags} </span> ) : null} <span id={hintId} className="sr-only"> Press Enter{splitChars.includes(",") ? " or comma" : ""} to add a tag. Press Backspace twice to remove the last tag. {maxTags !== undefined ? ` ${tags.length} of ${maxTags} tags used.` : ""} </span> <span role="status" className="sr-only"> {announcement} </span> </div> ); }
Usage
import { TagInput } from "@/components/ui/tag-input/tag-input";
export function SkillsField() {
const [skills, setSkills] = useState(["React"]);
return <TagInput aria-label="Skills" value={skills} onValueChange={setSkills} maxTags={8} />;
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
value | string[] | – | Controlled tags. |
defaultValue | string[] | [] | Initial tags when uncontrolled. |
onValueChange | (tags: string[]) => void | – | Called with the full list whenever a tag is added or removed. |
maxTags | number | – | Maximum number of tags; a counter appears when set. |
separators | string[] | ["Enter", ","] | Keys that turn the typed text into a tag. Single characters also split pasted text. |
validate | (tag: string) => boolean | – | Return false to reject a tag. The field shakes and the text stays so it can be fixed. |
allowDuplicates | boolean | false | Allow the same tag more than once (case-insensitive). |
addOnBlur | boolean | false | Turn leftover text into a tag when the field loses focus. |
className | string | – | Classes for the outer field. |
Also accepts every native <input> attribute, including ref and className.
- A duplicate makes the existing tag shake instead of adding a copy. Set allowDuplicates to permit repeats.
- When validate rejects a tag, the text stays in the field so the user can correct it.
Accessibility
- Each tag has its own remove button labelled "Remove <tag>", reachable with Tab.
- Backspace in an empty field first highlights the last tag and only removes it on a second press, so tags aren't deleted by accident.
- A visually hidden hint explains the keys, and a status region announces added, removed, duplicate and rejected tags.
- Separators work from typing, mobile keyboards and paste, so pasting "a, b, c" adds three tags.
- With reduced motion enabled, tags fade in and out without scaling, and nothing shakes.