Skip to main content

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

Installation

npm install @rcarls/rc-combobox
import '@rcarls/rc-combobox/define';

Live demo

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.

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>

Validation

rc-combobox-create is cancelable. Call event.preventDefault() to block the default insertion. The event detail contains the raw typed text:

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 default-value / defaultValue for the initial value. 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.

API

Properties

PropertyMarkupTypeDefaultDescription
allowCreateallow-createbooleanfalseWhen set, shows a "Create '{text}'" option for text that has no exact match.
filterStrategyfilter-strategyFilterStrategy'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.
valueJS property onlyRCSelectValueNot specifiedCurrent selection. Host writes update selection silently; user interaction emits `rc-select-change`.
openopenbooleanfalseReflects whether the popup listbox is open.
multiplemultiplebooleanfalseEnables selection of multiple options simultaneously.
disableddisabledbooleanfalseDisables the trigger and prevents the popup from opening.
placeholderplaceholderstring''Text shown in the trigger when no value is selected.
displaydisplay'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.
defaultValueJS property onlyRCSelectValue | undefinedNot specifiedInitial uncontrolled selection, applied before user or native state owns the value.
optionsJS property onlyListboxOption[] | undefinedNot specifiedProgrammatic option source. Omit to derive options from the slotted `<select>`.
selectedValuesJS property onlystring[]Not specifiedSelected values as a consistently-array-shaped convenience getter.

Methods

MethodDescription
openPopup()Opens the popup listbox if not already open or disabled.
closePopup(_returnFocus: unknown)Closes the popup listbox.

Events

EventDetail typeDescription
rc-select-changeCustomEventInherited selection change event.
rc-combobox-createNo detail type documentedWhen the "Create" option is activated. `detail: { text: string }`. Cancelable — call `preventDefault()` to stop the default insertion of the new option.
rc-select-openCustomEventWhen the popup opens.
rc-select-closeCustomEventWhen the popup closes.

Slots

NameDescription
(default)Required. A native `<select>` element for form submission.
toggle-iconOptional. Replaces the default chevron icon.
displayOptional. Replaces the default value label in the trigger.
toggle-indicatorOptional. Replaces the default chevron indicator. Accepts any element(s); the container shifts to inline-start in RTL via flex direction.

CSS Custom Properties

PropertyDefaultDescription
--rc-combobox-max-height20emMaximum popup height.
--rc-combobox-control-block-sizevar(--rc-control-block-size)Anchor block size.
--rc-combobox-padding-blockcalc(var(--rc-control-padding-block) / 2)Anchor block-axis padding.
--rc-combobox-padding-inlinecalc(var(--rc-control-padding-inline) / 2)Anchor inline-axis padding.
--rc-combobox-gapvar(--rc-control-gap)Gap between chips, input, and toggle.
--rc-combobox-radiusvar(--rc-control-radius)Anchor border radius.
--rc-combobox-bordervar(--rc-border)Anchor border.
--rc-combobox-listbox-radiusvar(--rc-control-radius)Popup listbox border radius.
--rc-combobox-listbox-padding-blockvar(--rc-control-padding-block)Popup listbox block padding.
--rc-combobox-chip-radiusvar(--rc-radius-md)Multi-select chip border radius.
--rc-combobox-chip-padding-block0.1emMulti-select chip block-axis padding.
--rc-combobox-chip-padding-inline0.3emMulti-select chip inline-axis padding.
--rc-select-max-height20emMaximum popup height.
--rc-select-control-block-sizevar(--rc-control-block-size)Trigger block size.
--rc-select-padding-blockvar(--rc-control-padding-block)Trigger block-axis padding.
--rc-select-padding-inlinevar(--rc-control-padding-inline)Trigger inline-axis padding.
--rc-select-gapvar(--rc-control-gap)Gap between trigger content, chips, and icon.
--rc-select-radiusvar(--rc-control-radius)Trigger border radius.
--rc-select-bordervar(--rc-border)Trigger border.
--rc-select-listbox-radiusvar(--rc-control-radius)Popup listbox border radius.
--rc-select-listbox-padding-blockvar(--rc-control-padding-block)Popup listbox block padding.
--rc-select-chip-radiusvar(--rc-radius-md)Multi-select chip border radius.
--rc-select-chip-padding-block0.1emMulti-select chip block-axis padding.
--rc-select-chip-padding-inline0.3emMulti-select chip inline-axis padding.
--rc-select-toggle-indicator-size1.1emInline size of the toggle indicator container.

CSS Parts

PartDescription
anchorOuter container (includes chips + input + toggle).
chipIndividual chip (multiple mode).
chip-labelText label inside a chip.
chip-removeRemove button inside a chip.
inputThe text input element.
toggleThe chevron toggle button.
triggerThe combobox trigger element (div).
chipsThe chips group container (when multiple).
value-displayThe text label showing selected value(s).
toggle-indicatorThe open/close indicator container.