← ui

chip-input

ui

a field whose value is a list of chips, found as you type

npx shadcn add @ag/chip-input
Already installed? Preview an update
npx shadcn add @ag/chip-input --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-12
  • Changed: the list stands on the best match as soon as something is typed, so Enter takes it; it stood on nothing, and Enter after typing "tawn" did nothing. Tab with something typed completes to that match too and keeps the focus in the field; with nothing typed Tab leaves, as before.
  • Changed: the popup is a frame that stays whole; the list inside it scrolls and fades its cut edges.
Updated 2026-10-12 · release notes

also called tag input, multi select

shelf controls

uses scrims, fuzzy, input, scroll-area, search

the demo above, verbatim

chip-input.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import { ChipInput, type ChipOption } from "@/registry/base-nova/ui/chip-input"

// #region birds
const BIRDS: ChipOption[] = [
  {
    value: "gull",
    label: "herring gull",
    group: "coast",
    hint: "Larus argentatus",
  },
  {
    value: "tern",
    label: "common tern",
    group: "coast",
    hint: "Sterna hirundo",
  },
  {
    value: "gannet",
    label: "northern gannet",
    group: "coast",
    hint: "Morus bassanus",
  },
  {
    value: "heron",
    label: "grey heron",
    group: "wetland",
    hint: "Ardea cinerea",
  },
  {
    value: "egret",
    label: "little egret",
    group: "wetland",
    hint: "Egretta garzetta",
  },
  {
    value: "kingfisher",
    label: "common kingfisher",
    group: "wetland",
    keywords: ["alcedo"],
  },
  {
    value: "wren",
    label: "wren",
    group: "woodland",
    hint: "Troglodytes troglodytes",
  },
  {
    value: "jay",
    label: "eurasian jay",
    group: "woodland",
    hint: "Garrulus glandarius",
  },
  { value: "owl", label: "tawny owl", group: "woodland", hint: "Strix aluco" },
]
// #endregion

export default function ChipInputDemo() {
  const [picked, setPicked] = React.useState<string[]>(["heron"])
  const [phrases, setPhrases] = React.useState<string[]>(["a bird in flight"])
  const [not, setNot] = React.useState<string[]>([])
  return (
    <div className="flex flex-col gap-8">
      <Sample
        name="set"
        glance
        title="from a set"
        description="focused with nothing typed, every option in its group; typing finds them fuzzily: try 'gry hrn'"
        with="birds"
      >
        <div className="w-full max-w-96">
          <ChipInput
            label="birds"
            placeholder="pick birds"
            options={BIRDS}
            value={picked}
            onChange={setPicked}
          />
        </div>
      </Sample>
      <Sample
        name="free"
        title="free, the set suggests"
        description="Enter or a comma makes what is typed a chip; a chip pressed goes back to the text"
        with="birds"
      >
        <div className="flex w-96 flex-col gap-2">
          <ChipInput
            label="phrases"
            placeholder="add a phrase"
            free
            options={BIRDS}
            value={phrases}
            onChange={setPhrases}
          />
          <ChipInput
            label="not"
            placeholder="add what it is not"
            free
            negative
            value={not}
            onChange={setNot}
          />
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

chip-input.tsx
"use client"

import { Combobox } from "@base-ui/react/combobox"
import { Check, Plus, X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { fuzzyFilter, type Range } from "@/registry/base-nova/lib/fuzzy"
import { WELL } from "@/registry/base-nova/ui/input"
import { ScrollArea } from "@/registry/base-nova/ui/scroll-area"
import { Highlight } from "@/registry/base-nova/ui/search"

// A FIELD OF CHIPS: its value is a list, each item a chip with its ×, the text after
// them a search. Focused with nothing typed it shows EVERY option, in its groups;
// typing finds them fuzzily (lib/fuzzy: letters in order, every word, best first),
// the letters it matched lit. Two ways to fill it, one field:
//   - from a set (`options`): a fixed vocabulary, picked and unpicked in the list;
//   - free (`free`): what is typed is a chip of its own (Enter or a comma), the set
//     only suggests. A chip pressed goes back to the text to be edited.
// Every key is the combobox's (Base UI): arrows walk the list, Enter picks, Backspace
// on empty text takes the last chip back, Escape closes. The well is input's.

export interface ChipOption {
  /** what is kept */
  value: string
  /** what is shown */
  label: string
  /** the section it is listed under; none, the list's top */
  group?: string
  /** a quieter line after the label */
  hint?: string
  /** other words that find it */
  keywords?: readonly string[]
}

export interface ChipInputProps {
  /** the values, in order */
  value: readonly string[]
  onChange: (value: string[]) => void
  /** what it picks from, or suggests when `free` */
  options?: readonly ChipOption[]
  /** what is typed is a value of its own */
  free?: boolean
  /** names the field for a screen reader */
  label: string
  placeholder?: string
  /** chips drawn quieter: what it is not */
  negative?: boolean
  disabled?: boolean
  className?: string
}

/** An option as the list draws it: its match, and whether it is the typed text. */
interface Row extends ChipOption {
  ranges: Range[]
  typed?: boolean
}

interface Section {
  group: string
  items: Row[]
}

const CHIP =
  "flex h-6 max-w-full items-center rounded-md pl-2 text-[12px] outline-none data-highlighted:ring-1 data-highlighted:ring-foreground/40"
const ITEM =
  "flex cursor-default items-baseline gap-2 rounded-md px-2 py-1.5 text-[12px] outline-none data-highlighted:bg-foreground/8"

const fold = (s: string) => s.trim().toLocaleLowerCase()

export function ChipInput({
  value,
  onChange,
  options = [],
  free = false,
  label,
  placeholder,
  negative,
  disabled,
  className,
}: ChipInputProps) {
  const [query, setQuery] = React.useState("")
  const anchor = React.useRef<HTMLDivElement>(null)
  const known = React.useMemo(
    () => new Map(options.map((o) => [o.value, o])),
    [options],
  )
  // A value is its option, or (free) a text of its own.
  const picked = value.map(
    (v): ChipOption => known.get(v) ?? { value: v, label: v },
  )

  const sections = React.useMemo((): Section[] => {
    const found = fuzzyFilter(options, query, (o) => [
      o.label,
      o.value,
      o.hint ?? "",
      ...(o.keywords ?? []),
    ])
    // Grouped in the order groups first appear: the options' own order with nothing
    // typed, best match first while typing.
    const out: Section[] = []
    for (const f of found) {
      const group = f.item.group ?? ""
      const at =
        out.find((s) => s.group === group) ??
        out[out.push({ group, items: [] }) - 1]
      at?.items.push({ ...f.item, ranges: f.ranges[0] ?? [] })
    }
    const text = query.trim()
    if (
      free &&
      text &&
      !options.some((o) => fold(o.label) === fold(text)) &&
      !value.includes(text)
    )
      out.unshift({
        group: "",
        items: [{ value: text, label: text, ranges: [], typed: true }],
      })
    return out
  }, [options, query, free, value])

  const set = (next: readonly ChipOption[]) => {
    onChange(next.map((o) => o.value))
    setQuery("")
  }
  // The option the list stands on: the best match as soon as something is typed
  // (`autoHighlight`), so Enter takes it and Tab completes to it.
  const highlighted = React.useRef<ChipOption | undefined>(undefined)
  /** Tab with something typed takes the option the list stands on, as Enter does, and
   *  keeps the focus here; with nothing typed Tab leaves, as it always does. */
  const completeTyped = () => {
    const h = highlighted.current
    if (!query.trim() || !h) return false
    if (picked.some((p) => p.value === h.value)) setQuery("")
    else set([...picked, h])
    return true
  }
  /** What is typed, as a chip of its own (free), once. */
  const commitTyped = () => {
    const text = query.trim()
    if (text && !value.includes(text))
      set([...picked, { value: text, label: text }])
    else setQuery("")
  }

  return (
    <Combobox.Root
      multiple
      items={sections}
      filteredItems={sections}
      value={picked}
      onValueChange={(next: ChipOption[]) => set(next)}
      inputValue={query}
      onInputValueChange={setQuery}
      isItemEqualToValue={(a: ChipOption, b: ChipOption) => a.value === b.value}
      itemToStringLabel={(o: ChipOption) => o.label}
      autoHighlight
      onItemHighlighted={(o: ChipOption | undefined) => {
        highlighted.current = o
      }}
      disabled={disabled}
    >
      <Combobox.Chips
        ref={anchor}
        data-slot="chip-input"
        className={cn(
          "flex min-h-8 flex-wrap items-center gap-1 px-1 py-1",
          WELL,
          className,
        )}
      >
        {picked.map((o) => (
          <Combobox.Chip
            key={o.value}
            data-slot="chip-input-chip"
            className={cn(
              CHIP,
              negative
                ? "bg-foreground/3 text-foreground/70"
                : "bg-foreground/8",
            )}
          >
            {free ? (
              // Pressed, it goes back to the text to be edited.
              <button
                type="button"
                className="min-w-0 cursor-text truncate"
                onClick={() => {
                  onChange(value.filter((v) => v !== o.value))
                  setQuery(o.value)
                }}
              >
                {o.label}
              </button>
            ) : (
              <span className="min-w-0 truncate">{o.label}</span>
            )}
            <Combobox.ChipRemove
              aria-label={`remove ${o.label}`}
              className="grid h-6 w-6 shrink-0 cursor-pointer place-items-center rounded-r-md text-foreground/35 hover:text-foreground [&_svg]:size-3"
            >
              <X />
            </Combobox.ChipRemove>
          </Combobox.Chip>
        ))}
        <Combobox.Input
          aria-label={label}
          placeholder={picked.length ? undefined : placeholder}
          spellCheck={false}
          onKeyDown={(e) => {
            // A comma ends a free chip, as Enter does.
            if (free && e.key === "," && query.trim()) {
              e.preventDefault()
              commitTyped()
            }
            if (
              e.key === "Tab" &&
              !e.shiftKey &&
              !e.altKey &&
              !e.metaKey &&
              !e.ctrlKey &&
              completeTyped()
            )
              e.preventDefault()
          }}
          // Leaving a free field keeps what was typed: nothing typed is lost.
          onBlur={() => free && query.trim() && commitTyped()}
          className="h-6 min-w-24 flex-1 bg-transparent px-1 text-[13px] outline-none placeholder:text-foreground/35"
        />
      </Combobox.Chips>
      <Combobox.Portal>
        <Combobox.Positioner
          anchor={anchor}
          sideOffset={4}
          align="start"
          className="z-50"
        >
          <Combobox.Popup
            data-slot="chip-input-list"
            // The frame stays whole; the list inside it scrolls, its cut edges
            // fading where there is more (scroll-area).
            className="flex max-h-[min(24rem,var(--available-height))] w-(--anchor-width) min-w-64 flex-col rounded-lg bg-popover p-1 text-popover-foreground shadow-lg outline-none ring-1 ring-foreground/10"
          >
            <Combobox.Empty className="px-2 py-1.5 text-[12px] text-foreground/45 empty:hidden">
              {options.length
                ? "nothing matches"
                : free
                  ? "type to add one"
                  : null}
            </Combobox.Empty>
            <ScrollArea
              render={
                <Combobox.List>
                  {(section: Section) => (
                    <Combobox.Group
                      key={section.group}
                      items={section.items}
                      className="py-0.5"
                    >
                      {section.group && (
                        <Combobox.GroupLabel className="px-2 pt-1.5 pb-0.5 font-mono text-[10px] text-foreground/40">
                          {section.group}
                        </Combobox.GroupLabel>
                      )}
                      <Combobox.Collection>
                        {(row: Row) => (
                          <Combobox.Item
                            key={row.value}
                            value={row}
                            className={ITEM}
                          >
                            <span className="grid size-3.5 shrink-0 place-items-center self-center [&_svg]:size-3.5">
                              {row.typed ? (
                                <Plus className="text-foreground/55" />
                              ) : (
                                <Combobox.ItemIndicator>
                                  <Check />
                                </Combobox.ItemIndicator>
                              )}
                            </span>
                            <span className="min-w-0 truncate">
                              {row.typed ? (
                                `add “${row.label}”`
                              ) : (
                                <Highlight
                                  text={row.label}
                                  ranges={row.ranges}
                                />
                              )}
                            </span>
                            {row.hint && (
                              <span className="ml-auto shrink-0 font-mono text-[10px] text-foreground/40">
                                {row.hint}
                              </span>
                            )}
                          </Combobox.Item>
                        )}
                      </Combobox.Collection>
                    </Combobox.Group>
                  )}
                </Combobox.List>
              }
            />
          </Combobox.Popup>
        </Combobox.Positioner>
      </Combobox.Portal>
    </Combobox.Root>
  )
}