A token editor with duplicate prevention, removable chips and a keyboard workflow.
Enter or comma adds a tag.
pnpm dlx shadcn@latest add @latheui/tag-inputFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add lucide-reactAdd the Lathe items this component imports.
hooks/use-controllable-state.tsCopy the source into your project.
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
export type TagInputProps = Omit<React.ComponentProps<"div">, "onChange"> & {
label: string
value?: string[]
defaultValue?: string[]
onValueChange?: (value: string[]) => void
maxTags?: number
disabled?: boolean
placeholder?: string
name?: string
}
export function TagInput({
label,
value: valueProp,
defaultValue = [],
onValueChange,
maxTags = Infinity,
disabled,
placeholder = "Add a tag…",
name,
className,
...props
}: TagInputProps) {
const [tags, setTags] = useControllableState({
value: valueProp,
defaultValue,
onChange: onValueChange,
})
const [draft, setDraft] = React.useState("")
const [message, setMessage] = React.useState("")
const id = React.useId()
function remove(tag: string) {
setTags(tags.filter((t) => t !== tag))
setMessage(tag + " removed")
}
function commit() {
const tag = draft.trim()
if (!tag) return
if (tags.some((t) => t.toLowerCase() === tag.toLowerCase())) {
setMessage("That tag already exists")
return
}
if (tags.length >= maxTags) {
setMessage("Tag limit reached")
return
}
setTags([...tags, tag])
setDraft("")
setMessage(tag + " added")
}
return (
<div {...props} className={cn("w-full space-y-1.5", className)}>
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
<div
className={cn(
"flex min-h-10 flex-wrap items-center gap-1.5 rounded-md border border-input bg-background p-1.5 focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/20",
disabled && "opacity-50"
)}
>
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-sm bg-secondary px-2 py-1 text-xs"
>
{tag}
<button
type="button"
disabled={disabled}
aria-label={"Remove " + tag}
onClick={() => remove(tag)}
className="cursor-pointer rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<XIcon aria-hidden="true" className="size-3" />
</button>
{name && <input type="hidden" name={name} value={tag} />}
</span>
))}
<input
id={id}
disabled={disabled}
value={draft}
placeholder={tags.length >= maxTags ? "Limit reached" : placeholder}
aria-describedby={id + "-help"}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing) return
if (e.key === "Enter" || e.key === ",") {
e.preventDefault()
commit()
} else if (e.key === "Backspace" && !draft && tags.length) {
remove(tags[tags.length - 1])
}
}}
className="h-7 min-w-20 flex-1 bg-transparent px-1 text-base outline-none md:text-sm"
/>
</div>
<p id={id + "-help"} className="text-xs text-muted-foreground">
Enter or comma adds a tag.
</p>
<span role="status" className="sr-only">
{message}
</span>
</div>
)
}Depends on
| Prop | Type | Default | Description |
|---|---|---|---|
| label* (required) | string | — | Visible input label. |
| value / defaultValue | string[] | — | Controlled or initial tags. |
| onValueChange | (tags: string[]) => void | — | Called after adding or removing a tag. |
| maxTags | number | — | Maximum tag count. |
| name | string | — | Submit one hidden form value per tag. |
Combine the control with your own layout. Semantic colors follow the active theme.
import { TagInput } from "@/components/ui/tag-input"
export default function Demo(){return <TagInput className="max-w-sm" label="Project tags" defaultValue={["Design","Research"]} maxTags={5}/>}