import { ComboBox, ComboBoxItem } from "@/components/ui/combobox" ;
const countries = [
"Australia" ,
"Canada" ,
"France" ,
"Germany" ,
"India" ,
"Japan" ,
"Nepal" ,
"Norway" ,
"United Kingdom" ,
"United States" ,
]. map (( name ) => ({ id: name, name }));
export default function ComboBoxDemo () {
return (
< ComboBox
className = "w-full max-w-64"
label = "Country"
placeholder = "Search countries…"
defaultItems = {countries}
>
{( item ) => < ComboBoxItem >{item.name}</ ComboBoxItem >}
</ ComboBox >
);
}
CLI Manual
$ pnpm dlx shadcn@latest add @desyne/combobox
The CLI installs dependencies and any other components this one uses.
import {
ComboBox,
ComboBoxItem,
ComboBoxItemDescription,
ComboBoxItemLabel,
ComboBoxSection,
} from "@/components/ui/combobox" ;
< ComboBox label = "Country" placeholder = "Search countries…" defaultItems = {countries}>
{( country ) => < ComboBoxItem >{country.name}</ ComboBoxItem >}
</ ComboBox >
A combo box has two values: the selected option (value, a Key or null) and the text in the input (inputValue). Choosing an option sets both; typing only changes the text until an option is committed.
defaultItems filters for you, items doesn't
Pass defaultItems (or static children) and the list filters as the user
types, using a case- and accent-insensitive "contains" match. Pass items
when you filter yourself, for example against a server, and update them from
onInputChange.
Combo Box — long lists (roughly 15+) users will search, or when free-form input is allowed.
Select — a short list where browsing is faster than typing, and only listed values are valid.
Search Field — a query that filters content elsewhere, not a value to pick.
Command Palette — a global launcher for actions and navigation.
Tag Group with a combo box — picking several values; see the tag picker recipe .
< ComboBox > { /* root: label, input, description, error */ }
< ComboBoxSection title = "…" > { /* optional group with a heading */ }
< ComboBoxItem id = "…" > { /* option */ }
< Icon />
< ComboBoxItemLabel /> { /* primary text */ }
< ComboBoxItemDescription /> { /* secondary text */ }
</ ComboBoxItem >
</ ComboBoxSection >
</ ComboBox >
Part Renders Notes ComboBox<div> + popoverRoot. Carries data-slot="combobox" and the group/field class. Wraps the label, the input group, description, error message and the list popover. Input group FieldGroupThe visible box: prefix, the <input role="combobox">, and a chevron button that toggles the list. Chevron button <button>Opens the full list. Skipped in the tab order. It's the combo box's React Aria button, so don't put other React Aria buttons in prefix. Popover Popover + ListBoxAt least as wide as the input group. Shows emptyMessage when nothing matches (unless allowsEmptyCollection={false}). ComboBoxItemrole="option"An option. Needs a unique id (or an id on the item object), and textValue when its children aren't a plain string. Shows a check when selected. ComboBoxItemLabel<span slot="label">Primary text of a rich item. ComboBoxItemDescription<span slot="description">Secondary text of a rich item. ComboBoxSectionrole="group"Groups options under an optional title. Hidden <input> <input type="hidden">Rendered when name is set. Submits the selected key (or the text, see form submission ).
The input shows the selected option's textValue, so rich items still display as plain text once chosen.
outline (default), filled for dense or tinted surfaces, and underlined for minimal forms. The same variants apply to every field component.
import { ComboBox, ComboBoxItem } from "@/components/ui/combobox" ;
const languages = [ "TypeScript" , "Python" , "Go" , "Rust" , "Kotlin" , "Swift" ]. map (
( name ) => ({ id: name. toLowerCase (), name }),
);
export default function ComboBoxVariants () {
return (
< div className = "flex w-full max-w-64 flex-col gap-5" >
{([ "outline" , "filled" , "underlined" ] as const ). map (( variant ) => (
< ComboBox
key = {variant}
variant = {variant}
label = {variant}
defaultItems = {languages}
defaultValue = "typescript"
className = "capitalize"
>
{( item ) => < ComboBoxItem >{item.name}</ ComboBoxItem >}
</ ComboBox >
))}
</ div >
);
}
sm (28px), md (32px, default) and lg (40px), matching the buttons and other fields.
import { ComboBox, ComboBoxItem } from "@/components/ui/combobox" ;
const sizes = [ "sm" , "md" , "lg" ] as const ;
export default function ComboBoxSizes () {
return (
< div className = "flex w-full max-w-64 flex-col gap-4" >
{sizes. map (( size ) => (
< ComboBox
key = {size}
size = {size}
aria-label = { `Size ${ size }` }
placeholder = { `Size ${ size }` }
>
< ComboBoxItem id = "apple" >Apple</ ComboBoxItem >
< ComboBoxItem id = "banana" >Banana</ ComboBoxItem >
< ComboBoxItem id = "cherry" >Cherry</ ComboBoxItem >
</ ComboBox >
))}
</ div >
);
}
Compose icons or avatars with ComboBoxItemLabel and ComboBoxItemDescription, and set textValue so filtering and the input text use the name. prefix adds an icon to the input.
import { SearchIcon } from "lucide-react" ;
import { Avatar } from "@/components/ui/avatar" ;
import {
ComboBox,
ComboBoxItem,
ComboBoxItemDescription,
ComboBoxItemLabel,
} from "@/components/ui/combobox" ;
const people = [
{ id: 1 , name: "Olivia Martin" , email: "olivia@acme.dev" },
{ id: 2 , name: "Jackson Lee" , email: "jackson@acme.dev" },
{ id: 3 , name: "Isabella Nguyen" , email: "bella@acme.dev" },
{ id: 4 , name: "William Kim" , email: "will@acme.dev" },
{ id: 5 , name: "Sofia Davis" , email: "sofia@acme.dev" },
];
const initials = ( name : string ) =>
name
. split ( " " )
. map (( n ) => n[ 0 ])
. join ( "" );
export default function ComboBoxRichItems () {
return (
< ComboBox
className = "w-full max-w-72"
label = "Assignee"
placeholder = "Search people…"
prefix = {< SearchIcon />}
defaultItems = {people}
>
{( person ) => (
< ComboBoxItem textValue = {person.name}>
< Avatar
size = "sm"
colorful
alt = {person.name}
fallback = { initials (person.name)}
/>
< span className = "flex min-w-0 flex-col gap-0.5" >
< ComboBoxItemLabel >{person.name}</ ComboBoxItemLabel >
< ComboBoxItemDescription >{person.email}</ ComboBoxItemDescription >
</ span >
</ ComboBoxItem >
)}
</ ComboBox >
);
}
Pass sections as defaultItems and render each with ComboBoxSection, giving it items and a render function. Filtering applies to the options inside every section.
import { GlobeIcon } from "lucide-react" ;
import {
ComboBox,
ComboBoxItem,
ComboBoxSection,
} from "@/components/ui/combobox" ;
const zones = [
{
name: "Americas" ,
items: [
{ id: "America/New_York" , name: "New York (UTC−5)" },
{ id: "America/Chicago" , name: "Chicago (UTC−6)" },
{ id: "America/Los_Angeles" , name: "Los Angeles (UTC−8)" },
{ id: "America/Sao_Paulo" , name: "São Paulo (UTC−3)" },
],
},
{
name: "Europe" ,
items: [
{ id: "Europe/London" , name: "London (UTC+0)" },
{ id: "Europe/Berlin" , name: "Berlin (UTC+1)" },
{ id: "Europe/Istanbul" , name: "Istanbul (UTC+3)" },
],
},
{
name: "Asia" ,
items: [
{ id: "Asia/Kolkata" , name: "Kolkata (UTC+5:30)" },
{ id: "Asia/Kathmandu" , name: "Kathmandu (UTC+5:45)" },
{ id: "Asia/Tokyo" , name: "Tokyo (UTC+9)" },
],
},
];
export default function ComboBoxSections () {
return (
< ComboBox
className = "w-full max-w-72"
label = "Timezone"
placeholder = "Search timezones…"
prefix = {< GlobeIcon />}
defaultItems = {zones}
>
{( section ) => (
< ComboBoxSection
id = {section.name}
title = {section.name}
items = {section.items}
>
{( zone ) => < ComboBoxItem >{zone.name}</ ComboBoxItem >}
</ ComboBoxSection >
)}
</ ComboBox >
);
}
When nothing matches, the list stays open and shows emptyMessage (default "No results"), so users know the search worked. Type something that isn't in the list below to see it. Pass allowsEmptyCollection={false} to close the list instead.
import { ComboBox, ComboBoxItem } from "@/components/ui/combobox" ;
const frameworks = [
"Next.js" ,
"React Router" ,
"TanStack Start" ,
"Astro" ,
"Vite" ,
]. map (( name ) => ({ id: name, name }));
export default function ComboBoxEmptyState () {
return (
< ComboBox
className = "w-full max-w-64"
label = "Framework"
placeholder = "Try typing “svelte”"
defaultItems = {frameworks}
emptyMessage = "No frameworks match your search."
>
{( item ) => < ComboBoxItem >{item.name}</ ComboBoxItem >}
</ ComboBox >
);
}
menuTrigger controls when the list opens: input (default) when the user types, focus as soon as the input is focused, which suits short lists of suggestions, and manual only from the chevron button or the arrow keys.
Opens on button or arrow keys only
import { ComboBox, ComboBoxItem } from "@/components/ui/combobox" ;
const recent = [
"Q3 planning" ,
"Design review" ,
"Hiring pipeline" ,
"Onboarding revamp" ,
]. map (( name ) => ({ id: name, name }));
export default function ComboBoxMenuTrigger () {
return (
< div className = "flex w-full max-w-64 flex-col gap-5" >
< ComboBox
label = "Opens on focus"
placeholder = "Pick a project"
menuTrigger = "focus"
defaultItems = {recent}
>
{( item ) => < ComboBoxItem >{item.name}</ ComboBoxItem >}
</ ComboBox >
< ComboBox
label = "Opens on button or arrow keys only"
placeholder = "Pick a project"
menuTrigger = "manual"
defaultItems = {recent}
>
{( item ) => < ComboBoxItem >{item.name}</ ComboBoxItem >}
</ ComboBox >
</ div >
);
}
allowsCustomValue accepts text that isn't in the list. The value stays null for custom text, so read inputValue (or the submitted form value) instead.
Label Type anything to use a new label. Value: —
import { useState } from "react" ;
import { ComboBox, ComboBoxItem } from "@/components/ui/combobox" ;
export default function ComboBoxCustomValue () {
const [ input , setInput ] = useState ( "" );
return (
< div className = "flex w-full max-w-64 flex-col gap-3" >
< ComboBox
label = "Label"
placeholder = "Pick or type a label"
allowsCustomValue
inputValue = {input}
onInputChange = {setInput}
description = "Type anything to use a new label."
>
< ComboBoxItem id = "bug" >bug</ ComboBoxItem >
< ComboBoxItem id = "feature" >feature</ ComboBoxItem >
< ComboBoxItem id = "docs" >docs</ ComboBoxItem >
< ComboBoxItem id = "security" >security</ ComboBoxItem >
</ ComboBox >
< p className = "text-muted-foreground text-sm" >
Value: < code className = "text-foreground" >{input || "—" }</ code >
</ p >
</ div >
);
}
Control the selection with value / onChange and the text with inputValue / onInputChange. When you control both, update the input text yourself when the selection changes, and reset both to clear the field.
value: EUR · input: “Euro”
Clear
import { useState } from "react" ;
import type { Key } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { ComboBox, ComboBoxItem } from "@/components/ui/combobox" ;
const currencies = [
{ id: "USD" , name: "US Dollar" },
{ id: "EUR" , name: "Euro" },
{ id: "GBP" , name: "British Pound" },
{ id: "JPY" , name: "Japanese Yen" },
{ id: "NPR" , name: "Nepalese Rupee" },
{ id: "INR" , name: "Indian Rupee" },
];
export default function ComboBoxControlled () {
const [ value , setValue ] = useState < Key | null >( "EUR" );
const [ input , setInput ] = useState ( "Euro" );
return (
< div className = "flex w-full max-w-64 flex-col gap-3" >
< ComboBox
label = "Currency"
defaultItems = {currencies}
value = {value}
onChange = {( key ) => {
setValue (key);
setInput (currencies. find (( c ) => c.id === key)?.name ?? "" );
}}
inputValue = {input}
onInputChange = {setInput}
>
{( c ) => < ComboBoxItem >{c.name}</ ComboBoxItem >}
</ ComboBox >
< p className = "text-muted-foreground text-sm" >
value: < code className = "text-foreground" >{ String (value)}</ code > · input:{ " " }
< code className = "text-foreground" >“{input}”</ code >
</ p >
< Button
variant = "outline"
size = "sm"
className = "self-start"
onPress = {() => {
setValue ( null );
setInput ( "" );
}}
>
Clear
</ Button >
</ div >
);
}
React Aria's useAsyncList loads items as the user types and aborts stale requests. Wire its filterText to inputValue and pass the results as items. Swapping the prefix for a Spinner shows progress in place.
import { SearchIcon } from "lucide-react" ;
import { useAsyncList } from "react-aria-components" ;
import {
ComboBox,
ComboBoxItem,
ComboBoxItemDescription,
ComboBoxItemLabel,
} from "@/components/ui/combobox" ;
import { Spinner } from "@/components/ui/spinner" ;
type Repo = { id : string ; name : string ; stars : string };
const repos : Repo [] = [
{ id: "facebook/react" , name: "facebook/react" , stars: "232k" },
{ id: "vercel/next.js" , name: "vercel/next.js" , stars: "131k" },
{ id: "adobe/react-spectrum" , name: "adobe/react-spectrum" , stars: "13k" },
{
id: "tailwindlabs/tailwindcss" ,
name: "tailwindlabs/tailwindcss" ,
stars: "86k" ,
},
{ id: "shadcn-ui/ui" , name: "shadcn-ui/ui" , stars: "96k" },
{ id: "vitejs/vite" , name: "vitejs/vite" , stars: "72k" },
{ id: "microsoft/typescript" , name: "microsoft/TypeScript" , stars: "104k" },
];
// Stand-in for a real API call.
async function searchRepos ( query : string , signal : AbortSignal ) {
await new Promise (( resolve , reject ) => {
const t = setTimeout (resolve, 400 );
signal. addEventListener ( "abort" , () => {
clearTimeout (t);
reject (signal.reason);
});
});
const q = query. toLowerCase ();
return repos. filter (( r ) => r.name. toLowerCase (). includes (q));
}
export default function ComboBoxAsync () {
const list = useAsyncList < Repo >({
async load ({ signal , filterText }) {
return { items: await searchRepos (filterText ?? "" , signal) };
},
});
return (
< ComboBox
className = "w-full max-w-72"
label = "Repository"
placeholder = "Search GitHub…"
items = {list.items}
inputValue = {list.filterText}
onInputChange = {list.setFilterText}
prefix = {list.isLoading ? < Spinner size = "xs" /> : < SearchIcon />}
emptyMessage = {list.isLoading ? "Searching…" : "No repositories found." }
>
{( repo ) => (
< ComboBoxItem textValue = {repo.name}>
< span className = "flex min-w-0 flex-col gap-0.5" >
< ComboBoxItemLabel >{repo.name}</ ComboBoxItemLabel >
< ComboBoxItemDescription >★ {repo.stars}</ ComboBoxItemDescription >
</ span >
</ ComboBoxItem >
)}
</ ComboBox >
);
}
defaultFilter replaces the built-in "contains" match. React Aria's useFilter gives you locale-aware startsWith, contains and endsWith.
Command Matches from the start: “git p” finds pull and push.
import { useFilter } from "react-aria-components" ;
import { ComboBox, ComboBoxItem } from "@/components/ui/combobox" ;
const commands = [
"git add" ,
"git branch" ,
"git checkout" ,
"git commit" ,
"git merge" ,
"git pull" ,
"git push" ,
"git rebase" ,
"git stash" ,
]. map (( name ) => ({ id: name, name }));
export default function ComboBoxCustomFilter () {
const { startsWith } = useFilter ({ sensitivity: "base" });
return (
< ComboBox
className = "w-full max-w-64"
label = "Command"
placeholder = "git …"
defaultItems = {commands}
defaultFilter = {startsWith}
description = "Matches from the start: “git p” finds pull and push."
>
{( c ) => < ComboBoxItem className = "font-mono" >{c.name}</ ComboBoxItem >}
</ ComboBox >
);
}
isDisabled disables the input and the button; disabledKeys keeps specific options visible but unselectable. Use description to explain why.
Plan Enterprise requires a sales call.
import { ComboBox, ComboBoxItem } from "@/components/ui/combobox" ;
const plans = [
{ id: "hobby" , name: "Hobby" },
{ id: "pro" , name: "Pro" },
{ id: "team" , name: "Team" },
{ id: "enterprise" , name: "Enterprise (contact sales)" },
];
export default function ComboBoxDisabled () {
return (
< div className = "flex w-full max-w-64 flex-col gap-5" >
< ComboBox label = "Region" isDisabled defaultValue = "eu" >
< ComboBoxItem id = "us" >United States</ ComboBoxItem >
< ComboBoxItem id = "eu" >Europe</ ComboBoxItem >
</ ComboBox >
< ComboBox
label = "Plan"
defaultItems = {plans}
disabledKeys = {[ "enterprise" ]}
description = "Enterprise requires a sales call."
>
{( plan ) => < ComboBoxItem >{plan.name}</ ComboBoxItem >}
</ ComboBox >
</ div >
);
}
isRequired blocks form submission until an option is selected and shows the browser's message. Unless allowsCustomValue is set, the input reverts to the selected option's text on blur, so the value is always a listed option.
import { Form } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { ComboBox, ComboBoxItem } from "@/components/ui/combobox" ;
const cities = [
"Amsterdam" ,
"Berlin" ,
"Kathmandu" ,
"Lisbon" ,
"Singapore" ,
"Tokyo" ,
]. map (( name ) => ({ id: name, name }));
export default function ComboBoxValidation () {
return (
< Form
className = "flex w-full max-w-64 flex-col gap-4"
onSubmit = {( e ) => e. preventDefault ()}
>
< ComboBox
label = "Office"
name = "office"
placeholder = "Choose an office"
isRequired
defaultItems = {cities}
>
{( c ) => < ComboBoxItem >{c.name}</ ComboBoxItem >}
</ ComboBox >
< Button type = "submit" className = "self-start" >
Continue
</ Button >
</ Form >
);
}
validate receives { value, inputValue } and returns an error string or null. With validationBehavior="aria" on the Form, errors show live as the selection changes.
import { Form } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { ComboBox, ComboBoxItem } from "@/components/ui/combobox" ;
const instances = [
{ id: "t3.micro" , name: "t3.micro · 1 GB" },
{ id: "t3.medium" , name: "t3.medium · 4 GB" },
{ id: "m7g.large" , name: "m7g.large · 8 GB" },
{ id: "m7g.2xlarge" , name: "m7g.2xlarge · 32 GB" },
];
export default function ComboBoxCustomValidation () {
return (
< Form
className = "flex w-full max-w-72 flex-col gap-4"
validationBehavior = "aria"
onSubmit = {( e ) => e. preventDefault ()}
>
< ComboBox
label = "Instance type"
defaultItems = {instances}
defaultValue = "t3.micro"
description = "The database needs at least 4 GB of memory."
validate = {({ value }) =>
value === "t3.micro" ? "t3.micro is too small for Postgres." : null
}
>
{( i ) => < ComboBoxItem >{i.name}</ ComboBoxItem >}
</ ComboBox >
< Button type = "submit" className = "self-start" >
Deploy
</ Button >
</ Form >
);
}
With a name, the selected key is submitted through a hidden input. Set formValue="text" to submit the input text instead; with allowsCustomValue, the text is always submitted.
import { useState } from "react" ;
import { Form } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { ComboBox, ComboBoxItem } from "@/components/ui/combobox" ;
const countries = [
{ id: "DE" , name: "Germany" },
{ id: "IN" , name: "India" },
{ id: "JP" , name: "Japan" },
{ id: "NP" , name: "Nepal" },
{ id: "US" , name: "United States" },
];
export default function ComboBoxForm () {
const [ data , setData ] = useState < Record < string , FormDataEntryValue > | null >(
null ,
);
return (
< Form
className = "flex w-full max-w-64 flex-col gap-4"
onSubmit = {( e ) => {
e. preventDefault ();
setData (Object. fromEntries ( new FormData (e.currentTarget)));
}}
>
< ComboBox
label = "Country (submits key)"
name = "country"
defaultItems = {countries}
defaultValue = "NP"
>
{( c ) => < ComboBoxItem >{c.name}</ ComboBoxItem >}
</ ComboBox >
< ComboBox
label = "Ship from (submits text)"
name = "origin"
formValue = "text"
defaultItems = {countries}
defaultValue = "DE"
>
{( c ) => < ComboBoxItem >{c.name}</ ComboBoxItem >}
</ ComboBox >
< Button type = "submit" className = "self-start" >
Save
</ Button >
{data && (
< code className = "rounded-md bg-muted px-2 py-1 text-xs" >
{ JSON . stringify (data)}
</ code >
)}
</ Form >
);
}
Multiple selection built from a combo box and a TagGroup . The combo box stays empty (value={null}), each choice is added as a removable tag, and chosen options drop out of the list.
import { useState } from "react" ;
import type { Key } from "react-aria-components" ;
import { ComboBox, ComboBoxItem } from "@/components/ui/combobox" ;
import { Tag, TagGroup } from "@/components/ui/tag-group" ;
const skills = [
"Accessibility" ,
"Animation" ,
"Design systems" ,
"Figma" ,
"GraphQL" ,
"Next.js" ,
"Node.js" ,
"Performance" ,
"React" ,
"Testing" ,
"TypeScript" ,
]. map (( name ) => ({ id: name, name }));
export default function ComboBoxRecipeTagPicker () {
const [ selected , setSelected ] = useState < string []>([ "React" , "TypeScript" ]);
const [ input , setInput ] = useState ( "" );
const available = skills. filter (( s ) => ! selected. includes (s.id));
const add = ( key : Key | null ) => {
if (key == null ) return ;
setSelected (( s ) => [ ... s, String (key)]);
setInput ( "" );
};
return (
< div className = "flex w-full max-w-sm flex-col gap-3" >
< ComboBox
label = "Skills"
placeholder = "Add a skill…"
items = {available. filter (( s ) =>
s.name. toLowerCase (). includes (input. toLowerCase ()),
)}
value = { null }
onChange = {add}
inputValue = {input}
onInputChange = {setInput}
emptyMessage = "No more matching skills."
>
{( s ) => < ComboBoxItem >{s.name}</ ComboBoxItem >}
</ ComboBox >
< TagGroup
aria-label = "Selected skills"
items = {selected. map (( id ) => ({ id }))}
onRemove = {( keys ) => setSelected (( s ) => s. filter (( id ) => ! keys. has (id)))}
renderEmptyState = {() => (
< span className = "text-muted-foreground text-sm" >No skills yet.</ span >
)}
>
{( item ) => < Tag >{item.id}</ Tag >}
</ TagGroup >
</ div >
);
}
A country combo box drives the options, label and reset of the region combo box, alongside text fields with autoComplete hints.
import { useState } from "react" ;
import { Form, type Key } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { ComboBox, ComboBoxItem } from "@/components/ui/combobox" ;
import { TextField } from "@/components/ui/text-field" ;
const countries = [
{
id: "US" ,
name: "United States" ,
regions: [ "California" , "New York" , "Texas" , "Washington" ],
regionLabel: "State" ,
},
{
id: "CA" ,
name: "Canada" ,
regions: [ "Alberta" , "British Columbia" , "Ontario" , "Quebec" ],
regionLabel: "Province" ,
},
{
id: "NP" ,
name: "Nepal" ,
regions: [ "Bagmati" , "Gandaki" , "Koshi" , "Lumbini" ],
regionLabel: "Province" ,
},
];
export default function ComboBoxRecipeShipping () {
const [ country , setCountry ] = useState < Key | null >( "NP" );
const [ region , setRegion ] = useState < Key | null >( null );
const current = countries. find (( c ) => c.id === country);
return (
< Form
className = "grid w-full max-w-lg gap-4 rounded-xl border bg-card p-5 sm:grid-cols-2"
onSubmit = {( e ) => e. preventDefault ()}
>
< h3 className = "font-semibold sm:col-span-2" >Shipping address</ h3 >
< TextField
className = "sm:col-span-2"
label = "Street address"
name = "street"
autoComplete = "street-address"
isRequired
/>
< ComboBox
label = "Country"
name = "country"
isRequired
defaultItems = {countries}
value = {country}
onChange = {( key ) => {
setCountry (key);
setRegion ( null );
}}
>
{( c ) => < ComboBoxItem >{c.name}</ ComboBoxItem >}
</ ComboBox >
< ComboBox
key = { String (country)}
label = {current?.regionLabel ?? "Region" }
name = "region"
isRequired
isDisabled = { ! current}
defaultItems = {(current?.regions ?? []). map (( name ) => ({
id: name,
name,
}))}
value = {region}
onChange = {setRegion}
>
{( r ) => < ComboBoxItem >{r.name}</ ComboBoxItem >}
</ ComboBox >
< TextField
label = "City"
name = "city"
autoComplete = "address-level2"
isRequired
/>
< TextField
label = "Postal code"
name = "postalCode"
autoComplete = "postal-code"
/>
< Button type = "submit" className = "justify-self-start sm:col-span-2" >
Save address
</ Button >
</ Form >
);
}
The input has role="combobox" with aria-expanded, aria-controls and aria-activedescendant, so focus stays in the input while options are highlighted.
Always provide a label: label, aria-label, or aria-labelledby.
Screen readers announce the number of available options as the list changes, and the selected option when it's committed.
description and errorMessage are linked to the input with aria-describedby.
The chevron button is skipped in the tab order because the arrow keys open the list from the input.
Items that aren't plain strings need textValue so they can be filtered, announced and shown in the input.
Key Action Typing Filters the list and opens it (with menuTrigger="input") ↓ / ↑ Opens the list, focusing the first / last option; then moves between options Enter Selects the focused option and closes the list Tab Selects the focused option (if the list is open) and moves focus on Esc Closes the list and reverts the input to the selected option's text ← / → Moves the text cursor, leaving the list
On the ComboBox root (use group-data-*/field: to style children):
Attribute Present when data-openThe list is open data-focusedThe input has focus data-disabledisDisabled is truedata-readonlyisReadOnly is truedata-invalidValidation failed data-requiredisRequired is true
On each ComboBoxItem:
Attribute Present when data-focused / data-hoveredThe option is highlighted / hovered data-selectedThe option is the current value data-disabledThe option is in disabledKeys
data-slotElement comboboxRoot label, description, field-errorField text field-group, field-inputInput box and <input> popover, list-boxOverlay and list list-box-item, list-box-sectionOptions and groups
The popover is at least as wide as the input group (min-w-(--trigger-width)). Pass a className on ComboBox to size the whole field; it also accepts a function of the render state (isOpen, isDisabled, isInvalid, isReadOnly, isRequired). Option styles come from listBoxItemStyles in list-box.tsx.
Also accepts every prop of React Aria's ComboBox . This wrapper is single-selection only.
Accept every prop of React Aria's Text . They set slot="label" and slot="description" so each option's accessible name and description are wired up automatically.
Select — the same list without typing.
List Box — the list on its own, always visible, with multiple selection.
Tag Group — for showing several chosen values.
Search Field — for queries rather than values.