rc-combobox
Editable combobox with filtering and optional allow-create behavior, configured from native option data and following the WAI-ARIA Combobox pattern.
- Package
@rcarls/rc-combobox- Element
<rc-combobox>- Native dependency
- Requires a direct child native select
- State model
- Controlled or uncontrolled text and selection
- Main events
rc-select-changerc-combobox-createrc-select-openrc-select-close- Related
- Usage rulesTheme previews
Installation
- npm
- Yarn
- pnpm
npm install @rcarls/rc-webcomponents@next
yarn add @rcarls/rc-webcomponents@next
pnpm add @rcarls/rc-webcomponents@next
import '@rcarls/rc-webcomponents/rc-combobox/define';
Usage
Wrap a native <select> whose options are the choices. It stays in the page as the form value
and the no-JavaScript fallback, and its label names the text input.
<label>
Fruit
<rc-combobox placeholder="Search fruit">
<select name="fruit">
<option value="apple">Apple</option>
<option value="banana">Banana</option>
<option value="cherry">Cherry</option>
</select>
</rc-combobox>
</label>
Live demo
Accessibility
The text input has role="combobox" and aria-autocomplete="list", and filters an rc-listbox
popup. Focus stays in the input, which tracks the active option with aria-activedescendant.
In popup-mode="dialog", a trigger opens a modal that holds the filter input instead; Enter,
Space, or Down Arrow on the trigger opens it.
| Key | Action |
|---|---|
| Down Arrow | Opens the popup on the first match, or moves to the next match. |
| Up Arrow | Moves to the previous match while the popup is open. |
| Home, End | Moves to the first or last match while the popup is open. |
| Enter | Selects the active match, or the first match when none is active. |
| Escape | Clears the typed filter and closes the popup. |
| Tab | With the popup open, selects the first match, closes the popup, and moves focus on. |
| Backspace | In a multiple combobox with an empty filter, moves focus to the last chip's remove button. |
In a multiple combobox, Left Arrow with the caret at the start of the input also moves to the last chip's remove button (Right Arrow in right-to-left text).
Theming
The default demo mode shows the component without a package theme. Use the shared preview controls on this page to compare inherited, light, and dark color schemes or to apply the optional Material theme only inside the demo frames.
Inside a Material rc-field, a row of selected chips grows the field below its floated label, so
the label keeps the same offset as every other field. Add rc-combobox--dense to render 1.5rem chips that
fit on the field's single input line and keep a populated field at the standard 56px height. The
chips keep their full touch target.
<rc-field>
<label slot="label" for="categories">Categories</label>
<rc-combobox class="rc-combobox--dense" data-rc-field-control multiple>
<select id="categories" multiple>
<option selected>Beef</option>
<option selected>Cheap</option>
</select>
</rc-combobox>
</rc-field>
Allow Create
Add the allow-create attribute to show a "Create 'X'" option when typed text has no exact match in the option list. Activating it inserts the new option into the native <select>, selects it, and fires rc-combobox-create.
<rc-combobox allow-create placeholder="Add a tag…">
<select name="tags" multiple></select>
</rc-combobox>
Dialog popup
Set popup-mode="dialog" when the available viewport is too constrained for an anchored
listbox. This presents the current chips, search field, options, and a Done action in a native
modal dialog. Selection and allow-create changes are staged until Done; close or Escape discards
the draft. Done produces one committed selection change.
<rc-combobox popup-mode="dialog" allow-create placeholder="Add categories…">
<select name="categories" multiple>
<option value="quick">Quick meal</option>
<option value="vegetarian">Vegetarian</option>
</select>
</rc-combobox>
Applications can switch popupMode from viewport state. The mode is latched while a popup is
open so a resize does not replace an active interaction.
Validation
rc-combobox-create is cancelable. Call event.preventDefault() to block the default insertion. The event detail contains the raw typed text:
In dialog mode, create events are deferred until Done and run before the selection commit.
combobox.addEventListener('rc-combobox-create', (event) => {
if (event.detail.text.trim().length < 2) {
event.preventDefault(); // too short; block insertion
showError('Tag must be at least 2 characters.');
}
// Otherwise the default runs: option is inserted and selected.
});
React: managing options as state
When React owns the option list (for example, data fetched from a server), call preventDefault() and add the new item to your state instead. The component reads options from the slotted <select>, so the new <option> is picked up automatically after React re-renders via the slotchange event. Select the new value programmatically in a useEffect that runs after that render:
const [options, setOptions] = useState(initialOptions);
const pendingValue = useRef<string | null>(null);
const comboRef = useRef<HTMLElement & { value: string | string[] | undefined }>(null);
useEffect(() => {
const el = comboRef.current;
if (!el) return;
const handleCreate = (e: Event) => {
e.preventDefault(); // React manages options; block component insertion.
const { text } = (e as CustomEvent<{ text: string }>).detail;
const value = text.trim().toLowerCase().replace(/\s+/g, '-');
setOptions((prev) => [...prev, { value, label: text.trim() }]);
pendingValue.current = value;
};
el.addEventListener('rc-combobox-create', handleCreate);
return () => el.removeEventListener('rc-combobox-create', handleCreate);
}, []);
// Runs after React renders the new <option> into the DOM, by which point
// the combobox has processed slotchange and registered the option.
useEffect(() => {
const value = pendingValue.current;
if (!value || !comboRef.current) return;
pendingValue.current = null;
const el = comboRef.current;
const current = Array.isArray(el.value) ? el.value : el.value ? [el.value] : [];
if (!current.includes(value)) {
el.value = [...current, value];
}
}, [options]);
return (
<rc-combobox ref={comboRef} allow-create placeholder="Add a framework…">
<select name="frameworks" multiple>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</rc-combobox>
);
Form usage: ephemeral options until committed
Keep options (permanent) and pendingOptions (session-only) as separate state. Render both into the <select> so React controls all <option> elements. On submit, move the newly created selections into options with setOptions:
const [options, setOptions] = useState(persistedOptions); // permanent
const [pendingOptions, setPendingOptions] = useState([]); // session-only
const pendingValue = useRef(null);
const handleCreate = (e) => {
e.preventDefault(); // React renders all <option> elements; block component insertion.
const { text } = e.detail;
const newOpt = { value: text.toLowerCase(), label: text };
setPendingOptions((prev) => [...prev, newOpt]);
pendingValue.current = newOpt.value;
// (post-render useEffect selects pendingValue; same pattern as the React demo above)
};
const handleSubmit = (e) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
const selected = data.getAll('labels');
const newlyCreated = pendingOptions.filter((o) => selected.includes(o.value));
// After saving to the server, commit new options to permanent state:
setOptions((prev) => [...prev, ...newlyCreated]);
setPendingOptions([]);
comboRef.current.value = []; // clear selection after submit
};
// Reset discards pending options without committing them:
const handleReset = () => {
setPendingOptions([]);
comboRef.current.value = [];
};
// JSX: render persisted and pending options separately.
<rc-combobox ref={comboRef} allow-create>
<select name="labels" multiple>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
{pendingOptions.map((o) => (
<option key={`p-${o.value}`} value={o.value}>
{o.label}
</option>
))}
</select>
</rc-combobox>;
Controlled vs Uncontrolled
Selection
Uncontrolled (default): the component owns selection after mount. Use <option selected> or the defaultValue property for the initial value. defaultValue accepts string | string[] and has no HTML attribute form. Listen to rc-select-change to observe changes.
<!-- initial selection via native attribute -->
<rc-combobox>
<select name="fruit">
<option value="apple" selected>Apple</option>
<option value="banana">Banana</option>
</select>
</rc-combobox>
Controlled: write el.value (the property) to drive selection from outside the component. Programmatic writes apply selection silently. No rc-select-change is dispatched. Keep your state in sync by updating el.value in response to rc-select-change:
// Drive selection from outside:
combobox.value = 'banana';
// React to user selection and keep external state in sync:
combobox.addEventListener('rc-select-change', (e) => {
myState.fruit = e.detail.value;
});
For multiple selects, pass an array:
combobox.value = ['apple', 'cherry'];
Options with allow-create
Uncontrolled options: let the default behavior run. The component appends the new <option> to the native <select> and selects it. The option persists for the lifetime of the element.
Controlled options: call event.preventDefault() on rc-combobox-create and manage the <option> elements yourself (for example, in React state). After adding the option to the DOM, set el.value to include the new value. See React: managing options as state above.
Known Issues
Firefox for Android: <label>-wrapped multiple selects (worked around)
On Firefox for Android (GeckoView), tapping anywhere within an ancestor <label>'s bounds opens the native picker dialog for a wrapped <select multiple>, even though the select is hidden: "Geckoview and Fennec open select dropdown on tap of parent label; other browsers do not." Bugzilla 1475723
To workaround this bug, rc-select force-disables the slotted <select multiple> and hooks into the associated form's submit and formdata events to preserve constraint validation and form data participation.
See the rc-select Known Issues entry for consumer implications (pre-upgrade behavior, form submission, constraint validation).
Localization
Built-in text follows the element's own lang, else <html lang>. Override it with
messages, or register it for every instance under rc-combobox: every rc-select key plus search. Text registered under rc-select applies too, below the combobox's own; the "Create" option reads rc-listbox (create). See
Localization.
API
Properties
| Property | Markup | Type | Default | Description |
|---|---|---|---|---|
messages | JS property only | Partial<RCSelectMessages> | undefined | Not specified | Built-in text overrides; unset keys fall back to registered text, then English. |
allowCreate | allow-create | boolean | false | When set, shows a "Create '{text}'" option for text that has no exact match. |
filterStrategy | filter-strategy | FilterStrategy | 'contains' | How option labels are matched against typed input. - Forwarded to the internal `rc-listbox`. - Defaults to `'contains'` (substring). - Set to `'prefix'` for starts-with matching, or - Pass a custom `(label, query) => boolean` predicate. Function values are JS-only; string values may be set via the `filter-strategy` attribute. |
value | JS property only | RCSelectValue | Not specified | Current selection. Host writes update selection silently; user interaction emits `rc-select-change`. |
open | open | boolean | false | Reflects whether the popup listbox is open. |
multiple | multiple | boolean | false | Enables selection of multiple options simultaneously. |
disabled | disabled | boolean | false | Disables the trigger and prevents the popup from opening. |
required | required | boolean | false | Mirrors the slotted native `<select>`'s own `required`, for `aria-required` on the trigger. Read-only in practice: set `required` on the slotted `<select>` itself, the source of truth for form participation and constraint validation. |
placeholder | placeholder | string | '' | Text shown in the trigger when no value is selected. |
display | display | 'auto' | 'chips' | 'compact' | 'auto' | Controls how selected values appear in the trigger. - `'chips'` — each selected value renders as a removable chip. - `'compact'` — selected values are summarized as "First, +N more". - `'auto'` — uses `'chips'` on pointer devices and `'compact'` on touch. |
popupMode | popup-mode | RCSelectPopupMode | 'popover' | Presents options in an anchored popover or transactional modal dialog. |
dialogCancelButton | dialog-cancel-button | 'visible' | 'hidden' | 'visible' | Whether the leading dialog cancel action is visible. |
defaultValue | JS property only | RCSelectValue | undefined | Not specified | Initial uncontrolled selection, applied before user or native state owns the value. |
options | JS property only | ListboxOption[] | undefined | Not specified | Programmatic option source. Omit to derive options from the slotted `<select>`. |
selectedValues | JS property only | string[] | Not specified | Selected values as a consistently-array-shaped convenience getter. |
Methods
| Method | Description |
|---|---|
openPopup() | Opens the popup listbox if not already open or disabled. |
closePopup(_returnFocus: unknown) | Closes the popup listbox. |
focus(options?: FocusOptions) | Forwards focus to the trigger. The host itself is never in the tab order, so without this override, calling `.focus()` on `<rc-select>` (as `rc-field` does when treating it as a control provider) would be a no-op. |
blur() | Forwards blur to the trigger; see `focus()`. |
Events
| Event | Detail type | Description |
|---|---|---|
rc-select-change | CustomEvent | Inherited selection change event. |
rc-combobox-create | No detail type documented | When the "Create" option is activated. `detail: { text: string }`. Cancelable — call `preventDefault()` to stop the default insertion of the new option. |
rc-select-open | CustomEvent | When the popup opens. |
rc-select-close | CustomEvent | When the popup closes. |
Slots
| Name | Description |
|---|---|
(default) | Required. A native `<select>` element for form submission. |
toggle-icon | Optional. Replaces the default chevron icon. |
display | Optional. Replaces the default value label in the trigger. |
toggle-indicator | Optional. Replaces the default chevron indicator. Accepts any element(s); the container shifts to inline-start in RTL via flex direction. Built-in text (chip names, the dialog actions, the compact summary) follows the element's own `lang`, else `<html lang>`: per element through `messages`, or for every select through `registerMessages()` from `@rcarls/rc-common` under `rc-select`. |
CSS Custom Properties
| Property | Default | Description |
|---|---|---|
--rc-combobox-max-height | 20em | Maximum popup height. |
--rc-combobox-control-block-size | var(--rc-control-block-size) | Anchor block size. |
--rc-combobox-padding-block | calc(var(--rc-control-padding-block) / 2) | Anchor block-axis padding. |
--rc-combobox-padding-inline | calc(var(--rc-control-padding-inline) / 2) | Anchor inline-axis padding. |
--rc-combobox-gap | var(--rc-control-gap) | Gap between chips, input, and toggle. |
--rc-combobox-radius | var(--rc-control-radius) | Anchor border radius. |
--rc-combobox-border | var(--rc-border) | Anchor border. |
--rc-combobox-listbox-radius | var(--rc-popup-radius) | Popup listbox border radius. |
--rc-combobox-listbox-duration | 150ms | Popup listbox open/close fade transition duration. |
--rc-combobox-listbox-border | var(--rc-popup-border) | Popup listbox border. |
--rc-combobox-listbox-background | var(--rc-popup-background) | Popup listbox background. |
--rc-combobox-listbox-color | var(--rc-popup-color) | Popup listbox text color. |
--rc-combobox-shadow | var(--rc-popup-shadow) | Popup listbox box shadow. |
--rc-combobox-listbox-padding-block | var(--rc-popup-padding-block) | Popup listbox block padding. |
--rc-combobox-dialog-input-border | var(--rc-border) | Dialog search input border. |
--rc-combobox-dialog-input-radius | var(--rc-control-radius) | Dialog search input radius. |
--rc-combobox-dialog-input-background | var(--rc-field) | Dialog search input background. |
--rc-combobox-dialog-gap | 1rem | Gap between fullscreen dialog regions. |
--rc-combobox-dialog-header-gap | 0.5rem | Gap between fullscreen dialog header slots. |
--rc-combobox-dialog-search-gap | 0.25rem | Gap between the dialog search label and input. |
--rc-anchor-viewport-inline-size | Not specified | Visual-viewport inline space supplied by the anchor controller. |
--rc-anchor-viewport-block-size | Not specified | Visual-viewport block space supplied by the anchor controller. |
--rc-combobox-chip-radius | var(--rc-radius-md) | Multi-select chip border radius. |
--rc-combobox-chip-padding-block | 0.1em | Multi-select chip block-axis padding. |
--rc-combobox-chip-padding-inline-start | 0.3em | Multi-select chip leading padding. |
--rc-combobox-chip-padding-inline-end | calc(var(--rc-chip-remove-target-size, 1.5rem) + var(--rc-chip-remove-offset-inline, 0.125rem)) | Multi-select chip trailing padding, including the remove affordance. |
--rc-combobox-chip-gap | calc(var(--rc-control-gap, 0.25em) * 0.8) | Gap between chip content items. |
--rc-combobox-chip-border | var(--rc-border) | Multi-select chip border. |
--rc-chip-remove-target-size | 1.5rem | Inherited remove-target width reserved by generated multi-select chips. |
--rc-chip-remove-offset-inline | 0.125rem | Inherited edge offset reserved by generated multi-select chips. |
--rc-chip-touch-target-overlap-block-start | 0px | Space returned between wrapped chip rows after a touch target overlaps the field's leading padding. |
--rc-chip-touch-target-overlap-block-end | 0px | Space returned between wrapped chip rows after a touch target overlaps the field's trailing padding. |
--rc-combobox-toggle-size | 1.1em | Inline size of the toggle button's icon area. |
--rc-select-max-height | 20em | Maximum popup height. |
--rc-select-control-block-size | var(--rc-control-block-size) | Trigger block size. |
--rc-select-padding-block | var(--rc-control-padding-block) | Trigger block-axis padding. |
--rc-select-padding-inline | var(--rc-control-padding-inline) | Trigger inline-axis padding. |
--rc-select-gap | var(--rc-control-gap) | Gap between trigger content, chips, and icon. |
--rc-select-radius | var(--rc-control-radius) | Trigger border radius. |
--rc-select-border | var(--rc-border) | Trigger border. |
--rc-select-listbox-radius | var(--rc-popup-radius) | Popup listbox border radius. |
--rc-select-listbox-duration | 150ms | Popup listbox open/close fade transition duration. |
--rc-select-listbox-border | var(--rc-popup-border) | Popup listbox border. |
--rc-select-listbox-background | var(--rc-popup-background) | Popup listbox background. |
--rc-select-listbox-color | var(--rc-popup-color) | Popup listbox text color. |
--rc-select-shadow | var(--rc-popup-shadow) | Popup listbox box shadow. |
--rc-select-listbox-padding-block | var(--rc-popup-padding-block) | Popup listbox block padding. |
--rc-select-chip-radius | var(--rc-radius-md) | Multi-select chip border radius. |
--rc-select-chip-padding-block | 0.1em | Multi-select chip block-axis padding. |
--rc-select-chip-padding-inline-start | 0.3em | Multi-select chip leading padding. |
--rc-select-chip-padding-inline-end | calc(var(--rc-chip-remove-target-size, 1.5rem) + var(--rc-chip-remove-offset-inline, 0.125rem)) | Multi-select chip trailing padding, including the remove affordance. |
--rc-select-chip-gap | calc(var(--rc-control-gap, 0.25em) * 0.8) | Gap between chip content items. |
--rc-select-chip-border | var(--rc-border) | Multi-select chip border. |
--rc-select-toggle-indicator-size | 1.1em | Inline size of the toggle indicator container. |
--rc-select-dialog-gap | 1rem | Gap between fullscreen dialog regions. |
--rc-select-dialog-header-gap | 0.5rem | Gap between fullscreen dialog header slots. |
CSS Parts
| Part | Description |
|---|---|
anchor | Outer container (includes chips + input + toggle). |
chips | The chip group container (multiple mode). |
chip | Individual chip (multiple mode). |
chip-label | Text label inside a chip. |
chip-remove | Remove button inside a chip. |
input | The text input element. |
toggle | The chevron toggle button. |
listbox | The `<rc-listbox>` popup element. |
dialog | Dialog popup surface. |
dialog-header | Dialog title and action row. |
dialog-title | Visible dialog title. |
dialog-selected | Scrollable selected-chip region. |
dialog-search | Dialog search field wrapper. |
dialog-input | Dialog search input. |
dialog-listbox | Dialog option list. |
dialog-actions | Dialog action group. |
dialog-confirm | Commits pending dialog selection and closes the dialog. |
dialog-cancel | Discards pending dialog selection and closes the dialog. Built-in text follows the element's own `lang`, else `<html lang>`: per element through `messages`, or for every combobox through `registerMessages()` from `@rcarls/rc-common` under `rc-combobox`. Text registered under `rc-select` applies too, below the combobox's own. The "Create" option reads `rc-listbox`. |
trigger | The combobox trigger element (div). |
value-display | The text label showing selected value(s). |
toggle-indicator | The open/close indicator container. |
dialog-cancel-icon | Decorative close icon inside the cancel button. |