a field whose value is a list of chips, found as you type
npx shadcn add @ag/chip-inputAlready installed? Preview an update
npx shadcn add @ag/chip-input --dry-runYour 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.
the demo above, verbatim
"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
"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>
)
}