Composites

Combobox

A searchable select — cmdk inside a Popover, sized to its trigger — that matches Vietnamese text with or without diacritics. MultiCombobox is the same machinery with badges on the trigger and a popover that stays open between picks.

Basic usage

Options are plain data; the component owns the popover, the search box and the filtering. Selecting a row closes the popover and clears the query, and picking the row that is already selected toggles it back off — which is why onValueChange is typed to receive null.

value: null

Diacritic-insensitive search

Open the list below and type dung — both Dung and Dũng come back. Type duc and you get both people called Đức. Nobody types tone marks into a search box, and cmdk's stock scorer is diacritic-sensitive, so out of the box nguyen would never find Nguyễn.

  • The replacement filter returns 1 or 0 rather than a score. Ranking is deliberately given up: the order you pass options in is the order people see, which for a list of colleagues or projects is usually already meaningful.
  • Matching runs over the row's value plus its keywords, and the component quietly folds label and description into that keyword list — so an opaque id in value costs you nothing.
  • Stripping accents is done with Unicode NFD plus a sweep of the combining range, and then đ/Đ by hand: those are letters in their own right in Unicode, not a d with a mark on it, so decomposition leaves them untouched and duc would miss Đức.
  • The same normaliser is exported as removeVietnameseTones and normalizeVi. Use those for your own filtering rather than writing a second normaliser — two rules produce two different result sets for one query.

Richer options

A row can carry a second line, an icon, a group heading and a disabled flag. icon is typed IconComponent: pass Rocket, not <Rocket />. A Lucide icon is a forwardRef object rather than a plain function, so no runtime check can tell a component from an already-rendered element — the type refuses the ambiguity instead of guessing wrong in silence.

Selecting several

MultiCombobox keeps the popover open while you pick, shows the first maxDisplay choices as badges and collapses the rest into +k. With maxSelected set, unselected rows disable themselves at the cap — the ones you already picked stay live so you can trade one for another — and a counter appears below the list.

0 selected

clearable defaults to true here and false on the single-select — clearing four badges one at a time is tedious in a way that clearing one value is not. Open the list and the counter under it reads 0/4 selected, because this demo passes selectedCountLabel. Leave it out and you get the Vietnamese default Đã chọn 0/4 — see Strings and language below.

The clear button and the chevron

Both live in an absolutely positioned row that is a sibling of the trigger, not a child of it, and the reasoning is worth knowing before you restyle either.

  • A <button> inside a <button> is invalid HTML. Demoting the clear control to a span with tabIndex={-1} would fix the markup by making the control permanently unreachable by keyboard, so it moved out instead.
  • The chevron had to follow. As the trigger's last child, the only way to make room for a clear button beside it is trigger padding — which pushes the chevron left. Two fields side by side in a form row, one with a value and one without, would then have chevrons at different offsets. Outside, it is pinned like every other control's.
  • The row is pointer-events-none with the clear button re-enabling itself, so clicking the chevron falls through to the trigger and opens the popover.
  • The chevron is explicitly text-muted-foreground rather than a dimmed copy of the trigger text. It reports aria-expanded on an operable control, so it is a 1.4.11 target at 3:1; at opacity-50 over placeholder text it measured 2.03:1 light and 2.30:1 dark. Named outright it holds 5.23:1 and 5.70:1 and no longer changes with the selection state.
  • Because the row is a sibling, it does not inherit the trigger's disabled:opacity-50 — it dims itself when disabled is set.

Server-side search

Passing onSearchChange — anything at all, even a no-op — is what flips the component into async mode. The built-in filter is switched off and the list renders options exactly as given, so a server that already narrowed the results is not narrowed a second time on the client. Pair it with loading. The demo below fakes a 450 ms round trip; the code tab shows the real thing. The line it shows while waiting comes from loadingText, which defaults to the Vietnamese Đang tải… — this demo passes an English one.

Strings and language

Unlike DataTable or TablePagination, the comboboxes have no labels prop. Their user-visible text splits in two, and it is worth knowing which half is which before you ship an English UI.

  • Overridable, Vietnamese by default: placeholder (Chọn…), searchPlaceholder (Tìm kiếm…) and emptyText (Không tìm thấy kết quả.). Pass all three in a non-Vietnamese app — every demo on this page does.
  • Just as overridable, but easy to miss: the loading line via loadingText (Đang tải…), the screen-reader-only name on the clear button via clearLabel (Xóa lựa chọn on Combobox, Xóa tất cả lựa chọn on MultiCombobox) and the maxSelected counter (Đã chọn 2/4) via selectedCountLabel, which is a function, not a template string, because it interpolates two numbers. The loading line and the counter are only reachable through loading and maxSelected, but the clear button's name is the urgent one: clearable defaults to true on MultiCombobox, so an English app ships the Vietnamese name to screen readers without switching anything on.

Validation, and living inside a FormField

aria-invalid does more than talk to screen readers: it turns the trigger border --destructive-ink, the same red an invalid Input shows. Earlier versions only reddened the message underneath, which left people reading “something is wrong” with no field that looked wrong. Pick a project below and watch both signals clear together.

Assignment

  • The prop accepts a real boolean as well as the string forms the DOM uses, so aria-invalid={!!errors.projectId} from react-hook-form goes straight in with no cast.
  • A falsy value emits nothing. aria-invalid="false" is legal but noise, and it would match the CSS selector authors expect to mean “invalid”.
  • The red border comes from the Button base classes, because the trigger is a Button. Anything else built on Button that carries a form value inherits the same treatment.
  • Inside a FormField, spread the control object onto the combobox and stop thinking about it: id, aria-invalid, aria-required and aria-describedby are exactly the four props the shared interface accepts.

The empty option: SELECT_EMPTY_VALUE

A Combobox expresses “nothing chosen” as null and gets a clear button for free. A Radix Select cannot: it reserves the empty string for clearing the selection and rejects <SelectItem value="">, so a visible “None” row needs a stand-in value. The package ships one constant and two converters so that every app uses the same stand-in rather than each inventing a magic string that eventually lands in a request body.

model value: '' (empty)

PropTypeDefaultDescription
SELECT_EMPTY_VALUE'__empty__'The shared sentinel for a Select row that means "nothing chosen". One constant for the whole ecosystem, so no app invents its own and lets it slip into a request payload.
toSelectValue(value: string | null | undefined) => stringModel value → Radix value. Turns null, undefined and '' into the sentinel; passes anything else through.
fromSelectValue(value: string) => stringRadix value → model value. Turns the sentinel back into '', passes anything else through.
  • Convert at the boundary, both ways. The sentinel exists only between toSelectValue and fromSelectValue; your state and your payload keep ''.
  • Once the empty row is a real option, it is also what the trigger displays — SelectValue's placeholder never gets a turn, because the value is never unset. If you want a placeholder instead, leave the value undefined and do not render an empty row at all.
  • These three live in a module with no 'use client' directive, deliberately. Every export of a client module reaches a Server Component as a client-reference proxy that reads as undefined without throwing or warning — and plain data constants are exactly what server pages reach for. Keeping them outside the boundary means a server component can compare against SELECT_EMPTY_VALUE and get the string, not a proxy.

Props

Shared by Combobox and MultiCombobox:

PropTypeDefaultDescription
optionsrequiredComboboxOption[]The rows. Order is preserved: the filter only decides whether a row survives, it does not re-rank.
placeholderstring'Chọn…'Trigger text while nothing is chosen. Vietnamese by default — pass an English string.
searchPlaceholderstring'Tìm kiếm…'Placeholder in the search box inside the popover. Vietnamese by default.
emptyTextstring'Không tìm thấy kết quả.'Shown when the filter matches nothing. Vietnamese by default.
loadingTextstring'Đang tải…'Visible line inside the panel while loading is true. Vietnamese by default — pass an English string.
disabledbooleanfalseDisables the trigger and dims the adornment row, which is a sibling and would otherwise stay at full opacity.
onSearchChange(search: string) => voidIts mere presence switches the component into async mode: the built-in filter is turned off and options are rendered as given.
loadingbooleanfalseReplaces the list with a spinner. Only meaningful in async mode.
clearablebooleanfalse (true on MultiCombobox)Adds an X button that resets the value to null / [].
classNamestringMerged onto the trigger button.
contentClassNamestringMerged onto the popover panel — for example to override the width, which otherwise tracks the trigger.
idstringPlaced on the trigger, so a <label htmlFor> points at the right element.
namestringForwarded to the trigger button.
aria-label / aria-labelledbystringNames the control. Needed whenever no <label htmlFor> points at the trigger: with nothing else supplied the trigger is named by its own text, which is the placeholder before a pick and the chosen label after — a name that changes with the value and never says what the field is for.
aria-describedbystringPoints at helper or error text elsewhere in the DOM.
aria-invalidboolean | 'true' | 'false'Turns the trigger border --destructive-ink. Accepts a raw boolean so aria-invalid={!!errors.x} works; falsy values never reach the DOM.
aria-requiredboolean | 'true' | 'false'Announced as required. Same permissive shape as aria-invalid.

Combobox only:

PropTypeDefaultDescription
valuestring | nullThe selected option value; null or undefined means nothing is chosen.
onValueChangerequired(value: string | null) => voidFires with the new value, or with null when the chosen row is picked again (choosing the current row toggles it off) or the clear button is pressed. The popover closes and the search box resets after each pick.
clearLabelstring'Xóa lựa chọn'sr-only name of the clear button. Vietnamese by default; only rendered when clearable is on.

MultiCombobox only:

PropTypeDefaultDescription
valuerequiredstring[]Selected values. The popover stays open between picks.
onValueChangerequired(value: string[]) => voidFires with the next array. Picking a selected row removes it.
maxDisplaynumber2How many badges the trigger shows before the rest collapse into a +k badge. Clamped to at least 1 — twelve names spilling out of a trigger break the form row.
maxSelectednumberSelection cap. Once reached, unselected rows are disabled (already-selected rows stay clickable so you can swap) and a counter appears under the list.
clearablebooleantrueNote the flipped default: multi-select opts in to the clear button, single-select opts out.
selectedCountLabel(selected: number, max: number) => string(s, m) => `Đã chọn ${s}/${m}`FUNCTION — the counter under the list when maxSelected is set. It interpolates two numbers, so it is a function, not a template string.
clearLabelstring'Xóa tất cả lựa chọn'sr-only name of the clear button — the button is an icon-only X, so this string is its accessible name. Vietnamese by default, and clearable is on by default here.

ComboboxOption:

PropTypeDefaultDescription
valuerequiredstringStable identifier. This is what onValueChange returns and what the filter searches first, so an opaque id is fine.
labelrequiredstringThe visible row text, and what the trigger shows once selected.
descriptionstringMuted second line — an email, a code, a department. Searchable.
iconIconComponentIcon component, not an element: icon={Rocket}, never icon={<Rocket />}.
disabledbooleanRow stays visible and searchable but cannot be chosen.
groupstringRows sharing a value are gathered under a heading, in the order each group first appears.
keywordsstring[]Extra search terms — a short code, an abbreviation, a former name. Matched alongside value, label and description.

Accessibility

  • The trigger is a real <button> with role="combobox" and a live aria-expanded. Enter or Space opens the popover, Escape closes it and returns focus to the trigger.
  • The panel itself is a Radix Popover, so it is announced as a dialog rather than as a listbox owned by the trigger. The real combobox relationship lives one level in, on cmdk's search box.
  • Focus lands in the search box once the popover is open. cmdk gives that input role="combobox", aria-autocomplete="list", aria-controls pointing at the role="listbox", and an aria-activedescendant that follows the arrow keys — so the highlighted row is announced while typing continues uninterrupted. Enter chooses it.
  • Rows are role="option"; disabled ones (including the ones a maxSelected cap has locked) carry aria-disabled and are skipped by the arrow keys rather than hidden.
  • Know this one: the tick beside a chosen row is aria-hidden, and cmdk uses aria-selected for the highlighted row rather than the chosen one. Which options are currently selected is conveyed by the trigger's text and badges, not by the list. If a screen-reader user has to audit a long multi-selection, give them that summary somewhere outside the popover.
  • Left to itself the trigger takes its accessible name from its own contents — the placeholder before a value is picked, the chosen label afterwards. So the name is announced as “Select a project” and then as “Billing migration”: it moves with the value and never states the field's purpose. Outside a FormField or a FieldLabel, give it a stable one with aria-label or aria-labelledby — every standalone demo on this page does; the one inside a FormField is named by its label instead.
  • The clear button is a focusable sibling of the trigger, reachable by Tab, with an sr-only name. That position is the whole point: nesting it inside the trigger would have been invalid HTML, and the usual workaround — a span with a negative tabindex — takes it away from keyboard users entirely.
  • Invalid state is not carried by colour alone: aria-invalid is exposed to assistive technology, and in a FormField the message is a role="alert" element wired in through aria-describedby. The border uses the -ink red, the step meant to sit on a page background, not the fill red behind a delete button.
  • The chevron clears the 3:1 non-text contrast threshold in both palettes (5.23:1 light, 5.70:1 dark) because it reports the expanded state of an operable control. Dimming it to 50% opacity, as a decorative caret could be, would drop it to about 2.1:1.