preact-combobox
v0.3.1
Published
Preact Multi-select Combo Box component
Maintainers
Readme
PreactCombobox
A Preact multi-select/single-select combobox component.

Interactive examples - https://munawwar.github.io/preact-combobox/example/example.html
(The code for the examples is in the git repo example/ directory).
Installation
# `npm add preact` first
npm add preact-combobox @popperjs/coreUsage
import PreactCombobox from "preact-combobox";
import "preact-combobox/dist/esm/PreactCombobox.css";
const options = [
{ label: "Option 1", value: "option-1" },
{ label: "Option 2", value: "option-2", icon: "🔍" }, // Optional icon
];
function App() {
const [values, setValues] = useState(["option-1"]);
// For single-select mode
const [singleValue, setSingleValue] = useState("option-1");
return (
<>
{/* Multi-select (default) */}
<PreactCombobox
id="my-combobox"
allowedOptions={options}
value={values}
onChange={setValues}
/>
{/* Single-select */}
<PreactCombobox
id="single-select"
multiple={false}
allowedOptions={options}
value={singleValue}
onChange={setSingleValue}
/>
</>
);
}CSS
If you have a build tool:
// In your main JS/TS entry file
import 'preact-combobox/dist/esm/PreactCombobox.css';Or download the CSS file from src/PreactCombobox.css and use it directly. Or you could use a CDN like esm.sh or unpkg.com if you wish:
@import 'https://esm.sh/preact-combobox/dist/esm/PreactCombobox.css';
/* Or use a CSS layer in modern browser to make overriding design easier */
@import 'https://esm.sh/preact-combobox/dist/esm/PreactCombobox.css' layer(components);
/* Or */
@layer components {
@import 'https://esm.sh/preact-combobox/dist/esm/PreactCombobox.css';
}Customizing Styles
You can customize the component's appearance by overriding CSS variables. The component uses CSS variables for theming:
:root {
/* Light theme overrides */
--preact-combobox-light-bg: #fff;
--preact-combobox-light-text: #333;
--preact-combobox-light-border: #ddd;
/* See PreactCombobox.css for all available variables */
}Without build tools
<script type="importmap">
{
"imports": {
"preact": "https://esm.sh/preact@10",
"preact/hooks": "https://esm.sh/preact@10/hooks",
"preact/jsx-runtime": "https://esm.sh/preact@10/jsx-runtime",
"preact/compat": "https://esm.sh/preact@10/compat",
"htm": "https://esm.sh/htm",
"@popperjs/core": "https://esm.sh/@popperjs/core@2",
"preact-combobox": "https://esm.sh/preact-combobox",
}
}
</script>Component Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| id | string | (required) | Component ID |
| className | string | "" | Additional CSS class |
| allowedOptions | Option[] \| Function | (required) | Array of options or function for lazy-loading |
| onChange | Function | (required) | Callback for value change |
| value | string[] \| string | [] or "" | Selected value(s) |
| multiple | boolean | true | Enable multi-select mode |
| allowFreeText | boolean | false | Allow custom values |
| language | string | "en" | BCP 47 language code for text matching and word segmentation (passed directly to Intl.Segmenter and Intl.Collator) |
| placeholder | string | "" | Input placeholder text shown when no selections are made |
| disabled | boolean | false | Disable the combobox |
| required | boolean | false | Mark as required |
| showClearButton | boolean | true | Show clear button for selections |
| name | string | undefined | Input name (for forms) |
| theme | "light" \| "dark" \| "system" | "system" | Theme for the component |
| showValue | boolean | false | Show value alongside label |
| optionRenderer | Function | defaultOptionRenderer | Custom option renderer |
| optionIconRenderer | Function | defaultOptionIconRenderer | Custom icon renderer for options |
| translations | Translations | defaultEnglishTranslations | Localization strings |
| formSubmitCompatible | boolean | false | Include hidden form elements for submission |
| portal | HTMLElement | document.body | Element to render dropdown into |
| rootElementProps | Object | {} | Props for root element |
| inputProps | Object | {} | Props for input element |
| selectElementProps | Object | {} | Props for hidden select element |
| tray | boolean \| "auto" | "auto" | Enable mobile tray mode - true/false or 'auto' for media query detection |
| trayBreakpoint | string | "768px" | CSS breakpoint for auto tray mode (e.g., '768px', '50rem') |
| trayLabel | string | undefined | Label text for the tray header (auto-detects from associated label if not provided) |
| warningIcon | VNode | (default icon) | Custom warning icon |
| tickIcon | VNode | (default icon) | Custom tick icon for selected options |
| chevronIcon | VNode | (default icon) | Custom dropdown icon |
| loadingRenderer | (text: string) => VNode\|string | (default text) | Custom loading indicator |
| maxPresentedOptions | number | 100 | Maximum number of options to show in the dropdown. For async options, this value is passed as the limit parameter to the fetcher function |
Advanced Features
Async/Lazy-loaded Options
// Simulate API call for options
const fetchOptions = async (query, limit, currentSelections, signal) => {
// Return Promise<Option[]>
const response = await fetch(`/api/search?q=${query}&limit=${limit}`, { signal });
return response.json();
};
function LazyExample() {
const [values, setValues] = useState([]);
return (
<PreactCombobox
id="lazy-example"
allowedOptions={fetchOptions}
value={values}
onChange={setValues}
placeholder="Type to search..."
/>
);
}RTL Support and Translations (Experimental)
const arabicTranslations = {
searchPlaceholder: "بحث...",
noOptionsFound: "لم يتم العثور على خيارات",
loadingOptions: "جاري التحميل...",
// Additional translations...
};
function RTLExample() {
const [values, setValues] = useState([]);
return (
<div dir="rtl">
<PreactCombobox
id="rtl-example"
allowedOptions={options}
value={values}
onChange={setValues}
language="ar"
translations={arabicTranslations}
/>
</div>
);
}Form Submission Compatible
<form onSubmit={handleSubmit}>
<PreactCombobox
id="form-example"
allowedOptions={options}
value={values}
onChange={setValues}
formSubmitCompatible={true}
name="selected-options"
// use true if you are doing a server-side render
isServer={typeof self === "undefined"}
/>
<button type="submit">Submit</button>
</form>Customizing Rendering and Icons
import { h } from "preact";
// Here is how to customize the looks of dropdown options
// This is the exact defaultOptionRenderer from PreactCombobox.jsx
import { matchSlicesToNodes } from 'preact-combobox';
/** @type {import('preact-combobox').OptionTransformFunction} */
function defaultOptionRenderer({
option,
isSelected,
isInvalid,
showValue,
warningIcon,
optionIconRenderer,
}) {
const isLabelSameAsValue = option.value === option.label;
/**
* @param {(VNode|string)[]} labelNodes
* @param {(VNode|string)[]} valueNodes
* @returns {VNode}
*/
const getLabel = (labelNodes, valueNodes) => (
<>
{optionIconRenderer?.(option, false)}
<span className="PreactCombobox-optionLabelFlex">
<span>{labelNodes}</span>
{isLabelSameAsValue || !showValue ? null : (
<span className="PreactCombobox-optionValue" aria-hidden="true">
({valueNodes})
</span>
)}
</span>
</>
);
const { label, value, matched, matchSlices } = option;
let labelElement;
if (matched === "label" || (matched === "value" && value === label)) {
const labelNodes = matchSlicesToNodes(matchSlices, label);
labelElement = getLabel(labelNodes, [value]);
} else if (matched === "value") {
const valueNodes = matchSlicesToNodes(matchSlices, value);
labelElement = getLabel([label], valueNodes);
} else {
// if matched === "none"
labelElement = getLabel([label], [value]);
}
return (
<>
<span
className={`PreactCombobox-optionCheckbox ${
isSelected ? "PreactCombobox-optionCheckbox--selected" : ""
}`}
>
{isSelected && <span aria-hidden="true">✓</span>}
</span>
{labelElement}
{isInvalid && warningIcon}
</>
);
}
// Here is how to customize the looks of the icon rendered before the option label text and input field
/** @type {NonNullable<import('preact-combobox').PreactComboboxProps['optionIconRenderer']>} */
const customIconRenderer = (option, isInput) => {
// Commented out code is the exact default icon renderer from PreactCombobox.jsx
// return option.icon ? (
// <span className="PreactCombobox-optionIcon" aria-hidden="true" role="img">
// {option.icon}
// </span>
// ) : null;
// But let me show you what else you could do:
const category = option.value.split('-')[0];
// or you could pass option.category in allowedOptions list and then do:
// const { category } = option;
// Different icon size when shown in input vs dropdown
const size = isInput ? 16 : 20;
// Icon mapping based on category
const icons = {
fruit: <img width={size} height={size} src="./fruit.svg" />,
vegetable: <img width={size} height={size} src="./vegetable.svg" />,
other: <img width={size} height={size} src="./other.svg" />
};
return (
<span className={`custom-icon category-${category}`}>
{icons[category] || icons.other}
</span>
);
};
// Here is how to customize the warning icon (static, not a function)
const customWarningIcon = <img class="PreactCombobox-warningIcon" src="warning.svg" />;
// Here is how to customize the tick icon for selected options (static, not a function)
const customTickIcon = <img class="PreactCombobox-tickIcon" src="checkmark.svg" />;
// Here is how to customize the dropdown chevron (static, not a function)
const customChevronIcon = <img class="PreactCombobox-chevron" src="chevron" />
// Here is how to customize the loading indicator
/** @type {NonNullable<import('preact-combobox').PreactComboboxProps['loadingRenderer']>} */
const customLoadingRenderer = (text) => (
<div className="custom-loading">
<svg className="spinner" viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="12" r="10" stroke="currentColor" fill="none" strokeWidth="2" />
</svg>
<span>{text}</span>
</div>
);
function CustomRenderingExample() {
const [values, setValues] = useState([]);
const options = [
{ label: "Apple", value: "fruit-apple", description: "Red and sweet" },
{ label: "Banana", value: "fruit-banana", description: "Yellow and curved" },
{ label: "Carrot", value: "vegetable-carrot", description: "Orange and crunchy" },
{ label: "Broccoli", value: "vegetable-broccoli", description: "Green and healthy" },
{ label: "Other item", value: "other-item", description: "Miscellaneous" }
];
return (
<PreactCombobox
id="custom-rendering-example"
allowedOptions={options}
value={values}
onChange={setValues}
optionRenderer={defaultOptionRenderer}
optionIconRenderer={customIconRenderer}
warningIcon={customWarningIcon}
tickIcon={customTickIcon}
chevronIcon={customChevronIcon}
loadingRenderer={customLoadingRenderer}
/>
);
}Option Object Format
{
label: "Display Text", // Required: text shown in dropdown
value: "option-value", // Required: unique identifier
icon: "🔍", // Optional: icon (string or VNode)
disabled: true, // Optional: whether option is disabled and cannot be selected
divider: true // Optional: show divider line below option (only when search is empty)
}Advanced: Using OptionsListbox Directly
If you need to build a custom UX (e.g., a menu dropdown with an embedded search field), you can use the extracted OptionsListbox component and helper utilities directly. See OptionsListbox.md for props, ref methods, and available helper exports.
About Accessibility
Some attempt has been made to make the component accessible in the following dimensions:
- Keyboard navigation
- Basic screen reader test only on Orca (v46.1). Help me out to test on more software and platforms.
- Default colors for light mode theme (passes WCAG and APCA contrast)
Run Demo
npm run dev
# open http://localhost:3050/example/example.html in a browser