Command Palette
A real <dialog>, opened with showModal(), the same reasoning as Dialog: a native top-layer stacking context, a native focus trap, and native Escape-to-close, none of it reimplemented in JavaScript. It's controlled, not self-triggering, you decide what opens it, a button, a keyboard shortcut, whatever, and it stays generic: it takes a flat list of items, it doesn't know anything about your app's navigation or commands. Open and close fade the panel and backdrop with a slight scale + settle by default — Escape and backdrop dismiss wait for the exit transition, same as Dialog — andprefers-reduced-motion collapses that to a short opacity fade.
Usage
import { useState } from "react";
import { Button, CommandPalette } from "@kernelui-lib/react";
import type { CommandPaletteItem } from "@kernelui-lib/react";
function App() {
const [open, setOpen] = useState(false);
const items: CommandPaletteItem[] = [
{ id: "new-file", label: "New file", onSelect: () => {} },
{ id: "save", label: "Save", description: "Save the current file", onSelect: () => {} },
];
return (
<>
<Button onClick={() => setOpen(true)}>Open command palette</Button>
<CommandPalette open={open} onOpenChange={setOpen} items={items} />
</>
);
}Playground
Toggle every adjustable prop and watch the component — and the code — update live.
Same component — Kernel look stripped. See Platforms.
Usage
<Button onClick={() => setOpen(true)}>Open command palette</Button>
<CommandPalette
open={open}
onOpenChange={setOpen}
items={items}
placeholder="Filter commands"
emptyMessage="No results"
/><kernel-button id="open-palette">Open command palette</kernel-button>
<kernel-command-palette id="palette" placeholder="Filter commands" empty-message="No results"></kernel-command-palette>
<script type="module">
const palette = document.getElementById("palette");
palette.items = [
{ id: "new-file", label: "New file", onSelect: () => {} },
{ id: "save", label: "Save", description: "Save the current file", onSelect: () => {} },
];
document.getElementById("open-palette").addEventListener("click", () => {
palette.setAttribute("open", "");
});
</script>Grouping & custom rendering
Pass groups instead of a flat items array to section the results under labelled headers, "Recent", "File", a danger zone, whatever fits your commands. Filtering, Arrow Up/Down, and aria-activedescendant all operate across the flattened result set. renderItem replaces an item's default label/description with anything you want, given the item, whether it's active, and which group (if any) it belongs to.
Usage
import { useState } from "react";
import { CommandPalette } from "@kernelui-lib/react";
import type { CommandPaletteGroup, CommandPaletteItem } from "@kernelui-lib/react";
const groups: CommandPaletteGroup[] = [
{ id: "recent", label: "Recent", items: [
{ id: "save", label: "Save", onSelect: () => {} },
] },
{ id: "file", label: "File", items: [
{ id: "new-file", label: "New file", onSelect: () => {} },
] },
{ id: "danger", label: "Danger zone", items: [
{ id: "delete", label: "Delete", onSelect: () => {} },
] },
];
function renderItem(item: CommandPaletteItem, state: { group?: CommandPaletteGroup }) {
return (
<div style={{ color: state.group?.id === "danger" ? "var(--kernel-color-danger)" : undefined }}>
{item.label}
</div>
);
}
function App() {
const [open, setOpen] = useState(false);
return (
<CommandPalette
open={open}
onOpenChange={setOpen}
groups={groups}
renderItem={renderItem}
/>
);
}Composable async results
The compound API keeps one focused input while your product owns the result model. This example combines controlled query state, external filtering, grouped lesson and section rows, active render state, disabled results, loading, empty, error, and asynchronous replacement.
Course search
Find a lesson or jump straight to a matching section.
Usage
import { CommandPalette, Combobox } from "@kernelui-lib/react";
<CommandPalette open={open} onOpenChange={setOpen} shouldFilter={false}>
<CommandPalette.Input value={query} onValueChange={setQuery} />
<CommandPalette.List>
{isLoading && <CommandPalette.Loading>Searching...</CommandPalette.Loading>}
{!isLoading && error && <CommandPalette.Empty>{error}</CommandPalette.Empty>}
<CommandPalette.Group heading="Engineering Track">
<CommandPalette.Item id="lesson-flexbox" value={lesson.searchText} onSelect={goToLesson}>
{({ active }) => <LessonRow lesson={lesson} active={active} />}
</CommandPalette.Item>
<CommandPalette.Item id="section-flexbox-layout" value={section.searchText} onSelect={goToSection}>
{({ active }) => <SectionRow section={section} active={active} />}
</CommandPalette.Item>
</CommandPalette.Group>
</CommandPalette.List>
</CommandPalette>
Props
| Prop | Type | Default |
|---|---|---|
open | boolean | — |
onOpenChange | (open: boolean) => void | — |
items | CommandPaletteItem[] | — |
groups | { id, label?, items: CommandPaletteItem[] }[] | — |
renderItem | (item, { active, group?, index }) => ReactNode | — |
placeholder | string | "Filter commands" |
emptyMessage | ReactNode | "No results" |
blur | boolean | false |
CommandPaletteItem: { id, label, description?, onSelect }. Provide either items or groups — when both are set, groups wins. Each group needs a unique id; a group without a label renders its items without a header.
Accessibility
- Follows the WAI-ARIA combobox pattern: the filter input is a real
role="combobox", focus never leaves it, andaria-activedescendantpoints at whicheverrole="option"is highlighted. - Arrow Up/Down move the active option, clamped at the ends. Enter selects it and closes the palette. Escape closes it too, natively, it isn't handled twice.
- Filtering is a case-insensitive substring match against each item's label, live as you type.
- Clicking the
::backdropcloses the dialog. - Grouped items are wrapped in
role="group", labelled viaaria-labelledbyby arole="presentation"header, so assistive tech announces which group an item belongs to.