Combobox
betaA text field that filters a list as you type, with groups, a check on the picked item, and chips for picking several.
Preview
size
label
groups
multiple
states
Pick a plan to go on.
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/combobox
Use it for
- Picking one item from a long list, where typing is faster than scrolling: a country, a time zone, a teammate.
- One highlight glides from row to row as the pointer moves, and jumps when the arrow keys move it. Reduced motion makes it jump.
- Picking several items from a long list. Set multiple, and each pick shows as a chip in the field.
- Pass items as strings, as { value, label, disabled } objects, or as groups shaped { label, items }.
- The value is the item's string value: a string, or a string array when multiple is set. It works uncontrolled with defaultValue, even from a server component.
- Give the text field a name: aria-label, aria-labelledby, or an id that a label's htmlFor points at.
- Set name to send the value with a form. Multiple sends one entry per pick.
- The list opens at the end of the page body. In a box with its own theme, like a dark preview on a light page, pass that box as container so the list keeps its colors. A container that clips its overflow can clip the list.
Avoid it for
- Fewer than about 7 items. Use a select or a radio group, so people see every choice at once.
- Free text, like a search box with suggestions. A combobox only accepts items from its list.
- Running commands or jumping to pages. Use a menu or links instead.
- Object values. Map each record to { value: id, label: name }, then look the record up by its id.
Props
- items
The choices. A string is both value and label. Groups show their label above their items.
(string | { value: string; label: string; disabled?: boolean })[] | { label: string; items: ... }[]
- multiple
Pick any number of items. The value becomes a string array, and picks show as chips.
boolean · default false
- defaultValue
The picked value at first render, when you do not control it. A string array when multiple is set.
string | null | string[]
- value
Controlled value. Use it with onValueChange.
string | null | string[]
- onValueChange
Called with the new value on each pick. Deleting the text sets a single value to null.
(value: string | null | string[], details) => void
- aria-label
Names the text field. TypeScript requires this, aria-labelledby, or id.
string
- id
The text field's id, for a label's htmlFor.
string
- placeholder
Hint text in the empty field. Hidden once a chip is in the field.
string
- emptyText
Shown in the list when nothing matches. Screen readers announce it.
ReactNode · default "No results found."
- size
Field height, padding, and text size. The same heights as Input. Density scales them.
"sm" | "md" | "lg" · default "md"
- disabled
Blocks typing and picking, and removes the field from the tab order.
boolean · default false
- readOnly
Shows the value and the list but blocks changes.
boolean · default false
- required
The form will not submit until something is picked.
boolean · default false
- name
Form field name. The picked value is sent with the form.
string
- aria-invalid
Shows the danger border. Set it when validation fails, and link the message with aria-describedby.
boolean
- filter
Decides which items match the text. The default matches labels, ignoring case and accents. Pass null when a server filters.
((item, query: string) => boolean) | null
- inputValue
Controlled text. Use it with onInputValueChange, for example to search a server.
string
- onInputValueChange
Called as the text changes, with the new text.
(inputValue: string) => void
- open
Controlled open state. Use it with onOpenChange.
boolean
- onOpenChange
Called when the list opens or closes.
(open: boolean) => void
- container
Where the list opens. By default, at the end of the page body, so no parent can clip it. Pass an element, or a ref itself (not ref.current), to open it inside, for example to keep a parent's theme. While it is null, the list waits and does not open. Only client code has elements and refs.
HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null
- effect
How the list opens and closes. rise fades in and moves out from the field. grow fades in and grows from the field, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none.
"rise" | "grow" · default "rise"
- className
Classes for the field. Later classes win, so className='max-w-60' sets the width.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| items | (string | { value: string; label: string; disabled?: boolean })[] | { label: string; items: ... }[] | - | The choices. A string is both value and label. Groups show their label above their items. |
| multiple | boolean | false | Pick any number of items. The value becomes a string array, and picks show as chips. |
| defaultValue | string | null | string[] | - | The picked value at first render, when you do not control it. A string array when multiple is set. |
| value | string | null | string[] | - | Controlled value. Use it with onValueChange. |
| onValueChange | (value: string | null | string[], details) => void | - | Called with the new value on each pick. Deleting the text sets a single value to null. |
| aria-label | string | - | Names the text field. TypeScript requires this, aria-labelledby, or id. |
| id | string | - | The text field's id, for a label's htmlFor. |
| placeholder | string | - | Hint text in the empty field. Hidden once a chip is in the field. |
| emptyText | ReactNode | "No results found." | Shown in the list when nothing matches. Screen readers announce it. |
| size | "sm" | "md" | "lg" | "md" | Field height, padding, and text size. The same heights as Input. Density scales them. |
| disabled | boolean | false | Blocks typing and picking, and removes the field from the tab order. |
| readOnly | boolean | false | Shows the value and the list but blocks changes. |
| required | boolean | false | The form will not submit until something is picked. |
| name | string | - | Form field name. The picked value is sent with the form. |
| aria-invalid | boolean | - | Shows the danger border. Set it when validation fails, and link the message with aria-describedby. |
| filter | ((item, query: string) => boolean) | null | - | Decides which items match the text. The default matches labels, ignoring case and accents. Pass null when a server filters. |
| inputValue | string | - | Controlled text. Use it with onInputValueChange, for example to search a server. |
| onInputValueChange | (inputValue: string) => void | - | Called as the text changes, with the new text. |
| open | boolean | - | Controlled open state. Use it with onOpenChange. |
| onOpenChange | (open: boolean) => void | - | Called when the list opens or closes. |
| container | HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null | - | Where the list opens. By default, at the end of the page body, so no parent can clip it. Pass an element, or a ref itself (not ref.current), to open it inside, for example to keep a parent's theme. While it is null, the list waits and does not open. Only client code has elements and refs. |
| effect | "rise" | "grow" | "rise" | How the list opens and closes. rise fades in and moves out from the field. grow fades in and grows from the field, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none. |
| className | string | - | Classes for the field. Later classes win, so className='max-w-60' sets the width. |
Accessibility
- The text field has role="combobox". The list is a listbox, and each item is an option with aria-selected.
- Focus stays in the text field. aria-activedescendant tells screen readers which item is highlighted.
- Down Arrow opens the list on the first item. Up and Down Arrow move, Enter picks, and Escape closes. Typing filters.
- TypeScript requires aria-label, aria-labelledby, or an id. A label's htmlFor can name the field.
- The check mark is hidden from screen readers. aria-selected says which items are picked.
- The empty text is a polite live region, so screen readers hear when nothing matches.
- Group labels name their groups through aria-labelledby.
- With multiple, Left Arrow at the start of the text moves to the chips. Backspace or Delete removes the focused chip, and Backspace in empty text removes the last one.
- The open button is out of the tab order, so Tab moves on to the next control. Clicking anywhere in the field opens the list.
- The field shows the --ring outline while the text field inside has focus. The theme tests hold --ring to 3:1.
- Highlighted items use the accent pair, and group labels and empty text use muted text on the popover. The theme tests hold both to 4.5:1.
- The list fades in and slides by --motion-distance. It closes with a quicker fade and a slight shrink toward the field. Reduced motion keeps the fades and drops the slide and the shrink.
- The open button's label, 'Show options', and the chips' 'Remove …' labels are English. Edit the file to translate them.
Examples
Pick one item
<Combobox
aria-label="Time zone"
placeholder="Find a time zone"
items={["Europe/Lisbon", "Europe/Oslo", "America/New_York", "Asia/Tokyo"]}
/>With a label, a value, and a form name (needs @txbrendan/label)
import { Label } from "@/components/ui/label";
<div className="grid max-w-xs gap-1.5">
<Label htmlFor="country">Country</Label>
<Combobox
id="country"
name="country"
defaultValue="pt"
items={[
{ value: "no", label: "Norway" },
{ value: "pt", label: "Portugal" },
{ value: "se", label: "Sweden" },
]}
/>
</div>Groups
<Combobox
aria-label="Ingredient"
items={[
{ label: "Fruit", items: ["Apple", "Banana", "Pear"] },
{ label: "Herbs", items: ["Basil", "Mint", "Thyme"] },
]}
/>Pick several, shown as chips
<Combobox
aria-label="Labels"
multiple
name="labels"
placeholder="Add labels"
defaultValue={["bug"]}
items={[
{ value: "bug", label: "Bug" },
{ value: "docs", label: "Docs" },
{ value: "design", label: "Design" },
]}
/>Controlled
"use client";
import { useState } from "react";
const people = [
{ value: "u1", label: "Ada Lovelace" },
{ value: "u2", label: "Grace Hopper" },
];
function AssigneePicker() {
const [assignee, setAssignee] = useState<string | null>(null);
return <Combobox aria-label="Assignee" items={people} value={assignee} onValueChange={setAssignee} />;
}Grows from the field
<Combobox
aria-label="Time zone"
placeholder="Find a time zone"
items={["Europe/Lisbon", "Europe/Oslo", "America/New_York", "Asia/Tokyo"]}
effect="grow"
/>