@sam-mu/react-dropdown
v0.1.1
Published
Accessible, themeable React Dropdown / Select with multi-select, search, async loading, virtualization, creatable options, and color variants — built on Downshift + Floating UI.
Maintainers
Readme
@sam-mu/react-dropdown
A reusable, controlled, headless-friendly Dropdown / Select for React — written in
JavaScript (JSX), no TypeScript build step. Single-select works out of the box;
multi, searchable, clearable, creatable are opt-in flags that default OFF.
Built on Downshift (a11y/interaction) + Floating UI (positioning). Ships unstyled-able with CSS-variable theming. Peer-depends on React only.
Placeholder package name — rename before publishing.
Install
npm install @sam-mu/react-dropdown
# peer deps
npm install react react-domUsage
import { Dropdown } from '@sam-mu/react-dropdown';
import '@sam-mu/react-dropdown/styles.css'; // or roll your own via classNames
function Example() {
const [value, setValue] = React.useState(null);
return (
<Dropdown
label="Fruit"
options={['Apple', 'Banana', 'Cherry']}
value={value}
onChange={setValue}
clearable
/>
);
}Multi + searchable
<Dropdown
multi
searchable
clearable
options={[
{ value: 'us', label: 'United States', group: 'Americas' },
{ value: 'br', label: 'Brazil', group: 'Americas' },
{ value: 'de', label: 'Germany', group: 'Europe' },
]}
value={values}
onChange={setValues}
maxTagsVisible={2}
/>Async
<Dropdown
searchable
isLoading={loading}
onSearch={(q) => fetchOptions(q)} // debounced (searchDebounceMs, default 250)
options={results}
/>Phone / country-code wrapper
import { PhoneField } from '@sam-mu/react-dropdown';
<PhoneField
countryCode={code}
phone={phone}
onChange={({ countryCode, phone }) => { /* ... */ }}
/>Props
See the implementation spec §5 for the full prop table. Highlights:
| Prop | Type | Default | Notes |
|---|---|---|---|
| options | array | object | string | string[] | — | Normalized internally |
| value / defaultValue | V | V[] | null | — | Array when multi |
| onChange | (value, meta) => void | — | meta.action, meta.option |
| multi searchable clearable creatable | boolean | false | Opt-in |
| onSearch / searchDebounceMs | fn / number | 250 | Server-side filtering |
| renderOption renderValue renderEmpty | fn | — | Render slots |
| unstyled / classNames / size | — | — | Theming |
Value contract
When multi is true, value/onChange deal in arrays; otherwise a single
value or null. Normalized internally — the caller never branches on count.
Advanced features
Type-ahead — in non-searchable mode, start typing letters to jump the highlight
to the first matching option (like a native <select>).
Select all / count (multi) — pass showSelectAll to add a Select all / Clear all
toggle and an "N selected" counter to the menu header.
<Dropdown multi showSelectAll maxSelected={5} options={opts} value={v} onChange={setV} />Backspace removes the last chip in multi mode when the query is empty.
Match highlighting — search matches are wrapped in <mark class="dd-mark">.
Icon + description per option — the default row renders option.icon and
option.description:
options={[{ value: 'us', label: 'United States', icon: '🇺🇸', description: '+1' }]}Virtualization — long lists auto-virtualize past virtualizeThreshold (default 60)
via @tanstack/react-virtual. Force with virtualize={true} / disable with false.
(Flat lists only; grouped lists render normally.)
Async pagination — infinite scroll:
<Dropdown searchable isLoading={loading} onSearch={load}
options={rows} hasMore={hasMore} onLoadMore={loadNextPage} />Creatable with persistence — onCreate may return an option or a Promise of one:
<Dropdown creatable multi options={tags} value={v} onChange={setV}
onCreate={async (label) => { const opt = await api.createTag(label); return opt; }} />Custom data shapes — getOptionValue / getOptionLabel (or normalizeOption)
map arbitrary objects without pre-transforming your data.
Imperative handle — ref exposes { open, close, toggle, clear, focus, getValue }:
const ref = useRef(null);
<Dropdown ref={ref} options={opts} />;
ref.current.open();Form integration
A hidden <input name> is rendered for plain HTML form posts. With react-hook-form:
import { Controller, useForm } from 'react-hook-form';
import { Dropdown } from '@sam-mu/react-dropdown';
function Form() {
const { control, handleSubmit } = useForm({ defaultValues: { country: null } });
return (
<form onSubmit={handleSubmit(console.log)}>
<Controller
name="country"
control={control}
rules={{ required: true }}
render={({ field, fieldState }) => (
<Dropdown
label="Country"
options={countries}
value={field.value}
onChange={field.onChange}
onOpenChange={(o) => !o && field.onBlur()}
error={fieldState.error && 'Required'}
/>
)}
/>
<button type="submit">Save</button>
</form>
);
}Formik is analogous: wire value={field.value} and onChange={(v) => setFieldValue(name, v)}.
Accessibility
WAI-ARIA combobox/listbox semantics (via Downshift): role="combobox" with
aria-expanded / aria-controls, role="listbox"/option, aria-activedescendant
tracking, full keyboard support (↑/↓, Enter, Esc, Home/End, type-ahead), and a labelled
trigger. jest-axe runs against the closed and open (multi + search) states in CI with
zero violations.
Theming
Color variants
Pass variant for a semantic accent color — it tints the focus ring, selected rows,
tags/chips, checkmarks, and search highlight, while the trigger/menu surfaces stay
neutral:
<Dropdown variant="success" options={opts} /> // green
<Dropdown variant="danger" options={opts} /> // red
<Dropdown variant="warning" options={opts} /> // amber
// primary (default) | success | danger | warning | info | neutralEvery accent is derived from a single --dd-accent via color-mix, so a custom
color is just one variable — no per-token overrides:
.my-brand { --dd-accent: #7c3aed; } /* violet, everywhere */<Dropdown className="my-brand" options={opts} />Tokens & dark mode
Override any --dd-* CSS variable (defined on :root so the portaled menu inherits
them), pass classNames, or set unstyled to drop the default CSS entirely. Dark mode
is opt-in: add data-theme="dark" (or class="dark") to any ancestor — accents
adapt automatically since they blend toward --dd-fg.
Key tokens: --dd-accent (drives all accent colors), --dd-bg, --dd-fg,
--dd-border, --dd-radius, --dd-menu-bg, --dd-menu-shadow, --dd-option-hover,
--dd-danger, --dd-size-{sm,md,lg}.
Scripts
npm run dev # Vite playground at http://localhost:5173
npm run build # tsup -> dist (ESM + CJS + bundled CSS)
npm test # vitest (unit + component + jest-axe)
npm run size # size-limit bundle budgetStatus
Implements Tier 0 through Tier 2 and much of Tier 3 from the spec: single/multi/
search/clear/creatable, grouped + disabled options, icon/description rows, render slots,
match highlighting, type-ahead, select-all + count, async search + infinite scroll,
virtualization, imperative ref, hidden form inputs + RHF recipe, hand-written .d.ts
types, jest-axe gating, size-limit budget, opt-in dark mode, and the PhoneField
wrapper. Storybook + Changesets/CI remain as follow-ups.
