All componentsform
Tag Input
reacttypescript
Tags: react, typescript
Max 3 tags
Installation
$ npx shadcn@latest add https://deepmoon.dev/r/tag-input.jsonnpm dependencies: lucide-react
Props
TagInputProps
| Prop | Type | Required |
|---|---|---|
| value | string[] | Yes |
| onChange | (value: string[]) => void | Yes |
| placeholder | string | No |
| maxTags | number | No |
| disabled | boolean | No |
| className | string | No |
| aria-label | string | No |
Code
tag-input.tsx
"use client";
import * as React from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface TagInputProps {
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
maxTags?: number;
disabled?: boolean;
className?: string;
"aria-label"?: string;
}
/**
* Freeform version of the chip pattern used by Multiselect, without a
* listbox popup — there's nothing to search or pick, so it's just a plain
* text input with rendered removable chips. Fully accessible by
* construction: no custom ARIA widget role needed, native input semantics
* cover it.
*/
export function TagInput({
value,
onChange,
placeholder = "Add a tag...",
maxTags,
disabled = false,
className,
"aria-label": ariaLabel = "Tags",
}: TagInputProps) {
const [draft, setDraft] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const atMax = maxTags !== undefined && value.length >= maxTags;
const commit = (raw: string) => {
const tag = raw.trim();
if (!tag || atMax || value.includes(tag)) return;
onChange([...value, tag]);
setDraft("");
};
const removeTag = (tag: string) => {
onChange(value.filter((t) => t !== tag));
inputRef.current?.focus();
};
return (
<div
onClick={() => !disabled && inputRef.current?.focus()}
className={cn(
"flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-2 py-1.5",
"has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
disabled && "cursor-not-allowed opacity-50",
className,
)}
>
{value.map((tag) => (
<span
key={tag}
className="flex items-center gap-1 rounded bg-secondary py-0.5 pl-2 pr-1 text-xs font-medium text-secondary-foreground"
>
{tag}
{!disabled ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
removeTag(tag);
}}
aria-label={`Remove ${tag}`}
className="rounded-sm p-0.5 text-secondary-foreground/70 outline-none hover:bg-secondary-foreground/10 hover:text-secondary-foreground focus-visible:ring-1 focus-visible:ring-ring"
>
<X className="size-3" aria-hidden="true" />
</button>
) : null}
</span>
))}
<input
ref={inputRef}
type="text"
aria-label={ariaLabel}
disabled={disabled || atMax}
value={draft}
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) {
removeTag(value[value.length - 1]);
}
}}
onBlur={() => commit(draft)}
placeholder={atMax ? `Max ${maxTags} tags` : value.length === 0 ? placeholder : ""}
className="min-w-16 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
/>
</div>
);
}