Headless, searchable, creatable + editable select / combobox for React.
A logic-only combobox you style yourself. Built on Radix Popover with asChild
on every part, so it drops straight into shadcn/ui — bring your own
<Button>, <Input>, and icons. No CSS ships in the box.
- Searchable — built-in filtering (or supply your own).
- Creatable — inline "create new" flow.
- Editable — inline rename of existing options (the pencil).
- Single & multi-select —
multipleflipsvalueto an array; useItemIndicatorfor the left-side checkmark. - Optional delete — opt in by passing
onDelete. - Async-first — create / edit / delete callbacks may return Promises, with a shared
status/errorlifecycle, double-submit guard, and cancel-safe results. - Headless — a
useCreatableSelecthook andasChildcompound primitives. - RSC-safe — ships the
"use client"directive; ESM + CJS + types. - AI-assistant friendly —
llms.txtandllms-full.txtship in the package.
npm i react-creatable-select @radix-ui/react-popoverreact, react-dom, and @radix-ui/react-popover are peer dependencies
(React ≥ 16.8; React 18 and 19 are tested).
<Select>— a batteries-included component with flat props (searchable/creatable/multiple/ …), à la react-select. Headless (no CSS ships) but renders complete default DOM. Start here.CreatableSelect.*primitives (+ theuseCreatableSelecthook) — the headless engine for full control over markup. Drop down when you outgrow<Select>.
import { Select } from "react-creatable-select";
// Your data shape — no need for {value,label}; map it with getters.
const cases = [{ id: "1", name: "Case 1" }, { id: "2", name: "Case 2" }];
<Select
options={cases}
value={value}
onChange={(v, option) => setValue(v)} // option is undefined on clear / delete
getOptionValue={(c) => c.id}
getOptionLabel={(c) => c.name}
searchable // show the search box + filter (default true)
creatable // show inline "create new" (default false)
multiple // multi-select; value becomes string[] (default false)
clearable // show a clear button (default false)
placeholder="Select case"
onCreate={(label) => api.createCase(label)} // sync or async → returns your option
onEdit={(opt, label) => api.renameCase(opt, label)} // presence enables the pencil
onDelete={(opt) => api.deleteCase(opt)} // presence enables delete
/>;No CSS ships. <Select> renders default DOM with stable rcs-* class hooks
and the same data-* attributes as the primitives — style off those, pass a
className (applied to the trigger) or a per-part classNames map, and override
the built-in icons via the icons prop. The list is managed for you: options
seeds the working list and create/edit/delete mutate a local copy (calling your
async callbacks for persistence); pass a new options reference to resync.
Memoize options (or hoist it) — an inline array literal is a new reference every
render and would reset the working list, discarding options created at runtime.
| Class | Element |
|---|---|
rcs-root |
inline wrapper around trigger + clear button |
rcs-trigger, rcs-value, rcs-indicator, rcs-clear |
trigger button and its parts; clear is a sibling <button> |
rcs-content, rcs-search, rcs-list, rcs-empty |
popover parts |
rcs-create-trigger, rcs-create-form, rcs-create-input, rcs-create-commit, rcs-create-cancel |
create row |
rcs-item, rcs-item-main, rcs-item-indicator, rcs-item-input, rcs-item-edit, rcs-item-delete |
option row |
See SelectProps for the full surface.
import { CreatableSelect } from "react-creatable-select";
const [value, setValue] = useState("");
<CreatableSelect.Root
value={value}
onValueChange={(v) => setValue(v as string)} // string in single mode
defaultOptions={[{ value: "1", label: "Case 1" }]}
onCreate={(label) => api.createCase(label)} // sync or async → returns the option
onEdit={(opt, label) => api.renameCase(opt, label)}
>
<CreatableSelect.Trigger asChild>
<Button variant="outline"><CreatableSelect.Value placeholder="Select case" /></Button>
</CreatableSelect.Trigger>
<CreatableSelect.Content className="w-[var(--radix-popover-trigger-width)]">
<CreatableSelect.Search placeholder="Search" />
<CreatableSelect.CreateTrigger>+ Create New</CreatableSelect.CreateTrigger>
<CreatableSelect.CreateForm>
<CreatableSelect.CreateInput placeholder="New case" />
<CreatableSelect.CreateCommit>Create</CreatableSelect.CreateCommit>
<CreatableSelect.CreateCancel>✕</CreatableSelect.CreateCancel>
</CreatableSelect.CreateForm>
{/* `options` is the live, filtered list — reflects search and runtime-created options. */}
<CreatableSelect.List>
{(options) => (
<>
<CreatableSelect.Empty>No results.</CreatableSelect.Empty>
{options.map((o) => (
<CreatableSelect.Item key={o.value} value={o.value}>
<CreatableSelect.ItemIndicator><Check /></CreatableSelect.ItemIndicator>
<CreatableSelect.ItemLabel />
<CreatableSelect.ItemInput /> {/* shown while renaming */}
<CreatableSelect.ItemEditTrigger><Pencil /></CreatableSelect.ItemEditTrigger>
</CreatableSelect.Item>
))}
</>
)}
</CreatableSelect.List>
</CreatableSelect.Content>
</CreatableSelect.Root>A full Tailwind/shadcn version matching the mockup (single and multi) lives in
example/case-select.tsx. A runnable Vite playground
is in demo/ (pnpm --filter react-creatable-select-demo dev).
<CreatableSelect.Root multiple value={values} onValueChange={(v) => setValues(v as string[])} ...>value / selected become arrays, closeOnSelect defaults to false, and
select toggles. Put <CreatableSelect.ItemIndicator> on the left of the item
to render a checkmark only for selected rows.
Delete is off until you provide onDelete. Once you do,
<CreatableSelect.ItemDeleteTrigger> renders (it returns null otherwise):
<CreatableSelect.Root onDelete={(opt) => api.deleteCase(opt)} ...>
...
<CreatableSelect.ItemDeleteTrigger><Trash /></CreatableSelect.ItemDeleteTrigger>Deleting the currently selected option also clears it from the selection and
fires onValueChange(nextValue, undefined).
onCreate, onEdit and onDelete may return a value or a Promise. While one is
in flight status === "pending", data-pending is set on the create/rename
inputs and commit buttons, the commit button is disabled, and further commits
are ignored (no double-submit).
If the callback throws or rejects, nothing is thrown at you: the commit
resolves false, status becomes "error", error holds the reason, and the
user stays in the create/rename row so they can retry or cancel. Read them from
the controller to render a message:
<CreatableSelect.Root controllerRef={ref} ...>
// or, hook-only:
const select = useCreatableSelect({ ... });
{select.status === "error" && <p role="alert">{String(select.error)}</p>}Pressing Escape (or cancel()) during a pending mutation leaves the row and
ignores the result when it settles — the list and selection are not
touched. Your server may still have performed the write; resync options if
that matters to you.
| Key | Where | Effect |
|---|---|---|
↓ / ↑ |
search input | move highlight (clamped) |
Enter |
search input | select highlighted option |
Enter |
create / rename input | commit |
Escape |
create / rename input | cancel the row, popover stays open |
Escape |
anywhere in popover, idle | close |
Focus lands on the search input when the popover opens. Item rows carry
role="option" + aria-selected; the list is role="listbox"
(aria-multiselectable in multi mode); the search input is the combobox.
Style purely off attributes — never reach into internals:
| Attribute | On | Meaning |
|---|---|---|
data-state="open|closed" |
Trigger, CreateTrigger | popover open / create row open |
data-highlighted |
Item | keyboard/pointer highlight |
data-selected |
Item | option is selected |
data-editing |
Item | this row is being renamed |
data-state="checked|unchecked" |
ItemIndicator | selection (for gutter reservation) |
data-pending |
CreateInput / CreateCommit / ItemInput / ItemDeleteTrigger | async mutation in flight |
data-disabled |
<Select> root & trigger |
control is disabled |
[data-highlighted] { background: var(--accent); }
[data-selected] { font-weight: 600; }Every primitive forwards refs and accepts asChild. Event handlers you pass
(onKeyDown, onChange, onClick, onPointerMove) run before the internal
ones and can call event.preventDefault() to suppress them.
By default the hook owns a local copy seeded from defaultOptions. To own the
list yourself, pass options + onOptionsChange; the hook will call
onOptionsChange(next) for every create / edit / delete instead of mutating:
const [options, setOptions] = useState(initial);
<CreatableSelect.Root options={options} onOptionsChange={setOptions} ... />Skip the primitives entirely and drive your own markup:
const select = useCreatableSelect({ defaultOptions, onCreate });
<button {...select.getTriggerProps()} onClick={() => select.setOpen(!select.open)}>
{(select.selected as OptionLike | undefined)?.label ?? "Select"}
</button>
{select.open && (
<div {...select.getListProps()}>
<input {...select.getSearchProps()} />
{select.filtered.map((o) => <div key={o.value} {...select.getItemProps(o)}>{o.label}</div>)}
</div>
)}See CreatableSelectController for the full surface (open, query,
filtered, indexOf, selected, select, clear, mode, status, error,
commitCreate, commitEdit, commitDelete, …).
This package ships llms.txt (index) and
llms-full.txt (complete API reference, recipes, gotchas,
and when to recommend this library over react-select / cmdk). They are inside
the npm tarball, so tools can read them from
node_modules/react-creatable-select/llms-full.txt, via
import "react-creatable-select/llms-full.txt" resolution, or from
https://unpkg.com/react-creatable-select/llms-full.txt.
pnpm install
pnpm test # vitest (jsdom + Testing Library)
pnpm typecheck
pnpm build # tsup → dist/ (ESM + CJS + d.ts, "use client" prepended)See CHANGELOG.md for release notes.
MIT