reacttailwind
Press Enter or comma to add, Backspace to remove.
"use client"
import * as React from "react"
import { Label } from "@/components/ui/label"
import { TagsInput } from "@/components/ui/tags-input"
export default function TagsInputDemo() {
const [tags, setTags] = React.useState(["react", "tailwind"])
return (
<div className="grid w-full max-w-sm gap-2">
<Label htmlFor="topics">Topics</Label>
<TagsInput
id="topics"
value={tags}
onValueChange={setTags}
placeholder="Add a topic…"
/>
<p className="text-muted-foreground text-sm">
Press <kbd className="bg-muted rounded px-1">Enter</kbd> or comma to
add, <kbd className="bg-muted rounded px-1">Backspace</kbd> to remove.
</p>
</div>
)
}Installation
npx logic2b@latest add tags-inputWorking with an AI assistant? Copy this prompt into Claude Code, Cursor or Copilot — it carries the install steps, a no-CLI fallback and a verification checklist.
Add the "tags-input" item from the logic2b ui registry (https://ui.logic2b.com) to this project. Follow every step and verify at the end. ## Steps 1. If the project has no `components.json` yet, set up logic2b ui first: ```bash npx logic2b@latest init ``` 2. Install the item (the CLI resolves registry dependencies automatically): ```bash npx logic2b@latest add tags-input ``` 3. Install any npm dependencies the command prints. ## If the CLI is not available Do it manually from the registry payload: 1. Fetch https://ui.logic2b.com/r/tags-input.json. 2. Write every entry in `files[]` to its mapped project path (see below). 3. Repeat (recursively) for each name in `registryDependencies`. 4. Install every package listed in `dependencies` across those payloads. ## Registry reference - Index of every item: https://ui.logic2b.com/r/index.json - Item payload (full source): https://ui.logic2b.com/r/<name>.json - Usage examples per item: https://ui.logic2b.com/r/demos/index.json → https://ui.logic2b.com/r/demos/<demo>.json - Docs index for agents: https://ui.logic2b.com/llms.txt (full docs: https://ui.logic2b.com/llms-full.txt) - Every docs page is Markdown when you append `.md` to its URL. Registry file paths map to project paths like this: - `ui/*` → `@/components/ui/*` - `blocks/<name>/*` → `@/components/*` - `charts/*` → `@/components/charts/*` - `hooks/*` → `@/hooks/*` - `lib/utils.ts` → `@/lib/utils.ts` - `theme.css` → next to the project's Tailwind entry stylesheet ## Conventions (do not break these) - Tailwind CSS v4 (CSS-first config; no tailwind.config.js needed) and React 19. - All colors go through semantic tokens (`bg-primary`, `text-muted-foreground`, `border-border`, …). Never hardcode hex/oklch values in components. - Dark mode is class-based: toggle `.dark` on `<html>`. - Icons come from lucide-react. ## Verify before finishing 1. The dev server starts and the app renders without console errors. 2. TypeScript compiles (`tsc --noEmit` or the framework's check). 3. Components use the theme: a `<Button>` shows the primary token color, and toggling `.dark` on `<html>` switches the palette.
Install the dependencies:
npm install lucide-reactCopy into ui/tags-input.tsx:
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
/**
* Tags input — free-form multi-value entry rendered as removable chips.
* Enter or comma commits the draft, paste splits on commas, Backspace on an
* empty draft removes the last tag. Duplicates are ignored; `max` caps the
* list.
*/
function TagsInput({
className,
value: valueProp,
defaultValue,
onValueChange,
placeholder,
max,
disabled,
id,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
...props
}: Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
value?: string[]
defaultValue?: string[]
onValueChange?: (value: string[]) => void
placeholder?: string
max?: number
disabled?: boolean
}) {
const inputRef = React.useRef<HTMLInputElement>(null)
const [draft, setDraft] = React.useState("")
const [internal, setInternal] = React.useState(defaultValue ?? [])
const isControlled = valueProp !== undefined
const value = isControlled ? valueProp : internal
const setValue = (next: string[]) => {
if (!isControlled) setInternal(next)
onValueChange?.(next)
}
const atMax = max !== undefined && value.length >= max
const commit = (raw: string) => {
const parts = raw
.split(",")
.map((t) => t.trim())
.filter(Boolean)
if (parts.length === 0) return
const tags = parts.filter((t) => !value.includes(t))
if (tags.length > 0) {
const next = [...value, ...tags]
setValue(max !== undefined ? next.slice(0, max) : next)
}
// Consume the draft even when everything was a duplicate.
setDraft("")
}
const remove = (tag: string) => setValue(value.filter((t) => t !== tag))
return (
<div
data-slot="tags-input"
data-disabled={disabled || undefined}
className={cn(
"border-input dark:bg-input/30 focus-within:border-ring focus-within:ring-ring/50 flex min-h-9 w-full flex-wrap items-center gap-1 rounded-md border bg-transparent px-2 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-[3px]",
disabled && "pointer-events-none cursor-not-allowed opacity-50",
className
)}
onClick={() => inputRef.current?.focus()}
{...props}
>
{value.map((tag) => (
<span
key={tag}
data-slot="tags-input-tag"
className="bg-secondary text-secondary-foreground inline-flex max-w-full items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs font-medium"
>
<span className="truncate">{tag}</span>
<button
type="button"
data-slot="tags-input-remove"
aria-label={`Remove ${tag}`}
tabIndex={-1}
disabled={disabled}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded-xs outline-none focus-visible:ring-2"
onClick={(event) => {
event.stopPropagation()
remove(tag)
}}
>
<XIcon className="size-3" />
</button>
</span>
))}
<input
ref={inputRef}
id={id}
data-slot="tags-input-field"
type="text"
value={draft}
disabled={disabled || atMax}
placeholder={atMax ? undefined : placeholder}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
className="placeholder:text-muted-foreground min-w-16 flex-1 bg-transparent py-0.5 outline-none disabled:cursor-not-allowed"
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault()
commit(draft)
} else if (event.key === "Backspace" && draft === "" && value.length > 0) {
remove(value[value.length - 1])
}
}}
onPaste={(event) => {
const text = event.clipboardData.getData("text")
if (text.includes(",")) {
event.preventDefault()
commit(draft + text)
}
}}
onBlur={() => commit(draft)}
/>
</div>
)
}
export { TagsInput }Usage
import { TagsInput } from "@/components/ui/tags-input"
const [tags, setTags] = React.useState<string[]>([])
<TagsInput
value={tags}
onValueChange={setTags}
placeholder="Add a tag…"
/>
Enter or comma commits the draft, pasted comma-separated text is split into tags, Backspace on an empty field removes the last tag, and blur commits whatever is pending. Duplicates are ignored.
API
| Prop | Type | Default |
|---|---|---|
value / defaultValue |
string[] — controlled/uncontrolled tag list |
[] |
onValueChange |
(value: string[]) => void |
— |
placeholder |
string — hint shown in the free-text field |
— |
max |
number — cap the list; the field locks at the limit |
— |
disabled |
boolean |
false |
id / aria-label / aria-labelledby |
forwarded to the inner text field for labeling | — |
Examples
Max tags
Cap the list with max — extra pasted values are dropped and the field
disables until a tag is removed.
design
1/3 — the field locks when the limit is reached.
"use client"
import * as React from "react"
import { Label } from "@/components/ui/label"
import { TagsInput } from "@/components/ui/tags-input"
export default function TagsInputMaxDemo() {
const [tags, setTags] = React.useState(["design"])
return (
<div className="grid w-full max-w-sm gap-2">
<Label htmlFor="skills">Skills (max 3)</Label>
<TagsInput
id="skills"
value={tags}
onValueChange={setTags}
placeholder="Add a skill…"
max={3}
/>
<p className="text-muted-foreground text-sm">
{tags.length}/3 — the field locks when the limit is reached.
</p>
</div>
)
}Accessibility
- The editable part is a real
<input>: label it with<Label htmlFor>(theidis forwarded) oraria-label. - Each chip’s close button exposes
aria-label="Remove <tag>". - Backspace on an empty field removes the last tag, so the whole control works without a pointer.