Tag input
Type, paste or pick suggestions to add tags; validates each one.
$
pnpm dlx shadcn@latest add https://lodeui.com/r/tag-input.jsonreacttypescript
Enter or comma to add · Backspace to remove · paste a list
components/examples/tag-input-demo.tsx
"use client";
import * as React from "react";
import { TagInput } from "@/components/ui/tag-input";
export default function TagInputDemo() {
const [tags, setTags] = React.useState(["react", "typescript"]);
return (
<div className="w-full max-w-md">
<label className="mb-1.5 block text-sm font-medium">Topics</label>
<TagInput aria-label="Topics" value={tags} onValueChange={setTags} max={6} suggestions={["react", "nextjs", "tailwind", "motion", "typescript", "accessibility", "design-systems"]} validate={(t) => (t.length > 20 ? "Keep tags under 20 characters" : null)} />
<p className="mt-1.5 text-xs text-muted-foreground">Enter or comma to add · Backspace to remove · paste a list</p>
</div>
);
}When to use
- Lists of short values: emails to invite, labels, allowed domains.
When not to use
- Picking from a fixed list: use a multi combobox.
Installation
$
pnpm dlx shadcn@latest add https://lodeui.com/r/tag-input.json1Install the dependencies
npm install motion lucide-react2Copy the source into your project
components/ui/tag-input.tsx"use client"; import * as React from "react"; import { AnimatePresence, motion } from "motion/react"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; export interface TagInputProps { value: string[]; onValueChange: (tags: string[]) => void; placeholder?: string; /** Reject a tag by returning an error message. */ validate?: (tag: string) => string | null; max?: number; suggestions?: string[]; className?: string; "aria-label"?: string; } /** Type and press Enter or comma. Backspace removes the last tag. Paste splits on commas. */ export function TagInput({ value, onValueChange, placeholder = "Add…", validate, max, suggestions = [], className, ...aria }: TagInputProps) { const [draft, setDraft] = React.useState(""); const [error, setError] = React.useState<string | null>(null); const inputRef = React.useRef<HTMLInputElement>(null); const full = max !== undefined && value.length >= max; const matches = draft ? suggestions.filter((s) => s.toLowerCase().includes(draft.toLowerCase()) && !value.includes(s)).slice(0, 5) : []; const add = (raw: string) => { const tags = raw.split(",").map((t) => t.trim()).filter(Boolean); const next = [...value]; for (const t of tags) { if (next.includes(t) || (max !== undefined && next.length >= max)) continue; const err = validate?.(t); if (err) { setError(err); return; } next.push(t); } setError(null); onValueChange(next); setDraft(""); }; return ( <div className={cn("relative", className)}> <div onClick={() => 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 px-2 py-1.5 transition-[border-color,box-shadow] focus-within:border-foreground/40 focus-within:ring-2 focus-within:ring-ring/40", error && "border-destructive", )} > <AnimatePresence initial={false}> {value.map((tag) => ( <motion.span key={tag} layout initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }} transition={{ type: "spring", stiffness: 500, damping: 30 }} className="flex h-6 items-center gap-1 rounded bg-secondary pl-2 pr-1 text-xs font-medium" > {tag} <button type="button" aria-label={`Remove ${tag}`} onClick={(e) => { e.stopPropagation(); onValueChange(value.filter((t) => t !== tag)); }} className="grid size-4 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"> <X className="size-3" /> </button> </motion.span> ))} </AnimatePresence> <input ref={inputRef} value={draft} disabled={full} aria-label={aria["aria-label"]} aria-invalid={!!error || undefined} onChange={(e) => { setError(null); setDraft(e.target.value); }} onKeyDown={(e) => { if ((e.key === "Enter" || e.key === ",") && draft.trim()) { e.preventDefault(); add(draft); } else if (e.key === "Backspace" && !draft && value.length) { onValueChange(value.slice(0, -1)); } }} onPaste={(e) => { const text = e.clipboardData.getData("text"); if (text.includes(",")) { e.preventDefault(); add(text); } }} onBlur={() => draft.trim() && add(draft)} placeholder={full ? `Max ${max}` : value.length ? "" : placeholder} className="h-6 min-w-24 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground/70" /> </div> {matches.length > 0 && ( <ul className="absolute inset-x-0 top-full z-50 mt-1 rounded-lg border border-border bg-popover p-1 shadow-elev-3" role="listbox"> {matches.map((m) => ( <li key={m} role="option" aria-selected={false}> <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => add(m)} className="w-full rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-accent">{m}</button> </li> ))} </ul> )} {error && <p role="alert" className="mt-1.5 text-xs text-destructive">{error}</p>} </div> ); }
Usage
Import it from @/components/ui/tag-input after installing.
example.tsx
import * as React from "react";
import { TagInput } from "@/components/ui/tag-input";
export function Invite() {
const [emails, setEmails] = React.useState<string[]>([]);
return (
<TagInput
aria-label="Emails"
value={emails}
onValueChange={setEmails}
placeholder="name@company.com"
validate={(t) => (/^\S+@\S+$/.test(t) ? null : "Enter a valid email")}
max={10}
/>
);
}API reference
TagInput
Type and press Enter or comma to add a tag. Paste splits on commas.
| Prop | Type | Default | Description |
|---|---|---|---|
| value / onValueChange | string[] | – | Controlled tags. |
| validate | (tag) => string | null | – | Return an error message to reject a tag. |
| max | number | – | Most tags allowed. |
| suggestions | string[] | [] | Up to five matches shown as a listbox while typing. |
| placeholder | string | "Add…" | Input placeholder. |
Keyboard interactions
| Keys | Action |
|---|---|
| Enteror, | Add the tag |
| Backspace | Remove the last tag when empty |
| Tab | Reach suggestions and remove buttons |
Accessibility
- Each tag's remove button is labelled "Remove <tag>".
- Errors are announced with role="alert" and aria-invalid.
Motion
- Tags spring in and out (stiffness 500).
Responsive behavior
- Tags wrap onto new lines.
Performance
- Duplicates are ignored without re-rendering.
Notes for AI
- Normalize case in validate if tags are case-insensitive.
This page as Markdown, for assistants and coding agents:
/components/tag-input/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt