Skip to content
goodtabs UI
BrowseBrowse components

Tag input

Chips inside one input. Enter or a comma adds a tag, Backspace removes the last.

npx shadcn@latest add https://ui.goodtabs.co/r/tag-input.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/tag-input

reacttailwind

Two tags to start. Type, then press Enter or a comma to add more.

With a max of 5

designfontsicons3/5

Up to 5 topics. Enter or a comma adds one.

Controlled, with a count on the right and no more adds at the limit.

In a settings row

People with these email domains can join.
goodtabs.colinear.app

Pre-filled next to a label and a description, in a card.

Usage

import { TagInput } from "@/components/ui/tag-input"

<TagInput aria-label="Tags" defaultValue={["react"]} max={5} onValueChange={setTags} />

Props

PropTypeDefaultDescription
valuestring[]Controlled tags. Leave out for an uncontrolled field.
defaultValuestring[][]Starting tags for the uncontrolled form.
onValueChange(value: string[]) => voidFires with the new list after every add or remove.
placeholderstring"Add a tag"Shown in the empty input.
maxnumberMost tags allowed. Shows a count and stops adding at the limit.
allowDuplicatesbooleanfalseKeep tags that already exist, compared case-insensitively.

Source

tag-input/tag-input.tsx
"use client";

import * as React from "react";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";

type TagInputProps = Omit<React.ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "size"> & {
  /** Controlled tags. Leave out for an uncontrolled field. */
  value?: string[];
  /** Starting tags for the uncontrolled form. */
  defaultValue?: string[];
  /** Fires with the new list after every add or remove. */
  onValueChange?: (value: string[]) => void;
  /** Most tags allowed. Shows a count and stops adding at the limit. */
  max?: number;
  /** Keep tags that already exist, compared case-insensitively. */
  allowDuplicates?: boolean;
};

/**
 * Chips inside one bordered field. Enter or a comma adds the typed text,
 * Backspace on an empty input removes the last chip. Looks like shadcn's Input.
 */
export function TagInput({
  value,
  defaultValue = [],
  onValueChange,
  max,
  allowDuplicates = false,
  placeholder = "Add a tag",
  disabled,
  className,
  onKeyDown,
  onBlur,
  ...props
}: TagInputProps) {
  const [inner, setInner] = React.useState(defaultValue);
  const [text, setText] = React.useState("");
  const inputRef = React.useRef<HTMLInputElement>(null);
  const controlled = value !== undefined;
  const tags = controlled ? value : inner;
  const full = max !== undefined && tags.length >= max;

  const update = (next: string[]) => {
    if (!controlled) setInner(next);
    onValueChange?.(next);
  };

  /** Adds every non-empty piece, honouring max and duplicates. Returns the list it left behind. */
  const add = (pieces: string[]) => {
    let next = tags;
    for (const piece of pieces) {
      const tag = piece.trim();
      if (!tag) continue;
      if (max !== undefined && next.length >= max) break;
      if (!allowDuplicates && next.some((t) => t.toLowerCase() === tag.toLowerCase())) continue;
      next = [...next, tag];
    }
    if (next !== tags) update(next);
  };

  const remove = (index: number) => {
    update(tags.filter((_, i) => i !== index));
    inputRef.current?.focus();
  };

  return (
    <div
      data-slot="tag-input"
      data-disabled={disabled || undefined}
      onClick={() => inputRef.current?.focus()}
      className={cn(
        "flex min-h-8 w-full min-w-0 cursor-text flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent py-1 pr-2.5 pl-1 transition-colors has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-3 has-[input:focus-visible]:ring-ring/50 dark:bg-input/30 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50",
        className,
      )}
    >
      {tags.map((tag, i) => (
        <span
          key={`${tag}-${i}`}
          data-slot="tag-input-chip"
          className="inline-flex h-6 max-w-full items-center gap-1 rounded-full bg-muted px-2 text-xs text-foreground"
        >
          <span className="truncate">{tag}</span>
          <button
            type="button"
            disabled={disabled}
            aria-label={`Remove ${tag}`}
            onClick={(e) => {
              e.stopPropagation();
              remove(i);
            }}
            className="-mr-1 inline-grid size-4 shrink-0 place-items-center rounded-full text-foreground/70 transition-colors duration-100 ease-out outline-none hover:bg-foreground/10 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
          >
            <XIcon className="size-3" aria-hidden="true" />
          </button>
        </span>
      ))}
      <input
        ref={inputRef}
        type="text"
        value={text}
        disabled={disabled}
        readOnly={full}
        placeholder={full ? undefined : placeholder}
        autoComplete="off"
        onChange={(e) => {
          const next = e.target.value;
          if (next.includes(",")) {
            const parts = next.split(",");
            add(parts.slice(0, -1));
            setText(parts[parts.length - 1]);
          } else {
            setText(next);
          }
        }}
        onKeyDown={(e) => {
          onKeyDown?.(e);
          if (e.defaultPrevented) return;
          if (e.key === "Enter" && text.trim()) {
            e.preventDefault();
            add([text]);
            setText("");
          } else if (e.key === "Backspace" && text === "" && tags.length > 0) {
            e.preventDefault();
            update(tags.slice(0, -1));
          }
        }}
        onBlur={(e) => {
          onBlur?.(e);
          if (text.trim()) {
            add([text]);
            setText("");
          }
        }}
        className="h-6 min-w-16 flex-1 bg-transparent px-1.5 text-base outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed md:text-sm"
        {...props}
      />
      {max !== undefined && (
        <span className="ml-auto shrink-0 font-mono text-xs text-muted-foreground" aria-live="polite">
          {tags.length}/{max}
        </span>
      )}
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/tag-input/prompt.md, or the Copy AI prompt button above.

When to use

  • Keywords, topics, skills, domains or emails: short values that pile up in one field
  • Filters that people build up a term at a time
  • Anywhere a comma-separated string would otherwise be typed by hand

When not to use

  • Picking from a fixed list: use a multi-select or a combobox
  • Long values, sentences or anything that needs editing in place
  • More than a dozen tags, where a list with its own layout reads better

Agent notes

  • It takes a function (onValueChange), so a demo or page that passes one must be a client component
  • The box is a div styled like shadcn's Input; className lands on it, every other prop lands on the inner input
  • Give it an aria-label or an id that a Label points at; the chips have no label of their own
  • Text left in the input is added on blur, so nothing typed is lost on submit