Command
SourceA command palette: a query field over a ranked list of commands with full keyboard navigation, grouped sections, and breadcrumb drill-in. Built on a combobox/aria-activedescendant core so the input keeps focus while a virtual cursor moves the active row. Ranking is owned by the design system — rank your commands with the exported rankItems and render the survivors as Command.Items — and fully escapable when you need your own order.
A complete palette
Everything at once, the way you'd ship it: a modal launcher over grouped, fuzzy-ranked sections, each row an icon · label · shortcut, with Switch network… and Transfer assets… drilling into sub-pages (Backspace or the breadcrumb steps back out). Open it from the trigger and type to watch the panel re-rank and resize.
"use client";
import { Icon } from "@stridge/noctis";
import { Button } from "@stridge/noctis/button";
import { Command, rankItems } from "@stridge/noctis/command";
import type { CommandPage } from "@stridge/noctis/command";
import { Kbd } from "@stridge/noctis/kbd";
import {
ArrowDownToLine,
ArrowLeftRight,
ArrowUpFromLine,
BookOpen,
Coins,
Globe,
type LucideIcon,
Plus,
RefreshCw,
RotateCw,
Search,
Send,
Wallet,
Webhook,
XCircle,
} from "lucide-react";
import { useMemo, useState } from "react";
interface Cmd {
value: string;
label: string;
icon: LucideIcon;
/** The section a root command groups under (sub-page rows render flat, so they omit it). */
section?: string;
shortcut?: string;
/** When set, selecting the command drills into a sub-page instead of running. */
page?: CommandPage;
keywords?: string[];
}
/** Section order for the root list — empty sections drop out after ranking. */
const SECTIONS = ["Addresses", "Vaults", "Transfers", "Developer", "Navigation"];
const ROOT: Cmd[] = [
{
value: "new-address",
label: "Create universal address",
icon: Plus,
section: "Addresses",
shortcut: "C",
keywords: ["uda", "deposit"],
},
{ value: "refresh-addresses", label: "Refresh addresses", icon: RefreshCw, section: "Addresses" },
{ value: "deposit", label: "Deposit to vault", icon: ArrowDownToLine, section: "Vaults", shortcut: "D" },
{ value: "withdraw", label: "Withdraw from vault", icon: ArrowUpFromLine, section: "Vaults", keywords: ["payout"] },
{ value: "network", label: "Switch network…", icon: Globe, section: "Vaults", page: { id: "network", label: "Network" } },
{
value: "transfer",
label: "Transfer assets…",
icon: Send,
section: "Transfers",
shortcut: "T",
page: { id: "transfer", label: "Destination" },
},
{ value: "reject", label: "Reject payout", icon: XCircle, section: "Transfers", keywords: ["decline"] },
{
value: "rotate",
label: "Rotate API key",
icon: RotateCw,
section: "Developer",
shortcut: "Mod+R",
keywords: ["secret", "token"],
},
{ value: "endpoint", label: "Add webhook endpoint", icon: Webhook, section: "Developer" },
{ value: "test-event", label: "Send test event", icon: Send, section: "Developer" },
{ value: "go-settlements", label: "Go to settlements", icon: ArrowLeftRight, section: "Navigation", shortcut: "G S" },
{ value: "docs", label: "Open documentation", icon: BookOpen, section: "Navigation", shortcut: "G D" },
];
const SUBPAGES: Record<string, Cmd[]> = {
network: [
{ value: "ethereum", label: "Ethereum", icon: Globe },
{ value: "polygon", label: "Polygon", icon: Globe },
{ value: "base", label: "Base", icon: Globe },
{ value: "arbitrum", label: "Arbitrum", icon: Globe },
],
transfer: [
{ value: "treasury", label: "Treasury · USDC", icon: Wallet },
{ value: "operations", label: "Operations · USDC", icon: Wallet },
{ value: "cold", label: "Cold storage · ETH", icon: Coins },
],
};
export default function CommandAdvanced() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [pages, setPages] = useState<CommandPage[]>([]);
const currentId = pages[pages.length - 1]?.id;
const items = useMemo(() => (currentId ? (SUBPAGES[currentId] ?? []) : ROOT), [currentId]);
const ranked = useMemo(() => rankItems(items, query), [items, query]);
// At the root the rows are grouped by section; inside a sub-page they render as one flat list.
const sections = useMemo(
() =>
SECTIONS.map((label) => ({ label, rows: ranked.filter((row) => row.section === label) })).filter(
(s) => s.rows.length > 0,
),
[ranked],
);
const onSelect = (value: string) => {
const item = items.find((candidate) => candidate.value === value);
if (item?.page) {
setPages((stack) => [...stack, item.page!]);
setQuery("");
return;
}
// A leaf command runs its action; this demo just closes.
setOpen(false);
};
const renderRow = (command: Cmd) => (
<Command.Item key={command.value} value={command.value} onSelect={onSelect}>
<Command.ItemIcon>
<Icon icon={command.icon} />
</Command.ItemIcon>
<Command.ItemLabel>{command.label}</Command.ItemLabel>
{command.shortcut ? <Kbd keys={command.shortcut} /> : null}
</Command.Item>
);
return (
<>
<Command.Trigger open={open} onOpenChange={setOpen} render={<Button variant="outline" />}>
<Icon icon={Search} />
Search commands
<Kbd keys="Mod+K" />
</Command.Trigger>
<Command.Dialog
open={open}
onOpenChange={setOpen}
value={query}
onValueChange={setQuery}
pages={pages}
onPagesChange={(next) => setPages([...next])}
>
<Command.Header>
<Command.Breadcrumb />
<Icon icon={Search} />
<Command.Input aria-label="Command" placeholder="Type a command or search…" />
</Command.Header>
<Command.List>
{currentId
? ranked.map(renderRow)
: sections.map((section) => (
<Command.Group key={section.label}>
<Command.GroupLabel>{section.label}</Command.GroupLabel>
{section.rows.map(renderRow)}
</Command.Group>
))}
</Command.List>
{ranked.length === 0 && <Command.Empty>No commands found.</Command.Empty>}
{/* The footer is a bare region — compose hints however you like (here, a small flex row). */}
<Command.Footer>
<span className="inline-flex items-center gap-1.5">
<Kbd keys="Up" />
<Kbd keys="Down" />
Navigate
</span>
<span className="inline-flex items-center gap-1.5">
<Kbd keys="Enter" />
Select
</span>
<span className="inline-flex items-center gap-1.5">
<Kbd keys="Backspace" />
Back
</span>
</Command.Footer>
</Command.Dialog>
</>
);
}
Modal
The batteries-included launcher. Command.Dialog is a focus-trapped modal (portal, blurred backdrop, scroll-lock, Esc to close); a headless Command.Trigger wires open-state and ARIA onto your own button. Wire the same open/onOpenChange to both. The footer carries keyboard hints. In your own app you'd bind a global ⌘K to open it (omitted in these docs so the demos don't fight this site's own ⌘K palette).
"use client";
import { Icon } from "@stridge/noctis";
import { Button } from "@stridge/noctis/button";
import { Command, rankItems } from "@stridge/noctis/command";
import { Kbd } from "@stridge/noctis/kbd";
import { Search } from "lucide-react";
import { useMemo, useState } from "react";
const COMMANDS = [
{ value: "address", label: "Create universal address", keywords: ["uda", "deposit"] },
{ value: "transfer", label: "Transfer assets" },
{ value: "withdraw", label: "Withdraw from vault" },
{ value: "rotate", label: "Rotate API key", keywords: ["secret", "token"] },
{ value: "docs", label: "Open documentation" },
];
export default function CommandModal() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const ranked = useMemo(() => rankItems(COMMANDS, query), [query]);
// In a real app you'd bind a global ⌘K to `setOpen` — omitted here so this demo doesn't fight the
// docs site's own ⌘K palette. The trigger button (and the `Mod+K` cap on it) shows the convention.
return (
<>
<Command.Trigger open={open} onOpenChange={setOpen} render={<Button variant="outline" />}>
Open command palette
<Kbd keys="Mod+K" />
</Command.Trigger>
<Command.Dialog open={open} onOpenChange={setOpen} value={query} onValueChange={setQuery}>
<Command.Header>
<Icon icon={Search} />
<Command.Input aria-label="Command" placeholder="Type a command…" />
</Command.Header>
<Command.List>
{ranked.map((command) => (
<Command.Item key={command.value} value={command.value} onSelect={() => setOpen(false)}>
<Command.ItemLabel>{command.label}</Command.ItemLabel>
</Command.Item>
))}
</Command.List>
{ranked.length === 0 && <Command.Empty>No commands found.</Command.Empty>}
<Command.Footer>
<span className="inline-flex items-center gap-1.5">
<Kbd keys="Up" />
<Kbd keys="Down" />
Navigate
</span>
<span className="inline-flex items-center gap-1.5">
<Kbd keys="Enter" />
Select
</span>
</Command.Footer>
</Command.Dialog>
</>
);
}
Inline
Command.Root embeds the same palette in normal flow — a page, a sidebar, a settings panel — with no modal chrome. It shares every inner part with the dialog.
"use client";
import { Icon } from "@stridge/noctis";
import { Command, rankItems } from "@stridge/noctis/command";
import { Search } from "lucide-react";
import { useMemo, useState } from "react";
const COMMANDS = [
{ value: "address", label: "Create universal address", keywords: ["uda", "deposit"] },
{ value: "transfer", label: "Transfer assets" },
{ value: "withdraw", label: "Withdraw from vault" },
{ value: "endpoint", label: "Add webhook endpoint", keywords: ["webhook"] },
{ value: "settlement", label: "Copy settlement ID", keywords: ["copy"] },
];
export default function CommandInline() {
const [query, setQuery] = useState("");
const ranked = useMemo(() => rankItems(COMMANDS, query), [query]);
return (
<Command.Root value={query} onValueChange={setQuery} className="w-full max-w-md">
<Command.Header>
<Icon icon={Search} />
<Command.Input aria-label="Command" placeholder="Type a command or search…" />
</Command.Header>
<Command.List>
{ranked.map((command) => (
<Command.Item key={command.value} value={command.value}>
<Command.ItemLabel>{command.label}</Command.ItemLabel>
</Command.Item>
))}
</Command.List>
{ranked.length === 0 && <Command.Empty>No commands found.</Command.Empty>}
</Command.Root>
);
}
Rows with icons
Lead each row with a Command.ItemIcon wrapping an Icon — a glyph column that quiets to the muted role and lines up under the input's search glyph. The icon is the only opinion the row takes; everything after Command.ItemLabel is yours. Drop a Kbd straight in and the label's flex pushes it to the row's end, so the row reads icon · label · keys, left to right.
"use client";
import { Icon } from "@stridge/noctis";
import { Command, rankItems } from "@stridge/noctis/command";
import { Kbd } from "@stridge/noctis/kbd";
import { ArrowDownToLine, ArrowLeftRight, BookOpen, Copy, type LucideIcon, Plus, RotateCw, Search } from "lucide-react";
import { useMemo, useState } from "react";
interface IconCommand {
value: string;
label: string;
icon: LucideIcon;
shortcut?: string;
keywords?: string[];
}
const COMMANDS: IconCommand[] = [
{ value: "address", label: "Create universal address", icon: Plus, shortcut: "C", keywords: ["uda", "deposit"] },
{ value: "transfer", label: "Transfer assets", icon: ArrowLeftRight, shortcut: "T" },
{ value: "withdraw", label: "Withdraw from vault", icon: ArrowDownToLine, shortcut: "W", keywords: ["payout"] },
{ value: "rotate", label: "Rotate API key", icon: RotateCw, shortcut: "Mod+R", keywords: ["secret"] },
{ value: "settlement", label: "Copy settlement ID", icon: Copy, shortcut: "Mod+C" },
{ value: "docs", label: "Open documentation", icon: BookOpen },
];
export default function CommandIcons() {
const [query, setQuery] = useState("");
const ranked = useMemo(() => rankItems(COMMANDS, query), [query]);
return (
<Command.Root value={query} onValueChange={setQuery} className="w-full max-w-md">
<Command.Header>
<Icon icon={Search} />
<Command.Input aria-label="Command" placeholder="Type a command…" />
</Command.Header>
<Command.List>
{ranked.map((command) => (
<Command.Item key={command.value} value={command.value}>
<Command.ItemIcon>
<Icon icon={command.icon} />
</Command.ItemIcon>
<Command.ItemLabel>{command.label}</Command.ItemLabel>
{command.shortcut ? <Kbd keys={command.shortcut} /> : null}
</Command.Item>
))}
</Command.List>
{ranked.length === 0 && <Command.Empty>No commands found.</Command.Empty>}
</Command.Root>
);
}
Sections and shortcuts
Group rows with Command.Group and a Command.GroupLabel heading. For a keyboard hint, put a Kbd after the label — the component has no "shortcut" part, so you compose the caps yourself: single keys, chords (Alt+C), and sequences (G then I) all render exactly how you format them. Rank within each group and drop empty sections.
"use client";
import { Icon } from "@stridge/noctis";
import { Command, rankItems } from "@stridge/noctis/command";
import { Kbd } from "@stridge/noctis/kbd";
import { Search } from "lucide-react";
import { useMemo, useState } from "react";
interface GroupedCommand {
value: string;
label: string;
shortcut?: string;
keywords?: string[];
}
const GROUPS: { id: string; label: string; items: GroupedCommand[] }[] = [
{
id: "addresses",
label: "Addresses",
items: [
{ value: "new-address", label: "Create universal address", shortcut: "C", keywords: ["uda"] },
{ value: "refresh-addresses", label: "Refresh addresses", shortcut: "Mod+R" },
],
},
{
id: "vaults",
label: "Vaults",
items: [
{ value: "deposit", label: "Deposit to vault", shortcut: "D" },
{ value: "withdraw", label: "Withdraw from vault", keywords: ["payout"] },
],
},
{
id: "navigation",
label: "Navigation",
items: [
{ value: "go-settlements", label: "Go to settlements", shortcut: "G S" },
{ value: "go-wallets", label: "Go to wallets", shortcut: "G W" },
],
},
];
export default function CommandGrouped() {
const [query, setQuery] = useState("");
const sections = useMemo(
() => GROUPS.map((group) => ({ ...group, ranked: rankItems(group.items, query) })).filter((g) => g.ranked.length > 0),
[query],
);
return (
<Command.Root value={query} onValueChange={setQuery} className="w-full max-w-md">
<Command.Header>
<Icon icon={Search} />
<Command.Input aria-label="Command" placeholder="Type a command…" />
</Command.Header>
<Command.List>
{sections.map((group) => (
<Command.Group key={group.id}>
<Command.GroupLabel>{group.label}</Command.GroupLabel>
{group.ranked.map((command) => (
<Command.Item key={command.value} value={command.value}>
<Command.ItemLabel>{command.label}</Command.ItemLabel>
{/* The label's flex pushes any trailing content to the row's end — drop a Kbd straight in. */}
{command.shortcut ? <Kbd keys={command.shortcut} /> : null}
</Command.Item>
))}
</Command.Group>
))}
</Command.List>
{sections.length === 0 && <Command.Empty>No commands found.</Command.Empty>}
</Command.Root>
);
}
Drill-in
Selecting a parent command pushes a page: the list swaps to its children with a fresh, empty query, and a Command.Breadcrumb segment appears. Backspace on the empty query — or clicking an earlier segment — pops back out and restores the query you had typed at that level (each view keeps its own). The pages stack is controlled, so you drive which children load (including async).
"use client";
import { Icon } from "@stridge/noctis";
import { Command, rankItems } from "@stridge/noctis/command";
import type { CommandPage } from "@stridge/noctis/command";
import { Search } from "lucide-react";
import { useMemo, useState } from "react";
interface Item {
value: string;
label: string;
/** When set, selecting the item drills into a sub-page instead of running an action. */
page?: CommandPage;
}
const ROOT: Item[] = [
{ value: "network", label: "Switch network…", page: { id: "network", label: "Network" } },
{ value: "asset", label: "Filter by asset…", page: { id: "asset", label: "Asset" } },
{ value: "refresh", label: "Refresh wallet" },
{ value: "export", label: "Export transactions" },
];
const SUBPAGES: Record<string, Item[]> = {
network: [
{ value: "ethereum", label: "Ethereum" },
{ value: "polygon", label: "Polygon" },
{ value: "base", label: "Base" },
{ value: "arbitrum", label: "Arbitrum" },
],
asset: [
{ value: "usdc", label: "USDC" },
{ value: "usdt", label: "USDT" },
{ value: "eth", label: "ETH" },
],
};
export default function CommandDrilldown() {
const [query, setQuery] = useState("");
const [pages, setPages] = useState<CommandPage[]>([]);
const currentId = pages[pages.length - 1]?.id;
const items = useMemo(() => (currentId ? (SUBPAGES[currentId] ?? []) : ROOT), [currentId]);
const ranked = useMemo(() => rankItems(items, query), [items, query]);
const onSelect = (value: string) => {
const item = items.find((candidate) => candidate.value === value);
if (item?.page) {
setPages((stack) => [...stack, item.page!]);
}
// A leaf selection would run its action here; this demo just drills in.
};
return (
<Command.Root
value={query}
onValueChange={setQuery}
pages={pages}
onPagesChange={(next) => setPages([...next])}
className="w-full max-w-md"
>
<Command.Header>
<Command.Breadcrumb />
<Icon icon={Search} />
<Command.Input aria-label="Command" placeholder="Type a command…" />
</Command.Header>
<Command.List>
{ranked.map((command) => (
<Command.Item key={command.value} value={command.value} onSelect={onSelect}>
<Command.ItemLabel>{command.label}</Command.ItemLabel>
</Command.Item>
))}
</Command.List>
{ranked.length === 0 && <Command.Empty>No results.</Command.Empty>}
</Command.Root>
);
}
Ranking
The palette ships a fuzzy scorer tuned for command bars: a contiguous prefix beats a gapped match, a word-boundary hit beats one mid-word, and keywords let a row answer to hidden aliases. rankItems(items, query) returns the filtered, ordered survivors — feed it your command list and map the result to Command.Items. Blend in usage recency through a per-item boost, or pass your own score function (or sorted list) to take over ordering entirely. For very large lists, useCommandRanking offloads scoring to a Web Worker with a synchronous fallback.
Looping
Arrow-key navigation stops at the ends of the list by default — a command bar shouldn't quietly jump you from the last row back to the first. Pass loop to opt into cycling: ArrowDown past the last row wraps to the first (through the input, per the ARIA combobox pattern) and ArrowUp from the first wraps to the last.
"use client";
import { Icon } from "@stridge/noctis";
import { Command, rankItems } from "@stridge/noctis/command";
import { Search } from "lucide-react";
import { useMemo, useState } from "react";
const COMMANDS = [
{ value: "address", label: "Create universal address" },
{ value: "transfer", label: "Transfer assets" },
{ value: "withdraw", label: "Withdraw from vault" },
{ value: "settlement", label: "Refresh settlements" },
{ value: "docs", label: "Open documentation" },
];
export default function CommandLooping() {
const [query, setQuery] = useState("");
const ranked = useMemo(() => rankItems(COMMANDS, query), [query]);
// `loop` opts into cycling: ArrowDown past the last row wraps to the first (and ArrowUp the other
// way). Off by default, where the highlight stops at the ends.
return (
<Command.Root loop value={query} onValueChange={setQuery} className="w-full max-w-md">
<Command.Header>
<Icon icon={Search} />
<Command.Input aria-label="Command" placeholder="Hold ArrowDown past the last row…" />
</Command.Header>
<Command.List>
{ranked.map((command) => (
<Command.Item key={command.value} value={command.value}>
<Command.ItemLabel>{command.label}</Command.ItemLabel>
</Command.Item>
))}
</Command.List>
{ranked.length === 0 && <Command.Empty>No commands found.</Command.Empty>}
</Command.Root>
);
}
Theme scope
ThemeScope re-tunes a whole region's shape — corner radius, density, and type scale — through the cascade, with no per-component prop and without touching the app-wide theme. Tweak the knobs below, then open the command palette: the floating panel re-tunes too, even though it portals to <body>, because the scope rides a portal-safe context. See Customization for nesting, reset, and the raw-token portal caveat.
"use client";
import { DENSITY_PRESETS, FONT_SCALE_PRESETS, Icon, RADIUS_PRESETS, ThemeScope } from "@stridge/noctis";
import { Button } from "@stridge/noctis/button";
import { Command, rankItems } from "@stridge/noctis/command";
import { Kbd } from "@stridge/noctis/kbd";
import { Select } from "@stridge/noctis/select";
import { Search } from "lucide-react";
import { useMemo, useState } from "react";
const COMMANDS = [
{ value: "address", label: "Create universal address", keywords: ["uda", "deposit"] },
{ value: "transfer", label: "Transfer assets" },
{ value: "withdraw", label: "Withdraw from vault" },
{ value: "rotate", label: "Rotate API key", keywords: ["secret", "token"] },
{ value: "docs", label: "Open documentation" },
];
/** Title-case a preset key for its select label. */
const label = (key: string) => key.charAt(0).toUpperCase() + key.slice(1);
/** One labelled preset picker — its options are a shipped `ThemeScope` preset map, so the playground's
* vocab is exactly what the primitive accepts. */
function Knob({
name,
presets,
value,
onChange,
}: {
name: string;
presets: Record<string, number>;
value: string;
onChange: (value: string) => void;
}) {
const keys = Object.keys(presets);
const items = Object.fromEntries(keys.map((key) => [key, label(key)]));
return (
<label className="flex flex-col gap-1.5">
<span className="text-mini font-medium text-subtle">{name}</span>
<Select.Root items={items} value={value} onValueChange={(next) => onChange(String(next))}>
<Select.Trigger aria-label={name} className="w-40">
<Select.Value />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
{keys.map((key) => (
<Select.Item key={key} value={key}>
{label(key)}
</Select.Item>
))}
</Select.Popup>
</Select.Root>
</label>
);
}
/**
* A live `ThemeScope` playground for a floating component: the three seed knobs drive a scope wrapping
* the command launcher. Open the palette and the modal re-tunes too — corners, spacing, and text size —
* even though it portals out to `<body>`, because the scope rides a portal-safe context, not just the
* DOM cascade.
*/
export default function CommandThemeScope() {
const [radius, setRadius] = useState<keyof typeof RADIUS_PRESETS>("pill");
const [density, setDensity] = useState<keyof typeof DENSITY_PRESETS>("default");
const [fontScale, setFontScale] = useState<keyof typeof FONT_SCALE_PRESETS>("default");
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const ranked = useMemo(() => rankItems(COMMANDS, query), [query]);
return (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap gap-3">
<Knob name="Radius" presets={RADIUS_PRESETS} value={radius} onChange={(v) => setRadius(v as typeof radius)} />
<Knob
name="Density"
presets={DENSITY_PRESETS}
value={density}
onChange={(v) => setDensity(v as typeof density)}
/>
<Knob
name="Font scale"
presets={FONT_SCALE_PRESETS}
value={fontScale}
onChange={(v) => setFontScale(v as typeof fontScale)}
/>
</div>
<div className="rounded-md border border-dashed border-border p-4">
<ThemeScope radius={radius} density={density} fontScale={fontScale}>
<Command.Trigger open={open} onOpenChange={setOpen} render={<Button variant="outline" />}>
Open command palette
<Kbd keys="Mod+K" />
</Command.Trigger>
<Command.Dialog open={open} onOpenChange={setOpen} value={query} onValueChange={setQuery}>
<Command.Header>
<Icon icon={Search} />
<Command.Input aria-label="Command" placeholder="Type a command…" />
</Command.Header>
<Command.List>
{ranked.map((command) => (
<Command.Item key={command.value} value={command.value} onSelect={() => setOpen(false)}>
<Command.ItemLabel>{command.label}</Command.ItemLabel>
</Command.Item>
))}
</Command.List>
{ranked.length === 0 && <Command.Empty>No commands found.</Command.Empty>}
</Command.Dialog>
</ThemeScope>
</div>
</div>
);
}
Motion
The panel is auto-height: it grows and shrinks with its list as you type, so the palette glides between sizes instead of snapping. A ResizeObserver measures the rendered rows and feeds the height into the list, which tweens its block-size; once the panel reaches its cap the list scrolls past it behind a thin scrollbar. Reaching the first or last row scrolls it to the very edge — the list's inner padding (and a group label above the first row) stays in view rather than the row sitting cropped against the edge. Drilling into a sub-page cross-fades the new view in as the old one gives way. The animations honour prefers-reduced-motion.
Keyboard
The input keeps focus throughout; a virtual cursor (aria-activedescendant) moves the active row.
| Key | Action |
|---|---|
| Type | Filters and re-ranks; the highlight follows the same row, dropping to the top result only when that row is filtered out |
| Down arrow Up arrow | Move the active row by one |
| CommandDown arrow CommandUp arrow | Jump to the last / first row (AltDown arrow / AltUp arrow too) |
| Home End | Jump to the first / last row |
| PageUp PageDown | Move by a page of rows |
| Controlp Controln | Move up / down, emacs-style (Controlk / Controlj too) |
| Enter | Activate the highlighted command |
| Backspace | On an empty query, step back out of a drill-in page |
| Escape | Close the modal palette |
| Commandk | Open the palette (wire it yourself, as the modal example does) |
By default the active row stops at the ends of the list; pass loop to wrap (a held arrow still stops at the edge — only a fresh press wraps). See Looping.
Accessibility
The input is a role="combobox" that keeps DOM focus; the list is a role="listbox" and each row a role="option". A virtual cursor moves aria-activedescendant to the active row, so screen readers announce it without focus ever leaving the field — the WAI-ARIA pattern for a command palette. The modal is named by a visually-hidden title (label, defaulting to the localized command.label string), traps focus, and locks scroll. Render Command.Empty (a polite live region) when your ranked list is empty so the no-results state is announced. The headless Command.Trigger stamps aria-haspopup="dialog" and reflects aria-expanded. RTL is structural — logical properties throughout.
Anatomy
Compose the palette from its parts; both Command.Dialog and Command.Root share the same inner set.
Command.Dialog— the modal launcher (portal, backdrop, focus trap). Takesopen/onOpenChange, the controlled query, and thepagesstack.Command.Root— the inline panel; same props, no modal chrome.Command.Trigger— a headless launcher that wiresonClick+ ARIA onto your own element viarender.Command.Header— the input row; holds aCommand.Breadcrumb, a leading glyph, theCommand.Input, and an optionalCommand.InputAction.Command.Breadcrumb— the drill-in trail; renders thepagesstack and pops it.Command.Input— the query field; Backspace on empty pops a page.Command.InputAction— a trailing affordance slot in the input row.Command.List— the scrolling listbox.Command.Group/Command.GroupLabel— a labelled section and its heading.Command.Item— a command row, activated by click or Enter (onSelect); compose aCommand.ItemIconand aCommand.ItemLabel, then any trailing content (aKbd, a badge) which the label pushes to the row's end.Command.Separator— a divider between sections.Command.Empty/Command.Loading— the no-results and async-progress messages.Command.Footer— a bare footer region; compose your own status or hints inside.
Every rendered part carries a data-slot (noctis-command on the panel, noctis-command-item on a row, …) for host-side styling, off which the precompiled command.css keys each rule.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every palette in that region retunes. See Customization for the override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table.