Command Palette

Search for a command to run...

GitHub

Tags Input

A composable tags input built on shadcn primitives. Supports controlled and uncontrolled modes, keyboard navigation, paste-splitting, custom validation, and form integration.

Demo

A controlled tags input.

typescriptnext.js

value: ["typescript","next.js"]

Install

One command via the shadcn CLI, or copy the source manually.

CLI

Adds the file and resolves the registry dependency automatically.

$
bunx --bun shadcn@latest add https://huanngdev.site/r/shadcn-tags-input.json
Use the @huanng/... shorthand

Requires registering the namespace in your components.json: "registries": { "@huanng": "https://huanngdev.site/r/{name}.json" }

$
bunx --bun shadcn@latest add @huanng/shadcn-tags-input

Manual

Four steps if you prefer to copy the file yourself.

1

Install dependencies

Runtime libraries the component needs.

$
bun add lucide-react
2

Add shadcn primitives

Pulls in the badge component used by tags.

$
bunx --bun shadcn@latest add badge
3

Copy the source

Drop into components/shadcn-tags-input/tags-input.tsx.

tsx
"use client";

import * as React from "react";
import { X } from "lucide-react";

import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";

export type UseTagsInputOptions = {
  value?: string[];
  defaultValue?: string[];
  onChange?: (value: string[]) => void;
  maxTags?: number;
  minLength?: number;
  maxLength?: number;
  allowDuplicates?: boolean;
  trim?: boolean;
  validate?: (tag: string, tags: string[]) => true | string;
};

export type UseTagsInputReturn = {
  tags: string[];
  setTags: (value: string[]) => void;
  addTag: (tag: string) => true | string;
  removeTag: (index: number) => void;
  clear: () => void;
};

export function useTagsInput({
  value,
  defaultValue,
  onChange,
  maxTags,
  minLength = 1,
  maxLength,
  allowDuplicates = false,
  trim = true,
  validate,
}: UseTagsInputOptions = {}): UseTagsInputReturn {
  const isControlled = value !== undefined;
  const [internal, setInternal] = React.useState<string[]>(defaultValue ?? []);
  const tags = isControlled ? value : internal;

  const commit = React.useCallback(
    (next: string[]) => {
      if (!isControlled) setInternal(next);
      onChange?.(next);
    },
    [isControlled, onChange],
  );

  const setTags = React.useCallback((next: string[]) => commit(next), [commit]);

  const addTag = React.useCallback(
    (raw: string): true | string => {
      const tag = trim ? raw.trim() : raw;
      if (tag.length < minLength)
        return `Tag must be at least ${minLength} character${minLength === 1 ? "" : "s"}`;
      if (maxLength && tag.length > maxLength)
        return `Tag must be at most ${maxLength} characters`;
      if (!allowDuplicates && tags.includes(tag)) return "Tag already exists";
      if (maxTags && tags.length >= maxTags) return `Maximum ${maxTags} tags allowed`;
      if (validate) {
        const result = validate(tag, tags);
        if (result !== true) return result;
      }
      commit([...tags, tag]);
      return true;
    },
    [tags, trim, minLength, maxLength, allowDuplicates, maxTags, validate, commit],
  );

  const removeTag = React.useCallback(
    (index: number) => {
      if (index < 0 || index >= tags.length) return;
      const next = tags.slice();
      next.splice(index, 1);
      commit(next);
    },
    [tags, commit],
  );

  const clear = React.useCallback(() => commit([]), [commit]);

  return { tags, setTags, addTag, removeTag, clear };
}

export type TagsInputProps = Omit<
  React.ComponentProps<"div">,
  "onChange" | "defaultValue"
> &
  Pick<
    UseTagsInputOptions,
    | "value"
    | "defaultValue"
    | "onChange"
    | "maxTags"
    | "minLength"
    | "maxLength"
    | "allowDuplicates"
    | "trim"
    | "validate"
  > & {
    placeholder?: string;
    disabled?: boolean;
    delimiters?: string[];
    onTagAdd?: (tag: string) => void;
    onTagRemove?: (tag: string, index: number) => void;
    onValidationError?: (message: string) => void;
    "aria-invalid"?: boolean;
    name?: string;
  };

export function TagsInput({
  value,
  defaultValue,
  onChange,
  maxTags,
  minLength,
  maxLength,
  allowDuplicates,
  trim,
  validate,
  placeholder = "Add a tag...",
  disabled = false,
  delimiters = ["Enter", ","],
  onTagAdd,
  onTagRemove,
  onValidationError,
  className,
  name,
  "aria-invalid": ariaInvalid,
  ...props
}: TagsInputProps) {
  const { tags, addTag, removeTag } = useTagsInput({
    value,
    defaultValue,
    onChange,
    maxTags,
    minLength,
    maxLength,
    allowDuplicates,
    trim,
    validate,
  });
  const [input, setInput] = React.useState("");
  const inputRef = React.useRef<HTMLInputElement>(null);

  const commitInput = (raw: string) => {
    if (!raw.trim()) return;
    const result = addTag(raw);
    if (result === true) {
      onTagAdd?.(trim === false ? raw : raw.trim());
      setInput("");
    } else {
      onValidationError?.(result);
    }
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (delimiters.includes(event.key)) {
      event.preventDefault();
      commitInput(input);
      return;
    }
    if (event.key === "Backspace" && input === "" && tags.length > 0) {
      const lastTag = tags[tags.length - 1];
      removeTag(tags.length - 1);
      onTagRemove?.(lastTag!, tags.length - 1);
    }
  };

  const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
    const pasted = event.clipboardData.getData("text");
    if (!pasted.includes(",") && !pasted.includes("\n")) return;
    event.preventDefault();
    const parts = pasted.split(/[,\n]/).map((p) => p.trim()).filter(Boolean);
    for (const part of parts) commitInput(part);
  };

  return (
    <div
      data-slot="tags-input"
      data-disabled={disabled || undefined}
      aria-invalid={ariaInvalid}
      onClick={() => inputRef.current?.focus()}
      className={cn(
        "border-input focus-within:border-ring focus-within:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 flex min-h-8 w-full flex-wrap items-center gap-1 rounded-lg border bg-transparent px-1.5 py-1 text-sm transition-colors outline-none focus-within:ring-3 aria-invalid:ring-3 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
        className,
      )}
      {...props}
    >
      {tags.map((tag, i) => (
        <Badge
          key={`${tag}-${i}`}
          variant="secondary"
          className="data-[icon=inline-end]:pr-1"
        >
          <span>{tag}</span>
          {!disabled && (
            <button
              type="button"
              data-icon="inline-end"
              aria-label={`Remove ${tag}`}
              onClick={(e) => {
                e.stopPropagation();
                removeTag(i);
                onTagRemove?.(tag, i);
              }}
              className="hover:bg-foreground/10 focus-visible:ring-ring/50 inline-flex size-3.5 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
            >
              <X className="size-2.5" />
            </button>
          )}
        </Badge>
      ))}
      <input
        ref={inputRef}
        type="text"
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyDown={handleKeyDown}
        onPaste={handlePaste}
        disabled={disabled}
        placeholder={tags.length === 0 ? placeholder : undefined}
        className="placeholder:text-muted-foreground min-w-[8ch] flex-1 bg-transparent px-1 py-0.5 text-sm outline-none disabled:cursor-not-allowed"
      />
      {name && <input type="hidden" name={name} value={JSON.stringify(tags)} />}
    </div>
  );
}
4

Browse the repo

Latest source, history, and issues live on GitHub.

Usage

Render with a controlled value and onChange handler.

jsx
"use client";

import * as React from "react";
import { TagsInput } from "@/components/shadcn-tags-input/tags-input";

export function Example() {
  const [tags, setTags] = React.useState<string[]>(["typescript", "next.js"]);
  return (
    <TagsInput
      value={tags}
      onChange={setTags}
      placeholder="Type and press Enter..."
    />
  );
}

useTagsInput hook

Use the hook directly if you need state without rendering the component.

js
import { useTagsInput } from "@/components/shadcn-tags-input/tags-input";

const { tags, addTag, removeTag, clear } = useTagsInput({
  defaultValue: ["alpha"],
  maxTags: 5,
  minLength: 2,
});

const result = addTag("beta");
if (result !== true) console.error(result); // "Tag must be at least 2 characters"

With constraints

Limit tag count, length, and surface validation errors inline.

Zod validation

Validate the final array with a Zod schema on submit.

jsx
"use client";

import * as React from "react";
import { z } from "zod";
import { TagsInput } from "@/components/shadcn-tags-input/tags-input";

const schema = z.object({
  tags: z
    .array(z.string().min(2).max(16))
    .min(1, "Add at least one tag")
    .max(5, "Maximum 5 tags allowed"),
});

export function TagsForm() {
  const [tags, setTags] = React.useState<string[]>([]);
  const [error, setError] = React.useState<string | null>(null);

  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const result = schema.safeParse({ tags });
    if (!result.success) {
      setError(result.error.issues[0]?.message ?? "Invalid");
      return;
    }
    setError(null);
    // submit result.data.tags
  };

  return (
    <form onSubmit={onSubmit}>
      <TagsInput
        value={tags}
        onChange={setTags}
        maxTags={5}
        aria-invalid={Boolean(error)}
      />
      {error && <p className="text-destructive text-xs">{error}</p>}
      <button type="submit">Submit</button>
    </form>
  );
}

The component itself enforces per-tag constraints at entry time (length, duplicates, max count). Use Zod to validate the final array shape on submit — e.g. require at least N tags, or constrain values against an enum. Pass aria-invalid when the schema rejects to apply the destructive ring.

Props

PropTypeDefaultDescription
valuestring[]Controlled tags array.
defaultValuestring[][]Initial tags (uncontrolled mode).
onChange(value: string[]) => voidFires when the tags array changes.
placeholderstring"Add a tag..."Placeholder shown when there are no tags.
disabledbooleanfalseDisables input and tag removal.
maxTagsnumberMaximum number of tags allowed.
minLengthnumber1Minimum length per tag.
maxLengthnumberMaximum length per tag.
allowDuplicatesbooleanfalseAllow the same tag to be added more than once.
trimbooleantrueTrim whitespace before committing a tag.
delimitersstring[]["Enter", ","]Keys that commit the current input as a tag.
validate(tag, tags) => true | stringCustom validator; return an error message to reject.
onTagAdd(tag: string) => voidCalled after a tag is added successfully.
onTagRemove(tag: string, index: number) => voidCalled after a tag is removed.
onValidationError(message: string) => voidCalled when a tag is rejected by validation.
aria-invalidbooleanSurfaces validation state to assistive tech and styling.
namestringAdds a hidden input carrying JSON-encoded tags for form submission.

Types

text
type TagsInputProps = {
  value?: string[];
  defaultValue?: string[];
  onChange?: (value: string[]) => void;
  placeholder?: string;
  disabled?: boolean;
  maxTags?: number;
  minLength?: number;
  maxLength?: number;
  allowDuplicates?: boolean;
  trim?: boolean;
  delimiters?: string[];
  validate?: (tag: string, tags: string[]) => true | string;
  onTagAdd?: (tag: string) => void;
  onTagRemove?: (tag: string, index: number) => void;
  onValidationError?: (message: string) => void;
  "aria-invalid"?: boolean;
  name?: string;
};

type UseTagsInputOptions = {
  value?: string[];
  defaultValue?: string[];
  onChange?: (value: string[]) => void;
  maxTags?: number;
  minLength?: number;
  maxLength?: number;
  allowDuplicates?: boolean;
  trim?: boolean;
  validate?: (tag: string, tags: string[]) => true | string;
};

type UseTagsInputReturn = {
  tags: string[];
  setTags: (value: string[]) => void;
  addTag: (tag: string) => true | string;
  removeTag: (index: number) => void;
  clear: () => void;
};