UI/UI
Tag Input
A flexible input component for adding and managing multiple tags with support for keyboard navigation and customization.
JavaScript
React
TypeScript
const [tags, setTags] = useState(["React", "Next.js", "TypeScript"]);
<TagInput
tags={tags}
onTagsChange={setTags}
placeholder="Add some tags..."
/>
Installation
Install following dependencies:
npm install @radix-ui/react-slot class-variance-authority lucide-react
pnpm add @radix-ui/react-slot class-variance-authority lucide-react
yarn add @radix-ui/react-slot class-variance-authority lucide-react
bun add @radix-ui/react-slot class-variance-authority lucide-react
Copy and paste the following code into your project.
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Input } from "./input";
import { Chip } from "./chip";
import { X } from "lucide-react";
const tagInputVariants = cva(
"min-h-9 w-full rounded-ele border border-border bg-input px-3 py-2 text-sm ring-offset-background transition-all focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
variant: {
default: "border-border",
destructive:
"border-destructive focus-within:ring-destructive",
},
size: {
sm: "min-h-8 px-2 py-1 text-xs",
default: "min-h-9 px-3 py-2 text-sm",
lg: "min-h-10 px-4 py-2",
xl: "min-h-12 px-6 py-3 text-base",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface TagInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size" | "value" | "onChange">,
VariantProps<typeof tagInputVariants> {
tags: string[];
onTagsChange: (tags: string[]) => void;
maxTags?: number;
placeholder?: string;
tagVariant?: "default" | "secondary" | "destructive" | "outline" | "ghost";
tagSize?: "sm" | "default" | "lg";
allowDuplicates?: boolean;
onTagAdd?: (tag: string) => void;
onTagRemove?: (tag: string) => void;
separator?: string | RegExp;
clearAllButton?: boolean;
onClearAll?: () => void;
disabled?: boolean;
error?: boolean;
}
const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
(
{
className,
variant,
size,
tags,
onTagsChange,
maxTags,
placeholder = "Type and press Enter to add tags...",
tagVariant = "secondary",
tagSize = "sm",
allowDuplicates = false,
onTagAdd,
onTagRemove,
separator = /[\s,]+/,
clearAllButton = false,
onClearAll,
disabled,
error,
...props
},
ref,
) => {
const [inputValue, setInputValue] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const addTag = React.useCallback(
(tag: string) => {
const trimmedTag = tag.trim();
if (!trimmedTag) return;
if (!allowDuplicates && tags.includes(trimmedTag)) return;
if (maxTags && tags.length >= maxTags) return;
const newTags = [...tags, trimmedTag];
onTagsChange(newTags);
onTagAdd?.(trimmedTag);
setInputValue("");
},
[tags, onTagsChange, onTagAdd, allowDuplicates, maxTags],
);
const removeTag = React.useCallback(
(tagToRemove: string) => {
const newTags = tags.filter((tag) => tag !== tagToRemove);
onTagsChange(newTags);
onTagRemove?.(tagToRemove);
},
[tags, onTagsChange, onTagRemove],
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (separator instanceof RegExp) {
const parts = value.split(separator);
if (parts.length > 1) {
parts.slice(0, -1).forEach((part) => addTag(part));
setInputValue(parts[parts.length - 1]);
return;
}
} else if (typeof separator === "string" && value.includes(separator)) {
const parts = value.split(separator);
parts.slice(0, -1).forEach((part) => addTag(part));
setInputValue(parts[parts.length - 1]);
return;
}
setInputValue(value);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
addTag(inputValue);
} else if (e.key === "Backspace" && inputValue === "" && tags.length > 0) {
removeTag(tags[tags.length - 1]);
}
};
const handleClearAll = () => {
onTagsChange([]);
onClearAll?.();
setInputValue("");
};
const handleContainerClick = () => {
inputRef.current?.focus();
};
const chipSizeMapping = {
sm: "sm" as const,
default: "sm" as const,
lg: "default" as const,
xl: "default" as const,
};
return (
<div className="relative">
<div
className={cn(
tagInputVariants({ variant: error ? "destructive" : variant, size }),
"cursor-text",
className,
)}
onClick={handleContainerClick}
>
<div className="flex flex-wrap gap-1.5">
{tags.map((tag, index) => (
<Chip
key={`${tag}-${index}`}
variant={tagVariant}
size={chipSizeMapping[size || "default"]}
dismissible
onDismiss={() => removeTag(tag)}
className="pointer-events-auto"
>
{tag}
</Chip>
))}
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={tags.length === 0 ? placeholder : ""}
disabled={disabled || (maxTags ? tags.length >= maxTags : false)}
className="flex-1 min-w-[120px] bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
{...props}
/>
</div>
</div>
{clearAllButton && tags.length > 0 && (
<button
type="button"
onClick={handleClearAll}
disabled={disabled}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1 hover:bg-accent transition-colors disabled:pointer-events-none disabled:opacity-50"
aria-label="Clear all tags"
>
<X size={14} className="text-muted-foreground" />
</button>
)}
</div>
);
},
);
TagInput.displayName = "TagInput";
export { TagInput, tagInputVariants };
npx hextaui@latest add tag-input
pnpm dlx hextaui@latest add tag-input
yarn dlx hextaui@latest add tag-input
bun x hextaui@latest add tag-input
Usage
import { TagInput } from "@/components/ui/tag-input";
const [tags, setTags] = useState<string[]>([]);
<TagInput
tags={tags}
onTagsChange={setTags}
placeholder="Add tags..."
/>
Examples
Default
const [tags, setTags] = useState<string[]>([]);
<TagInput
tags={tags}
onTagsChange={setTags}
placeholder="Type and press Enter..."
/>
With Pre-filled Tags
JavaScript
React
TypeScript
const [tags, setTags] = useState([
"JavaScript",
"React",
"TypeScript",
"Next.js"
]);
<TagInput
tags={tags}
onTagsChange={setTags}
placeholder="Add more tags..."
/>
Variants
React
Next.js
Invalid
Tags
// Default variant
<TagInput
tags={tags}
onTagsChange={setTags}
variant="default"
/>
// Error state
<TagInput
tags={tags}
onTagsChange={setTags}
error
/>
Sizes
Small
Tags
Default
Size
Large
Tags
XL
Tags
<TagInput tags={tags} onTagsChange={setTags} size="sm" />
<TagInput tags={tags} onTagsChange={setTags} size="default" />
<TagInput tags={tags} onTagsChange={setTags} size="lg" />
<TagInput tags={tags} onTagsChange={setTags} size="xl" />
Tag Variants
Primary
Important
Secondary
Normal
Outline
Minimal
Error
Warning
<TagInput tagVariant="default" tags={tags} onTagsChange={setTags} />
<TagInput tagVariant="secondary" tags={tags} onTagsChange={setTags} />
<TagInput tagVariant="outline" tags={tags} onTagsChange={setTags} />
<TagInput tagVariant="destructive" tags={tags} onTagsChange={setTags} />
With Max Tags
React
Next.js
<TagInput
tags={tags}
onTagsChange={setTags}
maxTags={3}
placeholder="Add up to 3 tags..."
/>
With Clear All Button
Next.js
TypeScript
Tailwind
<TagInput
tags={tags}
onTagsChange={setTags}
clearAllButton
onClearAll={() => console.log("All tags cleared!")}
/>
Custom Separator
// Comma separator
<TagInput
tags={tags}
onTagsChange={setTags}
separator=","
/>
// Space or comma separator (regex)
<TagInput
tags={tags}
onTagsChange={setTags}
separator={/[\s,]+/}
/>
With Event Handlers
React
Next.js
<TagInput
tags={tags}
onTagsChange={setTags}
onTagAdd={(tag) => {
console.log("Tag added:", tag);
// Custom logic when tag is added
}}
onTagRemove={(tag) => {
console.log("Tag removed:", tag);
// Custom logic when tag is removed
}}
onClearAll={() => {
console.log("All tags cleared!");
// Custom logic when all tags are cleared
}}
/>
Disabled State
React
Next.js
<TagInput
tags={tags}
onTagsChange={setTags}
disabled
/>
Keyboard Navigation
The TagInput component supports several keyboard shortcuts:
Enter
orTab
: Add the current input as a tagBackspace
: When input is empty, remove the last tagClick
: Focus the input field when clicking anywhere on the container
Props
Prop | Type | Default |
---|---|---|
className? | string | undefined |
error? | boolean | false |
disabled? | boolean | false |
onClearAll? | () => void | undefined |
clearAllButton? | boolean | false |
separator? | string | RegExp | /[\s,]+/ |
onTagRemove? | (tag: string) => void | undefined |
onTagAdd? | (tag: string) => void | undefined |
allowDuplicates? | boolean | false |
tagSize? | "sm" | "default" | "lg" | "sm" |
tagVariant? | "default" | "secondary" | "destructive" | "outline" | "secondary" |
placeholder? | string | "Type and press Enter to add tags..." |
maxTags? | number | undefined |
size? | "sm" | "default" | "lg" | "xl" | "default" |
variant? | "default" | "destructive" | "default" |
onTagsChange? | (tags: string[]) => void | undefined |
tags? | string[] | undefined |
Edit on GitHub
Last updated on