Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.
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
valueplus its keywords, and the component quietly foldslabelanddescriptioninto that keyword list — so an opaque id invaluecosts you nothing. - Stripping accents is done with Unicode
NFDplus 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 andducwould miss Đức. - The same normaliser is exported as
removeVietnameseTonesandnormalizeVi. 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 aspanwithtabIndex={-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-nonewith 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-foregroundrather than a dimmed copy of the trigger text. It reportsaria-expandedon an operable control, so it is a 1.4.11 target at 3:1; atopacity-50over 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 whendisabledis 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…) andemptyText(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 viaclearLabel(Clear selection onCombobox, Clear all selections onMultiCombobox) and themaxSelectedcounter (2/4 selected) viaselectedCountLabel, 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:clearabledefaults totrueonMultiCombobox, 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
booleanas well as the string forms the DOM uses, soaria-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
controlobject onto the combobox and stop thinking about it:id,aria-invalid,aria-requiredandaria-describedbyare 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)
| Prop | Kiểu | Mặc định | Mô 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
toSelectValueandfromSelectValue; 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 asundefinedwithout 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 againstSELECT_EMPTY_VALUEand get the string, not a proxy.
Props
Shared by Combobox and MultiCombobox:
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
optionsbắt buộc | ComboboxOption[] | — | The rows. Order is preserved: the filter only decides whether a row survives, it does not re-rank. |
placeholder | string | 'Select…' | Trigger text while nothing is chosen. Unset, it comes from the combobox namespace of ComitorLocaleProvider (Chọn… under a Vietnamese provider). |
searchPlaceholder | string | 'Search…' | Placeholder in the search box inside the popover. Follows ComitorLocaleProvider when unset. |
emptyText | string | 'No results found.' | Shown when the filter matches nothing. Follows ComitorLocaleProvider when unset. |
loadingText | string | 'Loading…' | Visible line inside the panel while loading is true. Follows ComitorLocaleProvider when unset. |
disabled | boolean | false | Disables 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. |
loading | boolean | false | Replaces the list with a spinner. Only meaningful in async mode. |
clearable | boolean | false (true on MultiCombobox) | Adds an X button that resets the value to null / []. |
className | string | — | Merged onto the trigger button. |
contentClassName | string | — | Merged onto the popover panel — for example to override the width, which otherwise tracks the trigger. |
id | string | — | Placed on the trigger, so a <label htmlFor> points at the right element. |
name | string | — | Forwarded to the trigger button. |
aria-label / aria-labelledby | string | — | 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-describedby | string | — | Points at helper or error text elsewhere in the DOM. |
aria-invalid | boolean | '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-required | boolean | 'true' | 'false' | — | Announced as required. Same permissive shape as aria-invalid. |
Combobox only:
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
value | string | 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. |
clearLabel | string | 'Clear selection' | sr-only name of the clear button; only rendered when clearable is on. Follows ComitorLocaleProvider (the clearLabel key) when unset. |
MultiCombobox only:
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
valuebắt buộc | string[] | — | 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. |
maxDisplay | number | 2 | How 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. |
maxSelected | number | — | Selection cap. Once reached, unselected rows are disabled (already-selected rows stay clickable so you can swap) and a counter appears under the list. |
clearable | boolean | true | Note 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). |
clearLabel | string | '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:
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
valuebắt buộc | string | — | Stable identifier. This is what onValueChange returns and what the filter searches first, so an opaque id is fine. |
labelbắt buộc | string | — | The visible row text, and what the trigger shows once selected. |
description | string | — | Muted second line — an email, a code, a department. Searchable. |
icon | IconComponent | — | Icon component, not an element: icon={Rocket}, never icon={<Rocket />}. |
avatarUrl | string | 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. |
disabled | boolean | — | Row stays visible and searchable but cannot be chosen. |
group | string | — | Rows sharing a value are gathered under a heading, in the order each group first appears. |
keywords | string[] | — | Extra search terms — a short code, an abbreviation, a former name. Matched alongside value, label and description. |
Accessibility
- The trigger is a real
<button>withrole="combobox"and a livearia-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-controlspointing at therole="listbox", and anaria-activedescendantthat 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 amaxSelectedcap has locked) carryaria-disabledand are skipped by the arrow keys rather than hidden. - Know this one: the tick beside a chosen row is
aria-hidden, and cmdk usesaria-selectedfor 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
FormFieldor aFieldLabel, give it a stable one witharia-labeloraria-labelledby— every standalone demo on this page does; the one inside aFormFieldis named by its label instead. - The clear button is a focusable sibling of the trigger, reachable by Tab, with an
sr-onlyname. That position is the whole point: nesting it inside the trigger would have been invalid HTML, and the usual workaround — aspanwith a negative tabindex — takes it away from keyboard users entirely. - Invalid state is not carried by colour alone:
aria-invalidis exposed to assistive technology, and in aFormFieldthe message is arole="alert"element wired in througharia-describedby. The border uses the-inkred, 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 frommdup, where they used to be 14px at every width — and so does the search box inside the popover, aCommandInput. Leave the size to the package; Input explains why.