Tag Input.
A text field that turns what you type into removable tags. Enter or comma adds a tag, Backspace on an empty field removes the last one, and pasting a comma separated list adds several.
Press Enter or comma to add. Backspace removes the last tag. Paste a comma separated list to add several.
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { TagInput } from "@/components/ui/tag-input";
export default function TagInputDemo() {
return (
<Field className="w-full max-w-md">
<FieldLabel htmlFor="site-tags">Site tags</FieldLabel>
<TagInput
id="site-tags"
defaultValue={["turtles", "guestbook"]}
placeholder="Add a tag"
aria-describedby="site-tags-help"
/>
<FieldDescription id="site-tags-help">
Press Enter or comma to add. Backspace removes the last tag. Paste a comma separated list to add several.
</FieldDescription>
</Field>
);
}Installation#
With the CLI
$npx lanterncn add tag-inputThis also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/tag-input. See Installation if your project is not set up yet.
Manually
Install the dependencies
npm install lucide-reactCopy the source into your project
components/ui/tag-input.tsx"use client"; import * as React from "react"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; type TagRejectReason = "duplicate" | "invalid" | "max"; type TagInputProps = Omit<React.ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "size"> & { value?: string[]; defaultValue?: string[]; onValueChange?: (tags: string[]) => void; /** Most tags allowed. */ max?: number; /** Return false to reject a tag. Runs on the trimmed text. */ validate?: (tag: string, tags: string[]) => boolean; /** Called when a tag is not added, with the reason. */ onReject?: (tag: string, reason: TagRejectReason) => void; /** Treat "Hub" and "hub" as the same tag. */ caseSensitive?: boolean; /** Add the typed text when the field loses focus. */ addOnBlur?: boolean; /** Classes for the outer field. className goes to the text input. */ containerClassName?: string; }; /** A field that turns typed text into removable tags. Enter or comma adds, Backspace on empty removes the last. */ function TagInput({ value: valueProp, defaultValue, onValueChange, max, validate, onReject, caseSensitive = false, addOnBlur = true, disabled, placeholder, className, containerClassName, onKeyDown, onPaste, onBlur, "aria-invalid": ariaInvalid, ...props }: TagInputProps) { const [uncontrolled, setUncontrolled] = React.useState<string[]>(defaultValue ?? []); const tags = valueProp ?? uncontrolled; const [draft, setDraft] = React.useState(""); const [announcement, setAnnouncement] = React.useState(""); const inputRef = React.useRef<HTMLInputElement>(null); const atMax = max !== undefined && tags.length >= max; const setTags = (next: string[]) => { if (valueProp === undefined) setUncontrolled(next); onValueChange?.(next); }; const norm = (t: string) => (caseSensitive ? t : t.toLowerCase()); /** Adds each candidate that passes the checks; returns true if every one was added. */ const addTags = (candidates: string[]) => { const next = [...tags]; const added: string[] = []; let allAdded = true; for (const raw of candidates) { const tag = raw.trim(); if (!tag) continue; let reason: TagRejectReason | null = null; if (next.some((t) => norm(t) === norm(tag))) reason = "duplicate"; else if (max !== undefined && next.length >= max) reason = "max"; else if (validate && !validate(tag, next)) reason = "invalid"; if (reason) { allAdded = false; onReject?.(tag, reason); continue; } next.push(tag); added.push(tag); } if (added.length) { setTags(next); setAnnouncement(`Added ${added.join(", ")}.`); } return allAdded; }; const removeTag = (index: number) => { const removed = tags[index]; setTags(tags.filter((_, i) => i !== index)); setAnnouncement(`Removed ${removed}.`); }; const commitDraft = () => { if (!draft.trim()) return; // Keep the text when it was rejected so it can be fixed; clear duplicates and successes. const tag = draft.trim(); const isDuplicate = tags.some((t) => norm(t) === norm(tag)); if (addTags([draft]) || isDuplicate) setDraft(""); }; return ( <div data-slot="tag-input" data-disabled={disabled ? "" : undefined} aria-invalid={ariaInvalid} onMouseDown={(e) => { // Clicking the empty part of the field focuses the text input. if (e.target === e.currentTarget) { e.preventDefault(); inputRef.current?.focus(); } }} className={cn( "flex min-h-10 w-full min-w-0 cursor-text flex-wrap items-center gap-1.5 rounded-md border border-input bg-background/40 px-2 py-1.5 text-sm transition-[border-color,box-shadow]", "hover:border-muted-foreground/60 has-[input:focus-visible]:border-primary has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-ring/25", "aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-disabled:cursor-not-allowed data-disabled:opacity-50", containerClassName, )} > {tags.map((tag, i) => ( <span key={`${tag}-${i}`} data-slot="tag-input-tag" className="inline-flex h-6 max-w-full items-center gap-1 border border-[#647160] pr-0.5 pl-1.5 font-mono text-[10px] leading-none tracking-[0.12em] text-[#b8c2b2] uppercase" > <span className="truncate">{tag}</span> <button type="button" aria-label={`Remove ${tag}`} disabled={disabled} onClick={() => { removeTag(i); inputRef.current?.focus(); }} className="inline-flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground outline-none hover:bg-secondary hover:text-foreground focus-visible:text-primary focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none [&_svg]:size-3" > <X /> </button> </span> ))} <input ref={inputRef} data-slot="tag-input-input" type="text" value={draft} disabled={disabled} aria-invalid={ariaInvalid} placeholder={atMax ? undefined : placeholder} enterKeyHint="enter" autoComplete="off" onChange={(e) => { const text = e.target.value; // Mobile keyboards do not always fire a comma keydown, so split on input too. if (text.includes(",")) { const parts = text.split(","); const rest = parts.pop() ?? ""; addTags(parts); setDraft(rest); } else { setDraft(text); } }} onKeyDown={(e) => { onKeyDown?.(e); if (e.defaultPrevented || e.nativeEvent.isComposing) return; if (e.key === "Enter" || e.key === ",") { if (e.key === "Enter" && !draft.trim()) return; // Let an empty Enter submit the form. e.preventDefault(); commitDraft(); } else if (e.key === "Backspace" && draft === "" && tags.length > 0) { e.preventDefault(); removeTag(tags.length - 1); } }} onPaste={(e) => { onPaste?.(e); if (e.defaultPrevented) return; const text = e.clipboardData.getData("text"); if (/[,\n]/.test(text)) { e.preventDefault(); addTags(text.split(/[,\n]/)); } }} onBlur={(e) => { onBlur?.(e); if (addOnBlur) commitDraft(); }} className={cn( "h-6 min-w-[8ch] flex-1 bg-transparent px-1 text-sm text-foreground outline-none placeholder:text-muted-foreground/70 disabled:cursor-not-allowed", className, )} {...props} /> <span className="sr-only" aria-live="polite"> {announcement} </span> </div> ); } export { TagInput, type TagRejectReason };Update the import paths to match your project
The source imports
cnfrom@/lib/utils.
Usage#
import { TagInput } from "@/components/ui/tag-input";<Label htmlFor="tags">Tags</Label>
<TagInput id="tags" defaultValue={["turtles"]} placeholder="Add a tag" />Use value and onValueChange to control it, or defaultValue to let it manage its own tags. Duplicates are ignored (case-insensitive unless caseSensitive is set).
max caps the count and validate(tag, tags) can reject a tag; onReject(tag, reason) reports why, with reason duplicate, invalid or max. Rejected text stays in the field so it can be fixed.
id, placeholder and aria-* props go to the text input, so a Label or FieldLabel points at it. Each remove button is labelled with its tag, and additions and removals are announced to screen readers.
Examples#
Max, validation and suggestions
Suggestions:
"use client";
import * as React from "react";
import { Plus } from "lucide-react";
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
import { TagInput, type TagRejectReason } from "@/components/ui/tag-input";
const suggestions = ["mining", "relay", "farm", "shop", "archive"];
const MAX = 4;
const messages: Record<TagRejectReason, string> = {
duplicate: "Already added.",
invalid: "Tags use lowercase letters, numbers and dashes.",
max: `Up to ${MAX} tags.`,
};
export default function TagInputMax() {
const [tags, setTags] = React.useState<string[]>(["relay"]);
const [error, setError] = React.useState<string | null>(null);
const add = (tag: string) => {
if (tags.includes(tag) || tags.length >= MAX) return;
setTags([...tags, tag]);
setError(null);
};
return (
<Field className="w-full max-w-md" data-invalid={error ? true : undefined}>
<div className="flex items-baseline justify-between gap-3">
<FieldLabel htmlFor="server-tags">Server tags</FieldLabel>
<span className="font-mono text-[10px] tracking-[0.2em] text-muted-foreground uppercase" aria-live="polite">
{tags.length} / {MAX}
</span>
</div>
<TagInput
id="server-tags"
value={tags}
onValueChange={(next) => {
setTags(next);
setError(null);
}}
max={MAX}
validate={(tag) => /^[a-z0-9-]+$/.test(tag)}
onReject={(_, reason) => setError(messages[reason])}
placeholder="lowercase-tag"
aria-invalid={error ? true : undefined}
aria-describedby="server-tags-help"
/>
{error ? <FieldError>{error}</FieldError> : null}
<FieldDescription id="server-tags-help">Suggestions:</FieldDescription>
<div className="flex flex-wrap gap-1.5">
{suggestions.map((s) => (
<button
key={s}
type="button"
onClick={() => add(s)}
disabled={tags.includes(s) || tags.length >= MAX}
className="inline-flex h-6 cursor-pointer items-center gap-1 rounded-sm border border-dashed border-input px-1.5 font-mono text-[10px] tracking-[0.12em] text-muted-foreground uppercase outline-none hover:border-primary hover:text-primary focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-3"
>
<Plus aria-hidden="true" />
{s}
</button>
))}
</div>
</Field>
);
}