@mshafiqyajid/react-combobox
Headless combobox hook and styled component. Type-to-filter, async loadOptions with debounce, grouped options, creatable, clearable, full keyboard navigation, portal dropdown, ARIA combobox pattern.
Playground #
Props
TSX
import { ComboboxStyled } from "@mshafiqyajid/react-combobox/styled";
import "@mshafiqyajid/react-combobox/styles.css";
<ComboboxStyled
options={options}
value={value}
onChange={setValue}
/>Grouped options
Grouped options (countries by region)
Async loadOptions
Async loadOptions (mock 500ms delay)
Creatable
Creatable — type a new option and press Enter
Form integration (label / hint / error)
Choose your primary framework
This field is required
Install #
npm install @mshafiqyajid/react-combobox Quick start #
import { useState } from "react";
import { ComboboxStyled } from "@mshafiqyajid/react-combobox/styled";
import "@mshafiqyajid/react-combobox/styles.css";
const options = [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "svelte", label: "Svelte" },
];
function App() {
const [value, setValue] = useState<string | null>(null);
return (
<ComboboxStyled
options={options}
value={value}
onChange={setValue}
placeholder="Search frameworks…"
clearable
/>
);
} Async loadOptions #
<ComboboxStyled
value={value}
onChange={setValue}
loadOptions={async (query) => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
return res.json(); // ComboboxOption[]
}}
debounceMs={300}
loadingText="Searching…"
emptyText="No results"
errorText="Couldn't load — try again"
/> Debounced on input query; stale requests cancelled via AbortController. Hook exposes isLoading and loadError; listbox sets aria-busy while in flight.
Grouped options #
const options = [
{ value: "react", label: "React", group: "Frontend" },
{ value: "vue", label: "Vue", group: "Frontend" },
{ value: "django", label: "Django", group: "Backend" },
{ value: "rails", label: "Rails", group: "Backend" },
];
<ComboboxStyled options={options} value={value} onChange={setValue} /> Creatable #
<ComboboxStyled
options={options}
value={value}
onChange={setValue}
creatable
createLabel={(q) => `Add "${q}"`}
onCreateOption={(newValue) => {
// add to your options list and set value
setOptions((prev) => [...prev, { value: newValue, label: newValue }]);
setValue(newValue);
}}
/> Form fields #
<ComboboxStyled
options={options}
value={value}
onChange={setValue}
label="Framework"
hint="Choose your primary framework"
placeholder="Search…"
required
/>
{/* With validation error */}
<ComboboxStyled
options={options}
value={value}
onChange={setValue}
label="Framework"
invalid
error="Please select a framework"
/> Headless #
import { useCombobox } from "@mshafiqyajid/react-combobox";
const options = [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
];
const {
inputProps,
listboxProps,
getOptionProps,
isOpen,
filteredOptions,
selectedOption,
clearSelection,
} = useCombobox({ options, value, onChange });
return (
<div style={{ position: "relative" }}>
<input {...inputProps} placeholder="Search…" />
{isOpen && (
<ul {...listboxProps} className="listbox">
{filteredOptions.map((opt, i) => (
<li key={opt.value} {...getOptionProps(opt, i)}>
{opt.label}
</li>
))}
</ul>
)}
</div>
); API #
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | null | — | Controlled selected value |
| defaultValue | string | — | Uncontrolled initial value |
| onChange | (v: string | null) => void | — | Called when selection changes |
| options | ComboboxOption[] | [] | Static options. Each: { value, label, disabled?, group? } |
| loadOptions | (q: string) => Promise<ComboboxOption[]> | — | Async option loader (debounced, cancellable) |
| debounceMs | number | 300 | Debounce delay for loadOptions |
| placeholder | string | "Search…" | Input placeholder |
| emptyText | string | "No options" | Message when no options match |
| loadingText | string | "Loading…" | Message while loading async options |
| errorText | string | — | Message on load error |
| creatable | boolean | false | Show a "Create" option for unmatched queries |
| createLabel | (q: string) => string | — | Custom label for the create option |
| onCreateOption | (v: string) => void | — | Called when user confirms creating a new option |
| clearable | boolean | true | Show clear button when a value is selected |
| size | "sm" | "md" | "lg" | "md" | Control size (32 / 40 / 48 px) |
| tone | "neutral" | "primary" | "success" | "danger" | "neutral" | Color tone applied to focus ring and selected state |
| disabled | boolean | false | Disable the combobox |
| readOnly | boolean | false | Input is read-only |
| required | boolean | false | Marks the field as required |
| invalid | boolean | false | Show error state styling |
| label | string | — | Label text above the control |
| hint | string | — | Hint text below the control |
| error | string | — | Error message below the control |
| placement | "auto" | "top" | "bottom" | "auto" | Dropdown side. "auto" picks bottom unless there isn't room. |
| offset | number | 4 | Gap (px) between control and dropdown |
| flip | boolean | true | Auto-flip when there isn't room on the chosen side |
| renderOption | (opt, state) => ReactNode | — | Custom option renderer |
Keyboard #
| Key | Action |
|---|---|
| Type | Filter options by query |
| ArrowDown / ArrowUp | Navigate options |
| Enter | Select focused option (or trigger create) |
| Escape | Close dropdown / clear query |
| Backspace (empty) | Clear current selection |
| Tab | Close dropdown |