# Tags Input

A free-form chip input with paste splitting, validation, and duplicate handling.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/tags-input.json
```

[Registry JSON](https://mwui.vercel.app/r/tags-input.json)

## Preview

```tsx
import * as React from "react";

import { TagsInput } from "@/components/ui/tags-input";

export function Preview() {
  const [labels, setLabels] = React.useState<string[]>(["design", "urgent"]);
  const [recipients, setRecipients] = React.useState<string[]>([]);

  return (
    <div className="flex w-full max-w-sm flex-col gap-6 text-sm">
      <div className="flex flex-col gap-2">
        <p className="font-medium">Labels</p>
        <TagsInput
          value={labels}
          onValueChange={setLabels}
          max={6}
          aria-label="Labels"
          placeholder="Add a label…"
        />
        <p className="font-mono text-xs text-muted-foreground">{JSON.stringify(labels)}</p>
      </div>

      <div className="flex flex-col gap-2">
        <p className="font-medium">Recipients</p>
        <TagsInput
          value={recipients}
          onValueChange={setRecipients}
          delimiters={[",", " ", ";"]}
          aria-label="Recipients"
          placeholder="name@example.com"
          validate={(tag) =>
            /^[^@\s]+@[^@\s]+\.[^@\s]+$/u.test(tag) ? true : `${tag} is not an email address.`
          }
        />
        <p className="text-xs text-muted-foreground">
          Try pasting <span className="font-mono">a@b.com, c@d.com</span>.
        </p>
      </div>
    </div>
  );
}
```


## Source

### ui/tags-input.tsx

```tsx
"use client";

import { IconX } from "@tabler/icons-react";
import * as React from "react";

import { useControllableState } from "@/hooks/use-controllable-state";
import { cn } from "@/lib/utils";

type TagsInputValidation = true | string;

type TagsInputProps = Omit<
  React.ComponentProps<"div">,
  "defaultValue" | "onChange" | "onInvalid" | "value"
> & {
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
  placeholder?: string;
  delimiters?: string[];
  max?: number;
  allowDuplicates?: boolean;
  validate?: (tag: string) => TagsInputValidation;
  blurBehavior?: "add" | "clear" | "keep";
  disabled?: boolean;
  name?: string;
  "aria-label"?: string;
};

function TagsInput({
  value,
  defaultValue = [],
  onValueChange,
  placeholder = "Add a tag…",
  delimiters = [",", " "],
  max,
  allowDuplicates = false,
  validate,
  blurBehavior = "add",
  disabled = false,
  name,
  className,
  "aria-label": ariaLabel = "Tags",
  ...props
}: TagsInputProps) {
  const [tags, setTags] = useControllableState<string[]>({
    value,
    defaultValue,
    onChange: onValueChange,
    isEqual: (a, b) => a.length === b.length && a.every((tag, index) => tag === b[index]),
  });

  const [draft, setDraft] = React.useState("");
  const [error, setError] = React.useState<string | null>(null);
  const [duplicate, setDuplicate] = React.useState<string | null>(null);

  const errorId = React.useId();
  const inputRef = React.useRef<HTMLInputElement>(null);
  const isFull = max !== undefined && tags.length >= max;

  const flagDuplicate = (tag: string) => {
    setDuplicate(tag);
    window.setTimeout(() => setDuplicate((current) => (current === tag ? null : current)), 600);
  };

  const addTags = (candidates: string[]) => {
    const next = [...tags];
    let rejected: string | null = null;

    for (const candidate of candidates) {
      const tag = candidate.trim();
      if (!tag) continue;

      if (max !== undefined && next.length >= max) {
        rejected = `Up to ${max} tags.`;
        break;
      }

      if (!allowDuplicates && next.includes(tag)) {
        flagDuplicate(tag);
        continue;
      }

      const result = validate?.(tag) ?? true;

      if (result !== true) {
        rejected = typeof result === "string" ? result : `${tag} is not allowed.`;
        continue;
      }

      next.push(tag);
    }

    setError(rejected);
    if (next.length !== tags.length) setTags(next);

    return rejected === null;
  };

  const removeTag = (tag: string) => {
    setError(null);
    setTags(tags.filter((current) => current !== tag));
  };

  const commitDraft = () => {
    if (!draft.trim()) return;
    if (addTags(splitValue(draft, delimiters))) setDraft("");
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "Enter") {
      event.preventDefault();
      commitDraft();
      return;
    }

    if (delimiters.includes(event.key) && draft.trim()) {
      event.preventDefault();
      commitDraft();
      return;
    }

    if (event.key === "Backspace" && draft === "" && tags.length > 0) {
      event.preventDefault();
      removeTag(tags[tags.length - 1]);
    }
  };

  return (
    <div
      data-slot="tags-input"
      data-disabled={disabled || undefined}
      className={cn("flex w-full flex-col gap-1.5", className)}
      {...props}
    >
      {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
      <div
        className={cn(
          "flex min-h-8 w-full flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent px-1.5 py-1 text-sm transition-colors",
          "focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
          "data-disabled:pointer-events-none data-disabled:opacity-50",
          error && "border-destructive focus-within:border-destructive",
        )}
        data-disabled={disabled || undefined}
        onClick={() => inputRef.current?.focus()}
      >
        <ul aria-label={ariaLabel} className="contents">
          {tags.map((tag) => (
            <li
              key={tag}
              data-duplicate={tag === duplicate || undefined}
              className={cn(
                "flex items-center gap-1 rounded-md bg-muted py-0.5 pr-0.5 pl-1.5 text-xs text-foreground transition-colors",
                "data-duplicate:bg-destructive/15 data-duplicate:text-destructive",
              )}
            >
              <span className="max-w-40 truncate">{tag}</span>
              <button
                type="button"
                aria-label={`Remove ${tag}`}
                disabled={disabled}
                onClick={() => removeTag(tag)}
                className="flex size-4 items-center justify-center rounded-sm text-muted-foreground outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
              >
                <IconX className="size-3" />
              </button>
            </li>
          ))}
        </ul>

        <input
          ref={inputRef}
          value={draft}
          disabled={disabled || isFull}
          placeholder={isFull ? undefined : tags.length > 0 ? "" : placeholder}
          aria-invalid={error !== null || undefined}
          aria-describedby={error ? errorId : undefined}
          className="h-6 min-w-16 flex-1 bg-transparent px-1 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
          onChange={(event) => {
            setError(null);
            setDraft(event.target.value);
          }}
          onKeyDown={handleKeyDown}
          onPaste={(event) => {
            const text = event.clipboardData.getData("text");
            const parts = splitValue(text, delimiters);

            if (parts.length <= 1) return;

            event.preventDefault();
            addTags(parts);
            setDraft("");
          }}
          onBlur={() => {
            if (blurBehavior === "add") commitDraft();
            if (blurBehavior === "clear") setDraft("");
          }}
        />
      </div>

      {error ? (
        <p id={errorId} role="alert" className="text-xs text-destructive">
          {error}
        </p>
      ) : null}

      {name
        ? tags.map((tag) => <input key={tag} type="hidden" name={name} value={tag} readOnly />)
        : null}
    </div>
  );
}

function splitValue(value: string, delimiters: string[]): string[] {
  const pattern = new RegExp(
    `[${delimiters.map(escapeForCharacterClass).join("")}\\n\\r\\t]+`,
    "u",
  );

  return value.split(pattern).filter(Boolean);
}

function escapeForCharacterClass(value: string): string {
  return value.replace(/[\\\]^-]/gu, "\\$&");
}

export { TagsInput, type TagsInputProps, type TagsInputValidation };
```



## Usage

For values the user types rather than picks: labels, keywords, recipients, allowed domains. Where
[multi-select](/components/multi-select) chooses from a known list, this one accepts anything that
passes your `validate`.

```tsx
import { TagsInput } from "@/components/ui/tags-input";

export function LabelField() {
  const [labels, setLabels] = React.useState<string[]>(["bug"]);

  return <TagsInput value={labels} onValueChange={setLabels} placeholder="Add a label…" />;
}
```

The value is a `string[]`, so it goes straight into a form body or a database column.

## Committing a tag

Enter commits. So does any character in `delimiters`, which defaults to comma and space — set it to
`[","]` alone when tags may contain spaces:

```tsx
<TagsInput delimiters={[","]} placeholder="Add a phrase…" />
```

Backspace on an empty input removes the last tag. Blur commits the draft by default; pass
`blurBehavior="clear"` to discard it, or `"keep"` to leave it in place.

## Pasting

Pasting text that contains delimiters, newlines, or tabs adds every part at once, which is what
makes this usable for a column copied out of a spreadsheet:

```
alice@example.com, bob@example.com, carol@example.com
```

A paste with no delimiter is left alone as ordinary text, so a single value can still be edited
before committing.

## Validation

`validate` returns `true` to accept, or a message to reject. The message renders under the field and
is wired up with `aria-describedby`.

```tsx
<TagsInput
  validate={(tag) => (tag.includes("@") ? true : `${tag} is not an email address.`)}
  onValueChange={setRecipients}
/>
```

Duplicates are rejected silently and the existing chip flashes instead — repeating "already added"
as an error is noise for something the user can see.

## Props

| Prop              | Description                                                                 |
| ----------------- | --------------------------------------------------------------------------- |
| `value` / `defaultValue` / `onValueChange` | Controlled or uncontrolled `string[]`.             |
| `delimiters`      | Characters that commit the draft. Defaults to `[",", " "]`.                  |
| `max`             | Maximum number of tags. The input disables at the limit.                     |
| `allowDuplicates` | Permit repeated values. Off by default.                                      |
| `validate`        | `(tag) => true \| string`. A string is shown as the error message.           |
| `blurBehavior`    | `"add"` (default), `"clear"`, or `"keep"`.                                   |
| `name`            | Emits one hidden input per tag, so the field posts in a plain HTML form.      |
| `aria-label`      | Labels the chip list. Defaults to `"Tags"`.                                  |

## Accessibility

Tags are a list of `li` elements, each with its own labelled remove button, so a screen reader
announces the count and reads every tag. Rejections use `role="alert"`. Clicking anywhere in the
field forwards focus to the input.

