Command menu
A searchable command surface with keyboard navigation and native modal isolation.
Live preview
Adjust a setting to update the preview and usage code together.
Usage & source
"use client";
import type { CSSProperties } from "react";
import { CommandMenu } from "./components/stepkit/CommandMenu";
import "./components/stepkit/styles.css";
const themeStyle = {"--stepkit-bg":"#f7f7f8","--stepkit-surface":"#ffffff","--stepkit-surface-muted":"#f0f0f3","--stepkit-fg":"#17181c","--stepkit-muted":"#626572","--stepkit-border":"#e3e4e9","--stepkit-border-strong":"#c8cad3","--stepkit-code":"#f1f1f4","--stepkit-shadow":"0 20px 60px rgba(31, 26, 64, .10)","--stepkit-accent":"#6950df","--stepkit-accent-strong":"#5137c7","--stepkit-accent-soft":"#eeeafd","--stepkit-accent-on-soft":"#5137c7","--stepkit-accent-text":"#ffffff","--stepkit-error":"#b43145","--stepkit-placeholder":"#626572","--stepkit-radius":"16px","--stepkit-space":"16px","--stepkit-control-height":"44px","--stepkit-font-sans":"Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif"} as CSSProperties;
const items = [{ id: "new", label: "Create workspace", description: "Start with a clean surface" }, { id: "docs", label: "Open documentation" }];
export default function Example() {
return (
<div className="stepkit-theme" data-stepkit-preset="modern" style={themeStyle}>
<CommandMenu
items={items}
triggerLabel={"Open command menu"}
placeholder={"Search commands…"}
/>
</div>
);
}Dependencies
[email protected] [email protected]
/*
* Stepkit 0.2.0
* Copyright (c) 2026 Vladislav Stepanov
*
* Original Stepkit portions in this release are licensed under the MIT License. Adapted portions identify their upstream licence and copyright in the copied source and in `NOTICE-MAGICUI.md` where applicable.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
"use client";
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { Button } from "./Button";
import "./styles.css";
export interface CommandItem { id: string; label: string; description?: string; shortcut?: string; icon?: ReactNode; disabled?: boolean; }
export interface CommandMenuProps { items: CommandItem[]; triggerLabel?: string; placeholder?: string; onSelect?: (item: CommandItem) => void; }
export function CommandMenu({ items, triggerLabel = "Open command menu", placeholder = "Search commands…", onSelect }: CommandMenuProps) {
const dialogRef = useRef<HTMLDialogElement>(null); const inputRef = useRef<HTMLInputElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const titleId = useId(); const listId = useId();
const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [selected, setSelected] = useState(0);
const filtered = useMemo(() => items.filter((item) => `${item.label} ${item.description ?? ""}`.toLowerCase().includes(query.toLowerCase())), [items, query]);
const selectable = useMemo(() => filtered.filter((item) => !item.disabled), [filtered]);
useEffect(() => { const node = dialogRef.current; if (!node) return; if (open && !node.open) { node.showModal(); requestAnimationFrame(() => inputRef.current?.focus()); } if (!open && node.open) node.close(); }, [open]);
useEffect(() => { setSelected(0); }, [query]);
const choose = (item: CommandItem | undefined) => { if (!item || item.disabled) return; onSelect?.(item); setOpen(false); setQuery(""); };
const move = (delta: number) => setSelected((current) => selectable.length ? (current + delta + selectable.length) % selectable.length : 0);
return <div className="stepkit stepkit-command">
<Button ref={triggerRef} label={triggerLabel} variant="soft" onClick={() => setOpen(true)} />
<dialog ref={dialogRef} className="stepkit stepkit-command__dialog" aria-labelledby={titleId} onCancel={(event) => { event.preventDefault(); setOpen(false); }} onClose={() => triggerRef.current?.focus()}>
<h2 className="stepkit-sr-only" id={titleId}>Command menu</h2>
<input ref={inputRef} className="stepkit-command__input" value={query} placeholder={placeholder} aria-label={placeholder} role="combobox" aria-controls={listId} aria-expanded="true" aria-activedescendant={selectable[selected] ? `${listId}-${selectable[selected].id}` : undefined} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); move(1); } if (event.key === "ArrowUp") { event.preventDefault(); move(-1); } if (event.key === "Enter") { event.preventDefault(); choose(selectable[selected]); } }} />
{filtered.length ? <ul className="stepkit-command__list" id={listId} role="listbox">{filtered.map((item) => <li key={item.id}><button className="stepkit-command__item" id={`${listId}-${item.id}`} type="button" role="option" aria-selected={item.id === selectable[selected]?.id} disabled={item.disabled} onMouseEnter={() => { const index = selectable.findIndex((candidate) => candidate.id === item.id); if (index >= 0) setSelected(index); }} onClick={() => choose(item)}><span>{item.icon ? <span aria-hidden="true">{item.icon} </span> : null}<strong>{item.label}</strong>{item.description ? <small>{item.description}</small> : null}</span>{item.shortcut ? <span className="stepkit-command__shortcut">{item.shortcut}</span> : null}</button></li>)}</ul> : <p className="stepkit-command__empty">No commands match this search.</p>}
</dialog>
</div>;
}
export default CommandMenu;
components/CommandMenu.tsxcomponents/Button.tsxcomponents/styles.cssInstall
Use the released registry entry or download the complete archive.
npx [email protected] add https://vstepanov.com/stepkit/r/command-menu.jsonIntegration prompt
Install Stepkit Command menu 0.2.0 from the registry and preserve the included MIT notice. npx [email protected] add https://vstepanov.com/stepkit/v0.2.0/r/command-menu.json Install the tested runtime dependencies exactly: npm install [email protected] [email protected] Keep the component source, shared styles, NOTICE.md, and provenance files together. Apply the theme tokens on a wrapper rather than passing theme keys as component props. Usage: ```tsx "use client"; import type { CSSProperties } from "react"; import { CommandMenu } from "./components/stepkit/CommandMenu"; import "./components/stepkit/styles.css"; const themeStyle = {"--stepkit-bg":"#f7f7f8","--stepkit-surface":"#ffffff","--stepkit-surface-muted":"#f0f0f3","--stepkit-fg":"#17181c","--stepkit-muted":"#626572","--stepkit-border":"#e3e4e9","--stepkit-border-strong":"#c8cad3","--stepkit-code":"#f1f1f4","--stepkit-shadow":"0 20px 60px rgba(31, 26, 64, .10)","--stepkit-accent":"#6950df","--stepkit-accent-strong":"#5137c7","--stepkit-accent-soft":"#eeeafd","--stepkit-accent-on-soft":"#5137c7","--stepkit-accent-text":"#ffffff","--stepkit-error":"#b43145","--stepkit-placeholder":"#626572","--stepkit-radius":"16px","--stepkit-space":"16px","--stepkit-control-height":"44px","--stepkit-font-sans":"Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif"} as CSSProperties; const items = [{ id: "new", label: "Create workspace", description: "Start with a clean surface" }, { id: "docs", label: "Open documentation" }]; export default function Example() { return ( <div className="stepkit-theme" data-stepkit-preset="modern" style={themeStyle}> <CommandMenu items={items} triggerLabel={"Open command menu"} placeholder={"Search commands…"} /> </div> ); } ``` Selected bounded settings: {"triggerLabel":"Open command menu","placeholder":"Search commands…"}. Selected theme: {"mode":"light","accent":"violet","radius":"soft","density":"comfortable","preset":"modern"}.
Props
| Name | Type | Default | Description |
|---|---|---|---|
items | CommandItem[] | required | Searchable commands. |
onSelect | (item: CommandItem) => void | undefined | Called after selection. |
Accessibility & limits
- Uses a combobox, listbox, and option relationship.
- Arrow keys skip disabled options and Enter selects the highlighted command.
- Escape closes the native dialog.
Limitations
- The command menu does not install a global keyboard shortcut.
Provenance
Original source under the MIT licence.
Original Stepkit implementation.
Required notices: NOTICE.md, PROVENANCE-stepkit-command-menu.md
- 3 source files in the release
- Version 0.2.0