Enter, paste, and remove text tags with duplicate prevention, limits, and keyboard shortcuts.
Project labels
Keep related work easy to find.
2 of 5 tags · Enter to add, Backspace to remove
import { TagInput } from "@/components/ballmac/tag-input"
export default function TagInputDemo() {
return (
<div className="w-full max-w-sm rounded-xl border bg-card p-5 shadow-sm">
<p className="mb-1 text-sm font-semibold">Project labels</p>
<p className="mb-4 text-xs text-muted-foreground">
Keep related work easy to find.
</p>
<TagInput
defaultValue={["Design", "Launch"]}
label="Project labels"
maxTags={5}
/>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/tag-inputInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/tag-input.tsx// Ballmac UI: Tag Input. https://ui.ballmac.com/components/tag-input "use client" import * as React from "react" import { X } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type TagInputProps = Omit<React.ComponentProps<"div">, "onChange"> & { /** Controlled tag list. */ value?: string[] /** Initial tag list when uncontrolled. */ defaultValue?: string[] /** Called with the complete list after edits. */ onValueChange?: (value: string[]) => void /** Maximum number of tags. */ maxTags?: number /** Maximum characters per tag. */ maxLength?: number /** Placeholder shown when the list is empty. */ placeholder?: string /** Accessible name for the text field. */ label?: string /** Name used for repeated hidden form inputs. */ name?: string /** Disable editing. */ disabled?: boolean } function TagInput({ value, defaultValue = [], onValueChange, maxTags = 8, maxLength = 32, placeholder, label, name, disabled = false, className, ...props }: TagInputProps) { const msg = useMessages() placeholder ??= msg("tag-input.placeholder", "Add a tag…") label ??= msg("tag-input.label", "Tags") const [internal, setInternal] = React.useState(defaultValue) const [draft, setDraft] = React.useState("") const [message, setMessage] = React.useState("") const tags = value ?? internal const id = React.useId() const inputRef = React.useRef<HTMLInputElement>(null) function commit(next: string[]) { if (value === undefined) setInternal(next) onValueChange?.(next) } function add(raw: string) { const tag = raw.trim().slice(0, maxLength) if (!tag) return if ( tags.some( (entry) => entry.toLocaleLowerCase("en-US") === tag.toLocaleLowerCase("en-US"), ) ) { setMessage("That tag is already added.") return } if (tags.length >= maxTags) { setMessage(`You can add up to ${maxTags} tags.`) return } commit([...tags, tag]) setDraft("") setMessage("") } return ( <div data-slot="tag-input" className={cn("w-full min-w-0", className)} {...props} > <div className="flex min-h-10 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 motion-reduce:transition-none focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 has-[input[aria-invalid=true]]:border-destructive" onClick={() => inputRef.current?.focus()} > {tags.map((tag, index) => ( <span key={`${tag}-${index}`} className="inline-flex max-w-full items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs text-secondary-foreground" > <span className="truncate">{tag}</span> <button type="button" disabled={disabled} aria-label={msg("tag-input.remove", "Remove {tag}", { tag })} onClick={(event) => { event.stopPropagation() commit(tags.filter((_, position) => position !== index)) inputRef.current?.focus() }} className="flex size-5 items-center justify-center rounded-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50 disabled:opacity-50" > <X aria-hidden="true" className="size-3" /> </button> </span> ))} <input ref={inputRef} data-slot="tag-input-field" aria-label={label} aria-describedby={message ? id : undefined} disabled={disabled} value={draft} placeholder={tags.length ? "" : placeholder} onChange={(event) => setDraft(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === ",") { event.preventDefault() add(draft) } if (event.key === "Backspace" && !draft && tags.length) commit(tags.slice(0, -1)) }} onPaste={(event) => { const text = event.clipboardData.getData("text") if (text.includes(",")) { event.preventDefault() const next = [...tags] for (const raw of text.split(",")) { const tag = raw.trim().slice(0, maxLength) if ( tag && next.length < maxTags && !next.some( (entry) => entry.toLocaleLowerCase("en-US") === tag.toLocaleLowerCase("en-US"), ) ) next.push(tag) } if (next.length !== tags.length) commit(next) setDraft("") } }} className="min-w-24 flex-1 bg-transparent px-1 text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-50" /> </div> {name && tags.map((tag, index) => ( <input key={`${tag}-${index}`} type="hidden" name={name} value={tag} /> ))} <p id={id} className="mt-1.5 text-xs text-muted-foreground" aria-live="polite" > {message || `${tags.length} of ${maxTags} tags · Enter to add, Backspace to remove`} </p> </div> ) } export { TagInput, type TagInputProps }Update the import paths to match your project setup.
Usage
import { TagInput } from "@/components/ballmac/tag-input"The full example is in the Code tab above.
Examples
States and variants
Add skills
0 of 3 tags · Enter to add, Backspace to remove
import { TagInput } from "@/components/ballmac/tag-input"
export default function TagInputStates() {
return (
<div className="w-full max-w-sm">
<p className="mb-2 text-sm font-medium">Add skills</p>
<TagInput
label="Skills"
placeholder="Type a skill and press Enter"
maxTags={3}
maxLength={20}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
valueControlled tag list. | string[] | — |
defaultValueInitial tag list when uncontrolled. | string[] | [] |
onValueChangeCalled with the complete list after edits. | (value: string[]) => void | — |
maxTagsMaximum number of tags. | number | 8 |
maxLengthMaximum characters per tag. | number | 32 |
placeholderPlaceholder shown when the list is empty. | string | — |
labelAccessible name for the text field. | string | — |
nameName used for repeated hidden form inputs. | string | — |
disabledDisable editing. | boolean | false |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter / comma | Adds the typed tag |
| Backspace | Removes the last tag when empty |
| Tab / Enter | Focuses and activates remove buttons |
Use with AI
Enter, paste, and remove text tags with duplicate prevention, limits, and keyboard shortcuts. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Tag Input (@ballmac/tag-input) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Enter free-form labels or skills
- Edit a small set of keywords
Not for
- Use multi-select for a fixed set of options
Registry JSON: https://ui.ballmac.com/r/tag-input.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Input
A text input in three heights that match Button, plus InputGroup and InputGroupAddon for leading or trailing icons, units and domains.
Calendar
A date grid for one day, several days or a range, with month and year selects, week numbers, disabled rules and range-end styling, on React DayPicker.
Color Picker
A native color chooser paired with an editable hex field and a live swatch.
Combobox
A searchable single-choice select with groups, descriptions, keywords, clearable value, invalid state and hidden-input form submission.