Skip to content

Repository files navigation

react-creatable-select

npm CI license llms.txt

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 — multiple flips value to an array; use ItemIndicator for the left-side checkmark.
  • Optional delete — opt in by passing onDelete.
  • Async-first — create / edit / delete callbacks may return Promises, with a shared status / error lifecycle, double-submit guard, and cancel-safe results.
  • Headless — a useCreatableSelect hook and asChild compound primitives.
  • RSC-safe — ships the "use client" directive; ESM + CJS + types.
  • AI-assistant friendly — llms.txt and llms-full.txt ship in the package.

Install

npm i react-creatable-select @radix-ui/react-popover

react, react-dom, and @radix-ui/react-popover are peer dependencies (React ≥ 16.8; React 18 and 19 are tested).

Two ways to use it

  1. <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.
  2. CreatableSelect.* primitives (+ the useCreatableSelect hook) — the headless engine for full control over markup. Drop down when you outgrow <Select>.

Simplified API: <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.

Quick start (primitives)

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).

Multi-select

<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.

Optional delete

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).

Async mutations, errors and cancellation

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.

Keyboard

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.

Styling contract (data-*)

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.

Controlled options

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} ... />

Hook-only usage

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, …).

For AI assistants

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.

Contributing

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.

License

MIT

About

Headless, creatable & editable select / combobox for React. Searchable, single + multi-select, async-first. Built on Radix, drops into shadcn/ui.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages