react-cmdk-base
v0.12.0
Published
A fast, accessible React command palette + AI prompt input built on Base UI
Downloads
609
Maintainers
Readme
react-cmdk-base
A fast, accessible React command palette + AI-style prompt input built on Base UI primitives.
Two top-level primitives:
CommandMenu—cmd/ctrl+K-style palette with drill-down pages, groups, custom filtering, and free-search fallback.PromptInput— chat-style composer with auto-grow textarea, attachments (file picker + drag/drop + paste), toolbar buttons with tooltips, action menus, model selector, and status-aware submit/stop.
Both expose composable parts, CSS-variable theme tokens, and asChild polymorphism. The library inlines its own SVG icons — no lucide-react / heroicons runtime dep.
See the full release history in CHANGELOG.md.
Features
- Accessible by default — full ARIA combobox/listbox + dialog semantics, focus trap, scroll lock, all via Base UI
- CommandMenu: drill-down pages with breadcrumb prefix and backspace-to-go-back; grouped items with sticky headings; custom
filterprop;forceMountfor catch-all actions; auto-renderingEmpty;LoadingandSeparatorprimitives; free-search fallback - PromptInput: Enter-submit / Shift+Enter newline (IME-safe); drag/drop, paste, and file-picker attachments; deferred object-URL revoke; status-aware Submit (ready / submitted / streaming / error) with Stop affordance; tooltip wrapper; screen-capture menu item
asChildcomposition onCommandMenu.Item,PromptInput.Button, andPromptInput.Submit— render your own design-system element while keeping primitive behaviour- CSS-variable theme tokens (
--pi-*,--cmdk-*) for branding without overriding utility classes — plus an optionalluztheme overlay andluzTailwind palette shipped as separate CSS imports - Tailwind v4 source also shipped, so you can fork classes if needed
- Zero icon-library dependency — SVGs inlined; override via
icon/childrenprops cmd/ctrl+Kshortcut helper
Install
pnpm add react-cmdk-base @base-ui/react react react-domImport the styles once at your app entry:
import "react-cmdk-base/styles.css";React 18 or 19 is required (declared as a peer dependency).
Publish (maintainers)
# Bump version in package.json, then:
pnpm publishprepublishOnly runs type-check, test, and build first.
Usage
CommandMenu
"use client";
import * as React from "react";
import { House, Cog, Layers } from "lucide-react";
import { CommandMenu, useCmdkShortcut } from "react-cmdk-base";
export function Palette() {
const [open, setOpen] = React.useState(false);
const [page, setPage] = React.useState("root");
useCmdkShortcut(setOpen);
return (
<CommandMenu.Root
open={open}
onOpenChange={setOpen}
page={page}
onPageChange={setPage}
>
<CommandMenu.Input placeholder="Type a command…" />
<CommandMenu.List>
<CommandMenu.Page id="root">
<CommandMenu.Group heading="Home">
<CommandMenu.Item value="home" icon={House} onSelect={() => {}}>
Home
</CommandMenu.Item>
<CommandMenu.Item value="settings" icon={Cog} onSelect={() => {}}>
Settings
</CommandMenu.Item>
<CommandMenu.Item
value="projects"
icon={Layers}
keepOpen
onSelect={() => setPage("projects")}
>
Projects
</CommandMenu.Item>
</CommandMenu.Group>
<CommandMenu.FreeSearch
onSelect={(q) => console.log("search:", q)}
/>
</CommandMenu.Page>
<CommandMenu.Page id="projects" searchPrefix={["Projects"]}>
{/* project items… */}
</CommandMenu.Page>
</CommandMenu.List>
</CommandMenu.Root>
);
}PromptInput
"use client";
import * as React from "react";
import { Globe } from "lucide-react";
import {
PromptInput,
type PromptInputMessage,
type PromptInputStatus,
} from "react-cmdk-base";
const MODELS = [
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "claude-sonnet-4", name: "Claude 4 Sonnet" },
] as const;
export function Composer() {
const [model, setModel] = React.useState<string>("gpt-4o");
const [search, setSearch] = React.useState(false);
const [status, setStatus] = React.useState<PromptInputStatus>("ready");
const handleSubmit = React.useCallback(
async (msg: PromptInputMessage) => {
if (!msg.text.trim() && msg.files.length === 0) return;
setStatus("submitted");
// …send msg.text and msg.files to your backend…
setStatus("ready");
},
[],
);
return (
<PromptInput.Root onSubmit={handleSubmit} multiple status={status}>
<PromptInput.Attachments />
<PromptInput.Body>
<PromptInput.Textarea placeholder="Ask anything…" />
</PromptInput.Body>
<PromptInput.Footer>
<PromptInput.Tools>
<PromptInput.ActionMenu>
<PromptInput.ActionMenuTrigger />
<PromptInput.ActionMenuContent>
<PromptInput.AddAttachments label="Add files" />
<PromptInput.AddScreenshot label="Take screenshot" />
</PromptInput.ActionMenuContent>
</PromptInput.ActionMenu>
<PromptInput.Button
pressed={search}
onClick={() => setSearch((s) => !s)}
tooltip={{ content: "Web search", shortcut: "⌘/" }}
>
<Globe />
<span>Search</span>
</PromptInput.Button>
<PromptInput.ModelSelect value={model} onValueChange={setModel}>
<PromptInput.ModelSelectTrigger
label={MODELS.find((m) => m.id === model)?.name ?? "Model"}
/>
<PromptInput.ModelSelectContent>
{MODELS.map((m) => (
<PromptInput.ModelSelectItem key={m.id} value={m.id}>
{m.name}
</PromptInput.ModelSelectItem>
))}
</PromptInput.ModelSelectContent>
</PromptInput.ModelSelect>
</PromptInput.Tools>
<PromptInput.Submit onStop={() => setStatus("ready")} />
</PromptInput.Footer>
</PromptInput.Root>
);
}Toolbar (recommended for control rows)
Use <PromptInput.Toolbar> whenever you have two or more controls (Add Attachments + Model picker + Submit, etc.). It provides arrow-key roving focus and the WAI-ARIA toolbar role automatically:
import { Toolbar } from "@base-ui/react/toolbar";
import { PromptInput } from "react-cmdk-base";
<PromptInput.Toolbar>
<Toolbar.Button render={<PromptInput.ActionMenuTrigger />} />
<Toolbar.Button render={<PromptInput.ModelSelectTrigger label="GPT-4o" />} />
<PromptInput.Submit />
</PromptInput.Toolbar>For a single control, <PromptInput.Tools> (plain div) is fine — Toolbar without arrow-key navigation is an a11y anti-pattern.
Theming
Both CommandMenu and PromptInput expose CSS custom properties on their root
element. Override them in your own stylesheet (or inline style):
.pi-root {
--pi-accent: oklch(0.62 0.21 264); /* indigo */
--pi-accent-fg: white;
--pi-radius: 1rem;
}
.cmdk-popup {
--cmdk-bg: #fafafa;
--cmdk-accent-bg: rgba(0, 0, 0, 0.06);
}All theme-able properties are declared as CSS custom properties at the top of
each surface block in src/styles.css. The themed surfaces split into inline
(rendered in the document tree) and portaled (mounted at <body>, can't
inherit CSS variables from the logical parent):
- Inline:
.pi-root(--pi-*),.si-root(--si-*) - Portaled:
.cmdk-popup(--cmdk-*),.pi-menu-popup(--pi-menu-*),.pi-tooltip(--pi-tooltip-*),.si-results-panel/.si-picker-popup/.si-tooltip(--si-*, declared together since they share a token shape), plus.si-results-backdropfor the modal variant (--si-backdrop-bg)
Override any --cmdk-* / --pi-* / --pi-menu-* / --pi-tooltip-* /
--si-* custom property to retheme.
Defaults follow the OS color scheme automatically.
Luz theme
An opt-in visual theme that maps CommandMenu to a Spotlight-style toolbar
(always-dark, 20px corners, base-black bg, product-purple-700 focus ring)
and PromptInput to a softer light/dark surface, modeled after the @fs/luz
design system. Pure CSS, no extra JS, no runtime dependency on @fs/luz.
1. Import the overlay alongside the base styles (order matters — overlay must come second):
// Once, at your app entry:
import "react-cmdk-base/styles.css";
import "react-cmdk-base/themes/luz.css";2. Activate by setting data-theme="luz" on any ancestor. In Next.js,
the simplest place is the root layout:
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en" data-theme="luz">
<body>{children}</body>
</html>
);
}Toggling between themes at runtime — e.g. for a settings panel — works by
document.documentElement.setAttribute("data-theme", "luz") inside an
effect. Combine with .dark on the same element to drive the dark variant.
Behaviour:
- Token-overlay only — every value the theme changes is a CSS custom property the base stylesheet already declares. Per-surface overrides documented above continue to work; they win because of the cascade order.
- Portaled surfaces inherit via the ancestor selector.
CommandMenu's popup,PromptInput's action menu / model select popups, and tooltips all portal todocument.body. Settingdata-theme="luz"on<html>(or<body>) ensures every portaled surface is themed. Setting it on a non-ancestor wrapper will theme the inline surfaces but miss the popups. - CommandMenu is always-dark. Spotlight is dark by design;
.dark-mode toggling has no effect on the command menu under this theme. - PromptInput honors
.dark. Add the.darkclass to the same element asdata-theme="luz"(or any ancestor) to switch the prompt surface between luz's light and dark FilterToolbar variants.
Luz palette (Tailwind tokens)
An opt-in companion to the Luz theme overlay. While themes/luz.css
re-skins the library's own surfaces, themes/luz-palette.css
registers the full luz design-system palette as Tailwind v4 @theme
tokens, so your own components can use luz-namespaced utility classes
directly:
<button className="rounded-luz-button bg-luz-product-purple-700 text-luz-base-white shadow-luz-button-secondary hover:bg-luz-product-purple-accent">
Take action
</button>// Once, at your app entry — alongside the base styles:
import "react-cmdk-base/styles.css";
import "react-cmdk-base/themes/luz-palette.css";Activation requires no attribute and no JS — Tailwind v4 reads the
@theme block at compile time and generates utilities only for the
tokens you actually reference in source. Unused tokens incur zero
output cost.
What's registered:
| Namespace | Utility prefix | Example |
| --- | --- | --- |
| --color-luz-* (80 tokens) | bg-luz-, text-luz-, border-luz-, fill-luz-, stroke-luz- | bg-luz-product-purple-700, text-luz-base-gray-dark/80 |
| --radius-luz-* (10 tokens) | rounded-luz-, rounded-t-luz-, rounded-tl-luz-, … | rounded-luz-toolbar, rounded-luz-button |
| --shadow-luz-* (7 tokens) | shadow-luz- | shadow-luz-heavy, shadow-luz-button-secondary |
| --ease-luz-* (1 token) | ease-luz- | ease-luz-button-action |
What's NOT registered (and why):
- Font families — would require font assets the consumer hasn't loaded.
- Spacing — luz uses a 5px-based scale that would conflict with
Tailwind's 4px default if applied to
--spacing. - Breakpoints — luz redefines
--breakpoint-xl: 1440pxwhich would silently shift the consumer'sxl:*breakpoint. - Animations — luz's
@keyframesoverlap with Tailwind defaults.
This file is independent from themes/luz.css. Use one, the other,
both, or neither. Hex values are inlined — no runtime dependency on
@fs/luz. If luz updates their palette, this file is frozen and must
be manually refreshed.
Styling hooks (data-slot)
Every part renders a data-slot="<family>-<part>" attribute on its root DOM
element (e.g. data-slot="command-menu-item", data-slot="prompt-input-submit").
Use these as stable, framework-agnostic selectors when you'd rather target parts
by role than by classname — handy for Tailwind variant selectors, scoped CSS,
or testing. Existing classnames (.cmdk-item, .pi-btn, …) still drive the
shipped styles. Composing wrappers like <PromptInput.ActionMenuTrigger>
produce more specific slots (prompt-input-action-menu-trigger) so you can
target them without ambiguity.
Composition with asChild
Several primitives accept asChild to delegate rendering to a custom element
while preserving the component's behaviour, ARIA, and styles. Supported on
CommandMenu.Item, PromptInput.Button, and PromptInput.Submit.
<PromptInput.Submit asChild>
<MyDesignSystemButton variant="primary">Send</MyDesignSystemButton>
</PromptInput.Submit>
<CommandMenu.Item value="docs" asChild>
<Link href="/docs">Docs</Link>
</CommandMenu.Item>The child must be a single React element. Parent and child event handlers
compose (parent runs first); the parent can call event.preventDefault() to
skip the child's handler. The child's className is appended to the
primitive's own classes; other props from the child win on collision so you
can override e.g. type="button".
API
<CommandMenu.Root>
| prop | type | required | description |
| --- | --- | --- | --- |
| open | boolean | yes | controlled open state |
| onOpenChange | (open: boolean) => void | yes | open callback |
| page | string | no | controlled active page id (defaults to "root") |
| onPageChange | (page: string) => void | no | required to drill down |
| label | string | no | accessible dialog name (visually hidden), default "Command menu" |
| loop | boolean | no | arrow-key wrap, default true |
| filter | (query, label, keywords) => boolean | no | custom matcher; defaults to substring + keyword includes |
<CommandMenu.Item>
| prop | type | description |
| --- | --- | --- |
| value | string | unique id within the active page |
| onSelect | (value: string) => void | fired on Enter or click |
| keepOpen | boolean | leave the menu open after selection (default false) |
| icon | ComponentType<{ className?: string }> | optional leading icon |
| keywords | string[] | extra search terms; "*" matches anything |
| disabled | boolean | aria-disabled and unhighlightable |
| trailing | ReactNode | text/element at the right of the row |
| asChild | boolean | render the child element instead of the default row wrapper. When set, the child's natural accessible name (link text / button text) is preserved unless aria-label is set explicitly on Item |
| forceMount | boolean | render even when the query doesn't match (e.g. "Create new …" actions); doesn't count toward matchCount |
| aria-label | string | override the accessible name and the filter target. For icon-only items, pass to make them reachable by typing the visible/spoken name. asChild branch: only propagates to the child element when explicitly set (empty/whitespace = "no override") |
Other parts
All parts below accept className and any standard HTML attributes for their root element (typically <div>); listed props are the component-specific ones.
<CommandMenu.Input>— search input row with magnifier and breadcrumb chips.placeholder?(default"Search…")<CommandMenu.List>— scrollable container<CommandMenu.Page id searchPrefix?>— drill-down section; only its children render whenpage === id<CommandMenu.Group heading?>— grouped items with a heading (sticky)<CommandMenu.Empty alwaysRender?>— auto-renders when the query is non-empty and zero items match; passalwaysRenderto force.children?(default"No results")<CommandMenu.Loading loading? label?>—role="progressbar"placeholder for async fetches.loading?defaults totrue; passloading={false}to hide<CommandMenu.Separator orientation?>— visual + a11y separator between sections.orientation?defaults to"horizontal"<CommandMenu.FreeSearch label? onSelect?>— convenience item that appears whenever the query is non-empty.label?(default"Search for")<CommandMenu.Footer>— bottom bar (e.g. keyboard hints)<CommandMenu.Kbd>—<kbd>chipuseCommandMenu()— access query, page, popPage, matchCount, filter, etc. inside the menuuseCmdkShortcut(setOpen)— wires cmd/ctrl+K. Callse.stopPropagation()on intercepted shortcuts; invokes the setter as an updater (c => !c), so passing a non-Dispatch(value: boolean) => voidsetter will receivetrue/falseas expected
useCommandMenu()
Returns the command-menu context. Throws if used outside <CommandMenu.Root>.
| field | type | description |
| --- | --- | --- |
| query | string | current input value |
| setQuery | (q: string) => void | imperatively update the query |
| page | string | active page id |
| popPage | () => void | go back one level on the page stack |
| searchPrefix | readonly string[] | breadcrumb chips shown in <Input> |
| matchCount | number | number of items currently matching the query (excludes forceMount items) |
| filter | (query, label, keywords) => boolean | the resolved matcher (Root's filter prop or the default) |
| close | () => void | close the menu (same as onOpenChange(false)) |
Plus internal fields (registerItem, registerMatch, unregisterMatch, fireSelect, setPage, setSearchPrefix) for advanced custom parts. The page-navigation callbacks (setPage, popPage) have stable identity across renders — safe to pass to React.memo'd children.
PromptInput API
<PromptInput.Root>
Renders a <form> and owns text + attachment state. Extends React.FormHTMLAttributes<HTMLFormElement> (so any standard form attribute works) minus onSubmit, onError, and defaultValue which are typed below.
| prop | type | required | description |
| --- | --- | --- | --- |
| onSubmit | (msg: PromptInputMessage, e: FormEvent) => void \| Promise<void> | yes | called on Enter or Submit click. Reject the returned promise to keep user content for retry |
| accept | string | no | hidden file-input accept filter (e.g. "image/*,.pdf") |
| multiple | boolean | no | allow multi-file selection |
| maxFiles | number | no | cap on total attachments; extras fire onError({ code: "max_files" }) |
| maxFileSize | number | no | per-file byte limit; over-size files fire onError({ code: "max_file_size" }) |
| globalDrop | boolean | no | listen for drops on document instead of just the form |
| fileInputName | string | no | name for the hidden file input (only matters if you bypass onSubmit) |
| onError | (err: PromptInputErrorEvent) => void | no | fired once per addFiles call when any file is rejected |
| value | string | no | controlled textarea value |
| onValueChange | (value: string) => void | no | text change callback |
| defaultValue | string | no | uncontrolled initial textarea value |
| label | string | no | accessible name for the form (default "Prompt input") |
| status | PromptInputStatus | no | one of ready / submitted / streaming / error; submitted/streaming block Enter |
| collapsible | boolean | no | opt in to the collapsible state (default false) |
| collapsed | boolean | no | controlled collapsed state |
| defaultCollapsed | boolean | no | uncontrolled initial collapsed state (default true when collapsible) |
| onCollapsedChange | (collapsed: boolean) => void | no | fires when the collapsed state should change |
Collapsible state
Opt into a single-row composer that animates open on hover or focus. Useful for floating prompts and persistent toolbars where you don't want the textarea taking up vertical space until the user engages.
<PromptInput.Root onSubmit={handleSubmit} collapsible>
<PromptInput.Body>
<PromptInput.Textarea />
</PromptInput.Body>
<PromptInput.Footer>
<PromptInput.Tools>{/* action menu, model select, etc. */}</PromptInput.Tools>
<PromptInput.Submit />
</PromptInput.Footer>
</PromptInput.Root>The composition is identical to the standard layout — Submit stays
nested inside Footer. When the prompt is collapsed, Footer switches
to display: contents so its children become row siblings of Body.
Tools (and Header and Attachments) get the hidden HTML attribute,
so screen readers and tab navigation skip them; Submit remains visible
in the single-row layout. Transitions are CSS-only and respect
prefers-reduced-motion.
The Root element exposes two data attributes for styling hooks:
data-collapsible=""— present whenevercollapsibleis ondata-state="expanded" | "collapsed"— the current resolved state
Triggers (each one calls onCollapsedChange; in controlled mode the
caller decides whether to honor it):
pointerenteron the root → expand.focusin(textarea or any descendant) → expand.pointerleave(after ~150ms debounce) → collapse, if text is empty, no attachments, status is notsubmitted/streaming, and nothing is focus-within.Escapewhile the textarea is focused → collapse + blur, same emptiness check. Escape originating from other descendants (open menus, buttons) is ignored so overlay dismissals don't also collapse the prompt.
Use usePromptInput() to read collapsed or call setCollapsed() from
custom children.
<PromptInput.Submit>
Status-aware button. The default icon changes with status (Send → Spinner → Stop → close-glyph); pass children to override. Extends React.ButtonHTMLAttributes<HTMLButtonElement>.
| prop | type | description |
| --- | --- | --- |
| status | PromptInputStatus | override the context status (defaults to ctx.status) |
| onStop | () => void | called when clicked during submitted/streaming (and onStop is set), instead of submitting. When onStop is absent, the click is a no-op during those statuses — submission is already blocked at the form level |
| asChild | boolean | render the child element via Slot |
Renders a data-status="<status>" attribute and a status-specific aria-label ("Send message" / "Submitting" / "Stop generating" / "Retry") for testing + styling hooks. Sets type="submit" by default and type="button" during submitted/streaming when onStop is wired (so the click handler runs instead of the form submitting).
<PromptInput.Button>
Toolbar button. Extends React.ButtonHTMLAttributes<HTMLButtonElement>. Defaults type="button" (skipped when asChild is used so the child's type is preserved). Sets data-variant={variant} on the rendered element for styling hooks.
| prop | type | description |
| --- | --- | --- |
| variant | "ghost" \| "default" | visual variant (default "ghost") |
| pressed | boolean | toggles data-pressed + aria-pressed (use for Search-style toggle buttons) |
| asChild | boolean | render the child element via Slot |
| tooltip | string \| { content; shortcut?; side? } | shorthand to wrap in <PromptInput.Tooltip> |
Any data-slot value passed via props lands directly on the rendered <button> element — wrapper components (such as PromptInput.Picker's trigger) override the default prompt-input-button slot to advertise their own.
<PromptInput.Textarea>
Auto-grow textarea via field-sizing: content, capped between 4rem and 12rem. Extends React.TextareaHTMLAttributes<HTMLTextAreaElement> minus value/onChange — text state is fully owned by Root; any consumer-supplied value / defaultValue is ignored. Hard-codes rows={1} so collapsed/empty heights render correctly. Defaults aria-label to the Root's label prop ("Prompt input").
| prop | type | description |
| --- | --- | --- |
| placeholder | string | default "What would you like to know?" |
Behaviour: Enter submits (IME-safe; suppressed when status is submitted or streaming), Shift+Enter inserts newline, Backspace on empty removes the last attachment (only when not auto-repeating), paste with files in the clipboard adds them as attachments.
<PromptInput.Tooltip>
Wrap a single child in a Base UI Tooltip. The shared Tooltip.Provider is auto-mounted by <PromptInput.Root>, so adjacent tooltips skip the open-delay. The positioner uses a fixed sideOffset of 6px (not configurable).
| prop | type | description |
| --- | --- | --- |
| content | ReactNode | tooltip body |
| shortcut | string | optional muted shortcut hint (e.g. "⌘↵") |
| side | "top" \| "right" \| "bottom" \| "left" | positioning side (default "top") |
| className | string | applied to Tooltip.Popup (the surface) |
| children | ReactElement | single trigger element |
<PromptInput.ActionMenu> and sub-parts
Wraps Base UI's Menu. Used for the "+" affordance.
<PromptInput.ActionMenu>— passes through toMenu.Root(controlled/uncontrolled open viaopen/onOpenChange)<PromptInput.ActionMenuTrigger>— uses<PromptInput.Button>as the trigger; defaults to a+icon andaria-label="Open actions"<PromptInput.ActionMenuContent>— popup container;align?: "start" | "center" | "end",side?,sideOffset?(defaultalign="start",side="top",sideOffset={8}), pluscollisionAvoidance?,collisionPadding?,sticky?pass-through to Base UI'sMenu.Positioner(see Base UI docs)<PromptInput.ActionMenuItem>—keepOpen?: booleankeeps the menu open after click (default closes)<PromptInput.AddAttachments>— built-inMenu.Itemthat opens the file dialog;label?(default"Add files"),icon?: ReactNode(default paperclip)<PromptInput.AddScreenshot>— built-inMenu.Itemthat callsnavigator.mediaDevices.getDisplayMedia, draws the captured frame to a canvas, and pushes the PNG as an attachment. Silently swallowsNotAllowedError/AbortError.label?(default"Take screenshot"),icon?: ReactNode(default monitor)
<PromptInput.ModelSelect> and sub-parts
Wraps Base UI's Menu. Used as a lightweight model picker.
<PromptInput.ModelSelect value? onValueChange?>— controlled value selection. IfonValueChangeis omitted, selecting an item logs a dev warning.<PromptInput.ModelSelectTrigger label?>—<PromptInput.Button>-based trigger.labelis rendered as the chip text. Defaultsaria-label="Model".<PromptInput.ModelSelectContent>— popup container;align?(default"end"),side?(default"top"),sideOffset?(default8), pluscollisionAvoidance?,collisionPadding?,sticky?pass-through to Base UI'sMenu.Positioner.<PromptInput.ModelSelectItem value>—role="menuitemradio"witharia-checked; selecting it callsonValueChange(value)after any consumeronClick.
PromptInput.Picker
Generic single-value picker built on Base UI's Select primitive. Use this when the popup is purely "pick one value from a known list" — items announce as option inside a listbox (the WAI-ARIA-correct pattern for selection). For a popup that mixes selection with arbitrary action items, use PromptInput.ModelSelect instead.
import { PromptInput } from "react-cmdk-base";
<PromptInput.Picker defaultValue="gpt-4o">
<PromptInput.PickerTrigger aria-label="Model" label="GPT-4o" />
<PromptInput.PickerContent aria-label="Model">
<PromptInput.PickerGroup>
<PromptInput.PickerGroupLabel>OpenAI</PromptInput.PickerGroupLabel>
<PromptInput.PickerItem value="gpt-4o">GPT-4o</PromptInput.PickerItem>
</PromptInput.PickerGroup>
<PromptInput.PickerGroup>
<PromptInput.PickerGroupLabel>Anthropic</PromptInput.PickerGroupLabel>
<PromptInput.PickerItem value="claude">Claude</PromptInput.PickerItem>
</PromptInput.PickerGroup>
</PromptInput.PickerContent>
</PromptInput.Picker>Trigger display: Three options for what shows in the trigger:
- Manual — pass
labelonPickerTrigger:<PickerTrigger label="GPT-4o" />. The label is rendered verbatim and does NOT auto-update with the selected item. For controlled state, drivelabelfrom yourvalue-to-display-name map. - Auto-derive via
itemsprop on Picker — omitlabel/childrenand passitems={[{ value, label }, …]}on<PromptInput.Picker>.<Select.Value />then auto-derives the display label from the selected item. - Auto-derive via Select.Value's render-prop —
<PickerTrigger><Select.Value>{(v) => LABELS[v] ?? v}</Select.Value></PickerTrigger>. Lightest workaround when you want auto-update without restructuring to theitemsprop.
⚠️ With JSX-child items and no
labelprop,<Select.Value />serializes the raw value (e.g."gpt-4o"instead of"GPT-4o"). Use one of the three patterns above.
Modal default:
Pickeris modal by default (locks page scroll, blocks outside clicks). When nesting inside another modal (Dialog, CommandMenu, etc.), passmodal={false}on<PromptInput.Picker>.
Key differences from ModelSelect:
| | PromptInput.ModelSelect (Menu) | PromptInput.Picker (Select) |
|---|---|---|
| Base primitive | Menu | Select |
| Popup ARIA role | menu | listbox |
| Item ARIA role | menuitemradio | option |
| defaultValue | not supported | ✓ supported |
| Native form submission | no | ✓ via name/form |
| Auto-display in trigger | manual label prop | ✓ via Select.Value + items prop |
| Group support | no | ✓ Group + GroupLabel |
| Mixed selection + action items | ✓ supported | not supported (every item must be an option) |
API:
<PromptInput.Picker {...selectRootProps}>— passes through allSelect.Rootprops:value/defaultValue/onValueChange,open/defaultOpen/onOpenChange,disabled,name/form,multiple,items,modal(defaulttrue). See Base UI Select.Root.<PromptInput.PickerTrigger label? aria-label?>—PromptInput.Button-based trigger.aria-labeldefaults to"Picker". See the three trigger-display options above.<PromptInput.PickerContent align? side? sideOffset? collisionAvoidance? collisionPadding? sticky? aria-label?>— popup container. Defaultsalign="end",side="top",sideOffset={8}. Thearia-labelis applied to the innerSelect.List(the listbox).<PromptInput.PickerItem value disabled?>—role="option". Selecting firesSelect.Root.onValueChange(value)and closes the popup. Renders a check icon when selected.<PromptInput.PickerGroup>/<PromptInput.PickerGroupLabel>— labeled grouping for related options.<PromptInput.PickerSeparator>— visual + a11y separator between items or groups (new in 0.10.0). Renders a 1px divider using the popup's border token.
Native form submission:
When name is set on <PromptInput.Picker>, the current selected value is included in the form's submission via a hidden <input>:
<form onSubmit={(e) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
console.log(data.get("model")); // "gpt-4o"
}}>
<PromptInput.Picker name="model" defaultValue="gpt-4o">
<PromptInput.PickerTrigger aria-label="Model" label="GPT-4o" />
<PromptInput.PickerContent aria-label="Model">
<PromptInput.PickerItem value="gpt-4o">GPT-4o</PromptInput.PickerItem>
<PromptInput.PickerItem value="claude">Claude</PromptInput.PickerItem>
</PromptInput.PickerContent>
</PromptInput.Picker>
<button type="submit">Send</button>
</form>⚠️ With
nameset but nodefaultValue(orvalue), an unsubmitted Picker contributes an empty-string entry — indistinguishable from a deliberate selection of an empty-string option. Pairnamewith eitherdefaultValueor your own validation if the distinction matters.
<PromptInput.Attachments>
Chip row that renders the current attachments. Auto-hides (via the hidden HTML attribute) when the parent <Root collapsible> is in its collapsed state. Extends React.HTMLAttributes<HTMLDivElement> (so className, style, and any standard div attribute work) plus:
| prop | type | description |
| --- | --- | --- |
| alwaysRender | boolean | render an empty row even with zero attachments (default false → returns null) |
Each chip shows an image thumbnail (for image/* files), filename, size, and a remove <button> with aria-label="Remove <filename>".
<PromptInput.Body> / <PromptInput.Header> / <PromptInput.Footer> / <PromptInput.Tools>
Styled <div> wrappers, all accepting className and any standard HTML div attributes:
<Body>— textarea container (flex column). Plain.<Header>— wrap-flex container intended for above-the-textarea custom content. Auto-hides (via thehiddenattribute) when the parent<Root collapsible>is collapsed.<Footer>— bottom bar withToolson the left andSubmiton the right. Switches todisplay: contentsin the collapsed state soSubmitbecomes a sibling ofBody.<Tools>— left-aligned button cluster inside Footer. Auto-hides when the parent<Root collapsible>is collapsed (same mechanism asHeader).
usePromptInput()
Returns the prompt-input context. Throws if used outside <PromptInput.Root>.
| field | type | description |
| --- | --- | --- |
| text | string | current textarea value |
| setText | (v: string) => void | imperatively update text (calls onValueChange) |
| attachments | PromptInputAttachment[] | current files |
| addFiles | (files: File[] \| FileList) => void | append files (applies accept/maxFiles/maxFileSize, fires onError) |
| removeFile | (id: string) => void | remove by attachment id |
| clearFiles | () => void | clear all and revoke object URLs |
| openFileDialog | () => void | trigger the hidden file input |
| status | PromptInputStatus | current status from Root |
| label | string | accessible form label |
| collapsible | boolean | whether Root was rendered with collapsible |
| collapsed | boolean | current collapsed state (always false when not collapsible) |
| setCollapsed | (next: boolean) => void | request a collapsed-state change; honors controlled/uncontrolled |
Exported types
| type | shape |
| --- | --- |
| PromptInputStatus | "ready" \| "submitted" \| "streaming" \| "error" |
| PromptInputMessage | { text: string; files: PromptInputAttachment[] } |
| PromptInputAttachment | { id; filename; mediaType; size; url; file: File } |
| PromptInputErrorEvent | { code: "max_files" \| "max_file_size" \| "accept"; message: string } |
| PromptInputButtonVariant | "ghost" \| "default" |
Additionally, every component's Props type is exported (PromptInputRootProps, PromptInputSubmitProps, PromptInputModelSelectProps, etc.), as is PromptInputContextValue (the return shape of usePromptInput()) and CommandMenuRootProps / CommandMenuItemProps / etc. for the command-menu side. These exist for consumers that need to write wrapper components or forwardRef adapters.
SearchInput
SearchInput is the third public namespace. It combines:
- The collapsible single-row look of
PromptInput(one line by default; expands on hover/focus to show controls). - The live filter-as-you-type results experience of
CommandMenu(Pages, Groups, Items, Empty, Loading, keyboard nav, drill-down, and the sameCommandCoreProviderunderneath).
Results open as you type by default (mode="live"). Pass mode="submit" to preserve the 0.11.x submit-only model where results only appear after pressing Enter or the Submit button.
Two anchored result variants are provided: SearchInput.ResultsInline (no backdrop — the page stays fully interactive behind the panel) and SearchInput.ResultsModal (adds a dimmed Combobox.Backdrop and sets modal=true on the Combobox, which aria-hides and makes inert everything outside the popup via FloatingFocusManager). The input retains real DOM focus in both variants; keyboard navigation uses aria-activedescendant on the listbox.
Quick start
import { SearchInput, type SearchInputMessage } from "react-cmdk-base";
function Header() {
const [selectedValue, setSelectedValue] = React.useState<string | null>(null);
const handleSubmit = async (msg: SearchInputMessage) => {
// msg.query — live input; msg.selectedValue — persistent selection; msg.scope
const res = await fetch(`/api/search?q=${encodeURIComponent(msg.query)}`);
// …consumer surfaces results into Page/Group/Item
};
return (
<SearchInput.Root onSubmit={handleSubmit} selectedValue={selectedValue} onSelectedValueChange={setSelectedValue}>
<SearchInput.Input placeholder="Search…" />
<SearchInput.Submit />
<SearchInput.ResultsInline>
<SearchInput.Page id="root">
<SearchInput.Group heading="Docs">
<SearchInput.Item value="useState" onSelect={(v) => router.push(`/docs/${v}`)}>
<SearchInput.ItemLabel>useState</SearchInput.ItemLabel>
</SearchInput.Item>
</SearchInput.Group>
<SearchInput.Empty>No results.</SearchInput.Empty>
</SearchInput.Page>
</SearchInput.ResultsInline>
</SearchInput.Root>
);
}Parts
| Part | Purpose |
|---|---|
| SearchInput.Root | <form role="search">. Owns query, selectedValue, mode, status, scope, collapsed, resultsOpen, and page. Exposes a formRef to anchor the results panel. No committedQuery. |
| SearchInput.Input | <input type="search" role="combobox"> with aria-expanded / aria-controls / aria-haspopup="listbox". In live mode, typing opens and filters the panel. Clears selectedValue on backspace-to-empty. Calls mutePanel() on Escape. |
| SearchInput.Submit | Status-aware submit button. Swaps to a Stop icon while status="streaming". In live mode, disabled when query is empty AND selectedValue is null. In submit mode, disabled when query is empty. |
| SearchInput.Button | Plain icon button used internally by Picker / asChild slots. Variants (ghost / default), pressed, tooltip. |
| SearchInput.Tools | Plain-<div> controls container. Hidden + inert + aria-hidden when collapsed. |
| SearchInput.Toolbar | WAI-ARIA role="toolbar" controls container with arrow-key roving focus. Hidden + inert + aria-hidden when collapsed. |
| SearchInput.Tooltip | Wraps a single button in a Base UI Tooltip. Requires a <Tooltip.Provider> from @base-ui/react/tooltip — SearchInput.Root does NOT include one. |
| SearchInput.Picker (+ Trigger / Content / Item / Group / GroupLabel / Separator) | Optional scope selector built on Base UI Select. Wire value / onValueChange through to <Root scope onScopeChange> to feed scope into the SearchInputMessage. |
| SearchInput.ResultsInline | Anchored panel with no backdrop. The Combobox is not modal — the rest of the page stays interactive. Uses Combobox.Portal + Combobox.Positioner + Combobox.Popup. |
| SearchInput.ResultsModal | Anchored panel with a dimmed Combobox.Backdrop and modal=true. FloatingFocusManager aria-hides + inerts everything outside the popup. The form's Submit button is not clickable while the panel is open — dismiss first (Escape, click backdrop, or select an item). |
| SearchInput.ResultsShell | Low-level shell exported for consumers building custom variants. Wraps Combobox.Portal + Combobox.Positioner + Combobox.Popup + optional Backdrop. |
| SearchInput.Page (+ Group / Item / Empty / Loading / Separator / FreeSearch) | CommandMenu-style result parts. Mirror the existing CommandMenu API, just renamed. |
| SearchInput.ItemLabel | Child slot that seeds the Item's accessibleName fallback, filter target, and (in live mode) the selection write-back string. Falls back to getLabelFromChildren when absent. |
| useSearchInput() | Hook exposing query, selectedValue, setSelectedValue, highlighted, mode, status, scope, collapsed, resultsOpen, submit(), and refs/ids. |
Behavior
- Live mode (default). Typing opens and filters the panel as you type. The panel mounts when the input is focused, query is non-empty, match count is > 0, and the panel is not muted.
mode="live"is the default. - Submit mode. Pass
mode="submit"to restore the 0.11.x model: typing does NOT open the popup; only Enter or the Submit button does. - Enter on a highlighted item. Fires
onSelect, writes the item's display label into the input (viaitemToStringLabel+handleItemSelect), setsselectedValue, closes the panel, and resets the page (unlesskeepOpen). - Enter when nothing is highlighted. Fires Submit — equivalent to clicking the Submit button.
- Backspace to empty. Clears
selectedValue. Typing after a selection does NOT clear it — only backspace-to-empty, programmatic null, or a consumer-provided clear button does. - Modal contract. When using
ResultsModal, the Comboboxmodal=truesetting aria-hides and inerts everything outside the popup, including the form. To interact with the form's Submit button, dismiss the panel first (Escape, click the backdrop, or select an item). - Drill-down. Use
keepOpenon items to navigate sub-pages without closing the panel. Drivepageas a controlled prop on Root. Backspace on an empty input inside the popup pops the page stack. - Collapsible row. Hover or focus expands the row to show Tools / Toolbar. Blurring + 150ms grace + empty input collapses again. Escape on an empty input also collapses.
- Status flow.
idle | submitted | streaming | error. The Submit button reflects the status and switches to a Stop affordance while in flight (whenonStopis provided). - A11y.
<form role="search">,<input role="combobox">witharia-expanded/aria-controls/aria-haspopup="listbox". The input retains real DOM focus; keyboard navigation in the panel usesaria-activedescendant. Collapsed controls getaria-hidden+inert. The modal variant addsCombobox.Backdropand hands focus management to FloatingFocusManager.
Upgrading
0.9.0 → 0.10.x
- Type-level breaking change:
PromptInputButtonProps,PromptInputSubmitProps, andPromptInputRootPropsno longer declarerefin the interface — the ref now arrives viaReact.forwardRef's second arg. Consumers that destructuredreffrom these prop types should remove the destructure. Passingref={x}in JSX is unchanged. - The Slot composition rule for
undefinedchild props is now scoped to event handlers only: writing<button disabled={undefined}>inside aSlotclears the prop (parent'sdisabledno longer wins for non-event props). Event handlers retain the parent-wins-when-child-undefined semantics. CommandMenucontext'ssearchPrefixis nowreadonly string[](wasstring[]). Code that calledsearchPrefix.push(...)on the context value will now be a type error — clone first.
Upgrading 0.10.x → 0.11.0
- No breaking changes. SearchInput is purely additive —
CommandMenuandPromptInputconsumers see no API drift. - Internally,
CommandMenunow consumes a sharedinternal/command-core/primitive. If your code imports fromsrc/lib/contextorsrc/hooks/use-command-menudirectly (not part of the public API), the modules still re-export the same types — but consider switching to the publicuseCommandMenuhook fromreact-cmdk-base. - New optional exports:
SearchInputnamespace,useSearchInput()hook, and per-part exports for tree-shaking. - The
CommandCoreProvidergained a new optionaldefaultQueryprop used internally bySearchInput.Resultsto seed the popup's filter fromcommittedQuery. CommandMenu consumers don't need to pass it.
Migrating from albingroen/[email protected]
This is a breaking rewrite — no compat shim is provided. Sketch of the changes:
| v1 | v2 |
| --- | --- |
| <CommandPalette> | <CommandMenu.Root> |
| isOpen / onChangeOpen | open / onOpenChange |
| search, onChangeSearch | managed internally |
| <CommandPalette.List heading> | <CommandMenu.Group heading> |
| <CommandPalette.ListItem index onClick href> | <CommandMenu.Item value onSelect> — or render an anchor/link directly via asChild: <CommandMenu.Item asChild value="docs"><Link href="/docs">Docs</Link></CommandMenu.Item> |
| filterItems, getItemIndex, renderJsonStructure | removed (filtering is internal) |
| icon: "HomeIcon" (string) | icon={HomeIcon} (component) |
| heroicons + headlessui deps | dropped — bring your own icons |
Repo layout
src/— library source (src/themes/holds opt-in theme overlays such asluz.css)dist/— published JS/TS artefacts (index.js,index.d.ts)styles.css,themes/luz.css,themes/luz-palette.css— published CSS artefacts at the package roottests/— Vitest + RTL test suiteapp/— Next.js prototype demonstratingCommandMenu(/),PromptInput(/prompt), and theluztheme (/luz)
License
MIT
