Skip to contentVibraUI
Inputs & filters

Tag input

A field that turns what you type into chips — tags, countries, currencies, anything list-shaped.

The tag list is controlled; only the half-typed text belongs to the field. Enter and any separator commit a tag after trimming it, duplicates are refused unless allowDuplicates is set, and every tag refused — by the validator, as a duplicate, or over maxTags — goes back into the field to be fixed rather than vanishing, on the Enter path, the separator path and the paste path alike, joined by the first separator when there are several. Backspace on an empty field removes the last chip. A paste splits on the separators and on newlines — a column out of a spreadsheet arrives one tag per row — and lands as a single change. Suggestions are a plain listbox positioned under the field rather than a popover: Base UI's popover needs a button trigger, and the trigger here is the field itself. Given suggestions the input becomes a real combobox — aria-expanded, aria-controls, aria-autocomplete=list — and the arrow keys move aria-activedescendant while focus stays in the field, so Enter takes the highlighted suggestion; without suggestions it claims none of that and stays a plain text field. Escape closes the list, then clears the draft. The aria-label names the input, not the container.

Install

npx shadcn@latest add @vibra/tag-input

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
valuestring[]—The tags, in the order they are shown.
onValueChange(value: string[]) => void—Called with the whole new list; a paste reports once, not once per tag.
placeholderstring"Add a tag"Shown while there are no tags, and used as the field's name when aria-label is left out.
separatorsstring[]comma and semicolonCharacters that end a tag as they are typed, and split a pasted list.
validate(tag: string) => boolean—Returns false to turn a tag down; the text stays in the field.
maxTagsnumber—The cap; further tags are refused.
suggestionsstring[]—Offered under the field, filtered by what is being typed.
size"sm" | "default""default"sm drops the field to min-h-7 for dense forms.
disabledbooleanfalseDims the field and stops every control.
allowDuplicatesbooleanfalseLets the same tag appear more than once.
aria-labelstringthe placeholderNames the input, e.g. Billing countries.

Dependencies

Source

components/ui/tag-input.tsx
"use client"

import * as React from "react"
import { XIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { InputGroup, InputGroupInput } from "@/components/ui/input-group"

export type TagInputProps = Omit<React.ComponentProps<"div">, "onChange"> & {
  value: string[]
  onValueChange: (value: string[]) => void
  placeholder?: string
  /** Characters that end a tag as they are typed, and split a pasted list. */
  separators?: string[]
  /** Returns false to turn a tag down; the text stays in the field to be fixed. */
  validate?: (tag: string) => boolean
  maxTags?: number
  /** Offered under the field, filtered by what is being typed. */
  suggestions?: string[]
  size?: "sm" | "default"
  disabled?: boolean
  allowDuplicates?: boolean
}

/** A field that turns what you type into chips — tags, countries, currencies, anything list-shaped. */
function TagInput({
  className,
  value,
  onValueChange,
  placeholder = "Add a tag",
  separators = [",", ";"],
  validate,
  maxTags,
  suggestions,
  size = "default",
  disabled = false,
  allowDuplicates = false,
  "aria-label": ariaLabel,
  onBlur,
  ...props
}: TagInputProps) {
  const listId = React.useId()
  const [draft, setDraft] = React.useState("")
  // Escape or a click away closes the list until the next keystroke.
  const [dismissed, setDismissed] = React.useState(false)
  // The virtual cursor: focus never leaves the field, so the highlighted option
  // is carried by aria-activedescendant instead.
  const [activeIndex, setActiveIndex] = React.useState(-1)

  // Rejected parts go back into the field the way they arrived, so they can be
  // fixed and committed again.
  const joiner = separators[0] ?? ","

  const query = draft.trim().toLowerCase()
  const matches =
    query.length === 0
      ? []
      : (suggestions ?? [])
          .filter((suggestion) => suggestion.toLowerCase().includes(query))
          .filter((suggestion) => allowDuplicates || !value.includes(suggestion))
          .slice(0, 8)
  const listOpen = !dismissed && !disabled && matches.length > 0
  // Clamped rather than reset in an effect: the list is recomputed every render,
  // so a stale index would otherwise point past the end for one paint.
  const active = listOpen && activeIndex < matches.length ? activeIndex : -1

  /**
   * Appends every candidate that passes, in one change, and hands back the ones
   * it turned down. Empty candidates are what a separator leaves behind, not
   * rejected tags, so they are dropped silently.
   */
  function add(candidates: string[]): string[] {
    const next = [...value]
    const rejected: string[] = []
    for (const candidate of candidates) {
      const tag = candidate.trim()
      if (!tag) continue
      if (
        (maxTags !== undefined && next.length >= maxTags) ||
        (!allowDuplicates && next.includes(tag)) ||
        (validate && !validate(tag))
      ) {
        rejected.push(tag)
        continue
      }
      next.push(tag)
    }
    if (next.length !== value.length) onValueChange(next)
    return rejected
  }

  function removeAt(index: number) {
    onValueChange(value.filter((_, position) => position !== index))
  }

  function split(text: string): string[] {
    // Newlines always split too, so a column pasted out of a spreadsheet lands
    // as one tag per row without configuring anything.
    return text.split(new RegExp(`[\\n\\r${separators.map(escapeForClass).join("")}]`))
  }

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const text = event.target.value
    setDismissed(false)
    setActiveIndex(-1)
    if (!separators.some((separator) => text.includes(separator))) {
      setDraft(text)
      return
    }
    const parts = split(text)
    // Whatever follows the last separator is still being typed.
    const remainder = parts.pop() ?? ""
    const rejected = add(parts)
    setDraft((remainder ? [...rejected, remainder] : rejected).join(joiner))
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
    if (event.key === "Enter") {
      event.preventDefault()
      // Enter takes the highlighted suggestion when there is one, and whatever
      // is typed otherwise.
      const picked = active >= 0 ? matches[active] : draft
      setDraft(add([picked]).join(joiner))
      setActiveIndex(-1)
      return
    }
    // Backspace on an empty field takes the last chip back off.
    if (event.key === "Backspace" && draft === "" && value.length > 0) {
      event.preventDefault()
      removeAt(value.length - 1)
      return
    }
    if (event.key === "Escape") {
      // Only announce the Escape as handled when it actually did something, so
      // a dialog around the field still closes on the next press.
      if (listOpen) {
        event.stopPropagation()
        setDismissed(true)
        setActiveIndex(-1)
      } else if (draft !== "") {
        event.stopPropagation()
        setDraft("")
      }
      return
    }
    if ((event.key === "ArrowDown" || event.key === "ArrowUp") && listOpen) {
      event.preventDefault()
      const step = event.key === "ArrowDown" ? 1 : -1
      const next = active + step
      setActiveIndex(next < 0 ? matches.length - 1 : next >= matches.length ? 0 : next)
    }
  }

  function handlePaste(event: React.ClipboardEvent<HTMLInputElement>) {
    const text = event.clipboardData.getData("text")
    if (!text) return
    event.preventDefault()
    setDraft(add(split(text)).join(joiner))
  }

  return (
    <div
      data-slot="tag-input"
      data-size={size}
      data-disabled={disabled || undefined}
      className={cn("relative w-full", className)}
      onBlur={(event) => {
        // Only focus leaving the field entirely puts the list away; moving
        // between the input and a suggestion does not.
        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDismissed(true)
        onBlur?.(event)
      }}
      {...props}
    >
      {/* COUPLED TO registry/vibra/ui/input-group.tsx: InputGroup is one fixed
          h-8 row, and chips wrap — h-auto/min-h and flex-wrap replace that. */}
      <InputGroup
        className={cn(
          "h-auto flex-wrap items-center gap-1 px-1.5 py-1",
          size === "sm" ? "min-h-7" : "min-h-8",
          disabled && "pointer-events-none opacity-50"
        )}
      >
        {value.map((tag, index) => (
          <Badge
            key={`${tag}-${index}`}
            data-slot="tag-input-tag"
            variant="secondary"
            className="max-w-40 gap-0.5 rounded-sm pe-0.5 font-normal"
          >
            <span className="truncate">{tag}</span>
            <button
              type="button"
              aria-label={`Remove ${tag}`}
              disabled={disabled}
              onClick={() => removeAt(index)}
              className="inline-flex size-3.5 shrink-0 items-center justify-center rounded-[3px] text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground focus-ring"
            >
              <XIcon className="size-3!" />
            </button>
          </Badge>
        ))}

        <InputGroupInput
          data-slot="tag-input-field"
          // A combobox only when there is a list to control; without suggestions
          // this is a plain text field and says so.
          role={suggestions ? "combobox" : undefined}
          aria-autocomplete={suggestions ? "list" : undefined}
          aria-expanded={suggestions ? listOpen : undefined}
          aria-controls={listOpen ? listId : undefined}
          aria-activedescendant={active >= 0 ? `${listId}-${active}` : undefined}
          // The label names the field, not the container the chips sit in.
          aria-label={ariaLabel ?? placeholder}
          autoComplete="off"
          placeholder={value.length === 0 ? placeholder : undefined}
          value={draft}
          disabled={disabled}
          onChange={handleChange}
          onKeyDown={handleKeyDown}
          onPaste={handlePaste}
          className={cn("min-w-24 flex-1 px-0.5", size === "sm" ? "h-5" : "h-6")}
        />
      </InputGroup>

      {listOpen ? (
        <div
          id={listId}
          role="listbox"
          aria-label="Suggestions"
          data-slot="tag-input-suggestions"
          className="absolute inset-x-0 top-full z-50 mt-1 flex max-h-56 flex-col overflow-y-auto elev-1 p-1 text-popover-foreground"
        >
          {matches.map((suggestion, index) => (
            <div
              key={suggestion}
              id={`${listId}-${index}`}
              role="option"
              aria-selected={index === active}
              data-active={index === active || undefined}
              // Keeps focus — and so the caret — in the field, so the click
              // lands instead of blurring the list out of existence.
              onMouseDown={(event) => event.preventDefault()}
              onClick={() => {
                setDraft(add([suggestion]).join(joiner))
                setActiveIndex(-1)
              }}
              className="flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-muted data-active:bg-muted"
            >
              <span className="truncate">{suggestion}</span>
            </div>
          ))}
        </div>
      ) : null}
    </div>
  )
}

/** Escapes a separator for use inside a character class. */
function escapeForClass(character: string): string {
  return character.replace(/[\\\]^-]/g, "\\$&")
}

export { TagInput }