Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.

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.

Options that are people

New in 3.7.0. Give an option an avatarUrl and it is drawn as a person: a LetterAvatar in place of the icon, in the list and beside the chosen name on the trigger. What counts is the key, not its value — null still draws initials, because in a list of people the ones without a photo need an avatar too, or the names stop lining up.

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 — the default selectedCountLabel, which reads Đã chọn 0/4 under a Vietnamese provider. 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 Loading… (Đang tải… under a Vietnamese provider).

Strings and language

Unlike DataTable or TablePagination, the comboboxes have no labels prop: every string is a prop of its own, and every one is optional. Left unset, a string comes from the combobox namespace of the nearest ComitorLocaleProvider, and is English when there is none. So the order is prop → provider → English: the first field below has neither, the other two sit under locale="vi", and the third passes its own placeholder, which wins. Open each one to see the search box and, after typing nonsense, the empty text.

No provider

locale="vi"

locale="vi" + own placeholder

  • The visible three: placeholder (Select… / Chọn…), searchPlaceholder (Search… / Tìm kiếm…) and emptyText (No results found. / Không tìm thấy kết quả.). Pass them when a field deserves its own wording — “Select a project” says more than “Select…”, which is why the other demos on this page do.
  • The easy-to-miss three: the loading line via loadingText (Loading…), the screen-reader-only name on the clear button via clearLabel (Clear selection on Combobox, Clear all selections on MultiCombobox) and the maxSelected counter (2/4 selected) via selectedCountLabel, which is a function, not a template string, because it interpolates two numbers and the words around them move from language to language (Đã chọn 2/4). The clear button's name is the one to watch: clearable defaults to true on MultiCombobox, so a Vietnamese app that forgets the provider announces an English name to screen readers without switching anything on — and nothing on screen looks wrong.
  • To reword one string across the whole app, give the provider messages={{ combobox: { emptyText: '…' } }} rather than repeating the prop at every call site.

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)

PropKiểuMặc địnhMô tả
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) => string—Model value → Radix value. Turns null, undefined and '' into the sentinel; passes anything else through.
fromSelectValue(value: string) => string—Radix 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:

PropKiểuMặc địnhMô tả
optionsbắt buộcComboboxOption[]—The rows. Order is preserved: the filter only decides whether a row survives, it does not re-rank.
placeholderstring'Select…'Trigger text while nothing is chosen. Unset, it comes from the combobox namespace of ComitorLocaleProvider (Chọn… under a Vietnamese provider).
searchPlaceholderstring'Search…'Placeholder in the search box inside the popover. Follows ComitorLocaleProvider when unset.
emptyTextstring'No results found.'Shown when the filter matches nothing. Follows ComitorLocaleProvider when unset.
loadingTextstring'Loading…'Visible line inside the panel while loading is true. Follows ComitorLocaleProvider when unset.
disabledbooleanfalseDisables the trigger and dims the adornment row, which is a sibling and would otherwise stay at full opacity.
onSearchChange(search: string) => void—Its 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 / [].
classNamestring—Merged onto the trigger button.
contentClassNamestring—Merged onto the popover panel — for example to override the width, which otherwise tracks the trigger.
idstring—Placed on the trigger, so a <label htmlFor> points at the right element.
namestring—Forwarded to the trigger button.
aria-label / aria-labelledbystring—Names 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-describedbystring—Points 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:

PropKiểuMặc địnhMô tả
valuestring | null—The selected option value; null or undefined means nothing is chosen.
onValueChangebắt buộc(value: string | null) => void—Fires 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'Clear selection'sr-only name of the clear button; only rendered when clearable is on. Follows ComitorLocaleProvider (the clearLabel key) when unset.

MultiCombobox only:

PropKiểuMặc địnhMô tả
valuebắt buộcstring[]—Selected values. The popover stays open between picks.
onValueChangebắt buộc(value: string[]) => void—Fires 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.
maxSelectednumber—Selection 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) => `${s}/${m} selected`FUNCTION — the counter under the list when maxSelected is set. It interpolates two numbers, so it is a function, not a template string: the word order around them differs by language (Đã chọn 2/4 in Vietnamese).
clearLabelstring'Clear all selections'sr-only name of the clear button — the button is an icon-only X, so this string is its accessible name. Follows ComitorLocaleProvider (the clearAllLabel key) when unset, and clearable is on by default here.

ComboboxOption:

PropKiểuMặc địnhMô tả
valuebắt buộcstring—Stable identifier. This is what onValueChange returns and what the filter searches first, so an opaque id is fine.
labelbắt buộcstring—The visible row text, and what the trigger shows once selected.
descriptionstring—Muted second line — an email, a code, a department. Searchable.
iconIconComponent—Icon component, not an element: icon={Rocket}, never icon={<Rocket />}.
avatarUrlstring | null—New in 3.7.0. The option is a person: with the key present a LetterAvatar replaces icon — the photo for a URL, initials from label for null — in the panel's row and beside the label on the Combobox trigger. Leave the key out for an ordinary option. Same shape as MarkdownEditorMentionItem.avatarUrl.
disabledboolean—Row stays visible and searchable but cannot be chosen.
groupstring—Rows 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.
  • Both triggers set their text like every field since 3.13.4 — 16px below md, 14px from md up, where they used to be 14px at every width — and so does the search box inside the popover, a CommandInput. Leave the size to the package; Input explains why.