Build websites 10x faster with HextaUI Blocks — Learn more
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.

components/ui/tag-input.tsx
"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 or Tab: Add the current input as a tag
  • Backspace: When input is empty, remove the last tag
  • Click: Focus the input field when clicking anywhere on the container

Props

PropTypeDefault
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