icon

Morainev0.5.0

CommandPalette
navigationcommand-palette

CommandPalette

Searchable command list with groups, keyboard navigation, and customizable rendering.

Import#

import { CommandPalette, Dialog } from 'moraine'

Slot Structure#

CommandPalette renders a searchable grouped list with keyboard navigation, active-descendant semantics, and optional footer content. Compose it with Dialog when it should appear in an overlay; the caller owns the trigger, open state, positioning, and closing behavior.

Input area#

root
└── inputWrapper
├── search (IconButton, optional)
├── input
└── close (IconButton, optional)

Results#

root
├── listbox
│ ├── item (×n)
│ │ ├── itemLeading (optional)
│ │ ├── itemWrapper
│ │ │ ├── itemLabel
│ │ │ └── itemDescription (optional, bottom or trailing inside itemLabel)
│ │ └── itemTrailing (optional custom metadata)
│ └── group (×n, optional)
│ └── label (optional)
├── empty (optional, no matches)
└── footer (optional)

Examples#

Basic#

Compose Dialog around CommandPalette and put the trigger in Dialog’s children. Handle the selected item with CommandPalette’s onSelect callback; enabled selections request closing by default through onClose.

function Usage() {
const [open, setOpen] = createSignal(false)
return (
<Dialog
open={open()}
onOpenChange={setOpen}
close={false}
classes={{ body: 'p-0 mb-0' }}
body={
<CommandPalette
groups={GROUPS}
showClose
onClose={() => setOpen(false)}
footerRender={() => (
<div class="flex gap-4 items-center justify-between">
<div class="flex flex-wrap gap-3 items-center">
<div class="flex gap-2 items-center">
<KbdGroup items={['↑', '↓']} />
<span class="text-xs">Navigate</span>
</div>
<div class="flex gap-2 items-center">
<Kbd value="↵" />
<span class="text-xs">Open</span>
</div>
</div>
<div class="flex gap-2 items-center">
<Kbd value="Esc" />
<span class="text-xs">Close</span>
</div>
</div>
)}
/>
}
>
<Button variant="outline" trailing={<KbdGroup items={['⌘', 'K']} />}>
Search...
</Button>
</Dialog>
)
}

Real-World Example#

Drive the palette from the same shortcut map you use across the app, and render those bindings with item.trailingRender plus Kbd so the UI and keyboard behavior stay aligned.

function RealWorldExample() {
const [open, setOpen] = createSignal(false)
const [isMac, setIsMac] = createSignal(true)
const [lastAction, setLastAction] = createSignal(
'Ready. Open the palette from the trigger below.',
)
const modifierLabel = createMemo(() => (isMac() ? '⌘' : 'Ctrl'))
const groups = createMemo<CommandPaletteT.Group<AppCommand>[]>(() => [
{
id: 'jump-to',
label: 'Jump to',
items: [
{
value: 'open-project-switcher',
label: 'Open project switcher',
description: 'Jump between projects, teams, and recent workspaces.',
leadingRender: () => <Icon name="i-lucide-search-check" />,
trailingRender: () => (
<KbdGroup
items={[modifierLabel(), 'Shift', 'P']}
size="sm"
class="text-muted-foreground"
/>
),
binding: { key: 'p', shiftKey: true },
result: 'Opened the project switcher.',
},
{
value: 'go-issues',
label: 'Go to issues',
description: 'Open the active team issue board.',
leadingRender: () => <Icon name="i-lucide-circle-dot" />,
trailingRender: () => (
<KbdGroup items={[modifierLabel(), 'I']} size="sm" class="text-muted-foreground" />
),
binding: { key: 'i' },
result: 'Navigated to the issue board.',
},
],
},
{
id: 'workspace',
label: 'Workspace',
items: [
{
value: 'new-issue',
label: 'Create issue',
description: 'Capture a bug or task without leaving the current page.',
leadingRender: () => <Icon name="i-lucide-file-plus-2" />,
trailingRender: () => (
<KbdGroup items={[modifierLabel(), 'N']} size="sm" class="text-muted-foreground" />
),
binding: { key: 'n' },
result: 'Created a new issue draft.',
},
{
value: 'toggle-sidebar',
label: 'Toggle sidebar',
description: 'Collapse navigation to focus on the current editor.',
leadingRender: () => <Icon name="i-lucide-panel-left-close" />,
trailingRender: () => (
<KbdGroup items={[modifierLabel(), 'B']} size="sm" class="text-muted-foreground" />
),
binding: { key: 'b' },
result: 'Toggled the workspace sidebar.',
},
{
value: 'open-billing',
label: 'Open billing',
description: 'Restricted to workspace owners.',
leadingRender: () => <Icon name="i-lucide-credit-card" />,
disabled: true,
trailingRender: () => <span class="text-xs text-muted-foreground">Owner only</span>,
},
],
},
])
const commands = createMemo(() => groups().flatMap((group) => group.items ?? []))
const onSelect = (item: AppCommand) => {
setLastAction(item.result ?? `Selected ${item.label ?? item.value}.`)
setOpen(false)
}
onMount(() => {
setIsMac(/Mac|iPhone|iPad/.test(window.navigator.platform))
const handler = (event: KeyboardEvent) => {
if (isTypingTarget(event.target)) {
return
}
const matchedCommand = commands().find(
(item) => item.binding && !item.disabled && matchesBinding(event, item.binding),
)
if (!matchedCommand) {
return
}
event.preventDefault()
onSelect(matchedCommand)
}
window.addEventListener('keydown', handler)
onCleanup(() => window.removeEventListener('keydown', handler))
})
return (
<div class="flex flex-col gap-3 max-w-full w-xl">
<div class="p-3 border border-border rounded-lg bg-muted/20">
<p class="text-sm font-medium">Global shortcuts and palette commands stay in sync.</p>
<p class="text-sm text-muted-foreground mt-1">{lastAction()}</p>
</div>
<Dialog
open={open()}
onOpenChange={setOpen}
close={false}
classes={{ body: 'p-0 mb-0' }}
body={
<CommandPalette<AppCommand>
groups={groups()}
showClose
onSelect={onSelect}
onClose={() => setOpen(false)}
footerRender={() => (
<div class="flex flex-wrap gap-3 items-center justify-between">
<div class="flex flex-wrap gap-3 items-center">
<span class="flex gap-2 items-center">
<KbdGroup items={['↑', '↓']} size="sm" />
<span class="text-xs">Navigate</span>
</span>
<span class="flex gap-2 items-center">
<Kbd value="↵" size="sm" />
<span class="text-xs">Run command</span>
</span>
</div>
</div>
)}
/>
}
>
<Button variant="outline">Search projects, issues, and actions</Button>
</Dialog>
</div>
)
}

Custom Item Render#

Use itemRender when the row layout needs richer metadata than the default label, description, leadingRender, and trailingRender item render hooks.

function CustomItemRender() {
const [open, setOpen] = createSignal(false)
return (
<div class="max-w-full w-lg">
<Dialog
open={open()}
onOpenChange={setOpen}
close={false}
classes={{ body: 'p-0 mb-0' }}
body={
<CommandPalette<TeamCommand>
groups={GROUPS}
onClose={() => setOpen(false)}
itemRender={(ctx) => (
<div class="flex flex-1 gap-3 min-w-0 items-center">
<Icon name="i-lucide-folder-kanban text-muted-foreground shrink-0" />
<span class="flex flex-1 flex-col min-w-0">
<span class="text-sm font-medium truncate">{ctx.item.label}</span>
<span class="text-xs text-muted-foreground truncate">
{ctx.item.owner} · {ctx.item.description}
</span>
</span>
<Badge variant={ctx.item.status === 'ready' ? 'default' : 'outline'}>
{ctx.item.status}
</Badge>
</div>
)}
/>
}
>
<Button variant="outline">Open project switcher</Button>
</Dialog>
</div>
)
}

Description Position#

Set descriptionPosition="trailing" to keep descriptions inline near the label while reserving item.trailingRender for badges, shortcuts, or other metadata.

function DescriptionPosition() {
const [open, setOpen] = createSignal(false)
return (
<div class="max-w-full w-lg">
<Dialog
open={open()}
onOpenChange={setOpen}
close={false}
classes={{ body: 'p-0 mb-0' }}
body={
<CommandPalette
groups={GROUPS}
descriptionPosition="trailing"
onClose={() => setOpen(false)}
/>
}
>
<Button variant="outline">Open navigation</Button>
</Dialog>
</div>
)
}

Custom Empty State#

Override the default ‘No results.’ message.

function CustomEmptyState() {
const [open, setOpen] = createSignal(false)
return (
<div class="max-w-full w-lg">
<Dialog
open={open()}
onOpenChange={setOpen}
close={false}
classes={{ body: 'p-0 mb-0' }}
body={
<CommandPalette
groups={[]}
emptyRender={() => (
<span class="flex flex-col gap-2 items-center">
<Icon name="i-lucide-search-x" class="text-muted-foreground size-6" />
<span class="text-foreground font-medium">No commands found</span>
<span class="text-xs">Try a different keyword or clear the search.</span>
</span>
)}
/>
}
>
<Button variant="outline">Open palette</Button>
</Dialog>
</div>
)
}

Loading#

Search icon becomes a spinner while loading.

function Loading() {
const [open, setOpen] = createSignal(false)
const BASIC_GROUPS: CommandPaletteT.Group[] = [
{
id: 'workspace',
label: 'Workspace',
items: [
{
value: 'new-issue',
label: 'New Issue',
leadingRender: () => <Icon name="i-lucide-circle-plus" />,
trailingRender: () => <KbdGroup items={['⌘', 'N']} />,
},
{
value: 'open-inbox',
label: 'Open Inbox',
leadingRender: () => <Icon name="i-lucide-inbox" />,
trailingRender: () => <KbdGroup items={['⌘', 'I']} />,
},
{
value: 'sync-roadmap',
label: 'Sync Roadmap',
leadingRender: () => <Icon name="i-lucide-refresh-cw" />,
description: 'Pull the latest planning updates',
},
],
},
]
return (
<div class="max-w-full w-lg">
<Dialog
open={open()}
onOpenChange={setOpen}
close={false}
classes={{ body: 'p-0 mb-0' }}
body={<CommandPalette groups={BASIC_GROUPS} loading onClose={() => setOpen(false)} />}
>
<Button variant="outline">Open palette</Button>
</Dialog>
</div>
)
}

With Close Button#

A close button in the input trailing slot.

function WithCloseButton() {
const [open, setOpen] = createSignal(false)
const BASIC_GROUPS: CommandPaletteT.Group[] = [
{
id: 'workspace',
label: 'Workspace',
items: [
{
value: 'new-issue',
label: 'New Issue',
leadingRender: () => <Icon name="i-lucide-circle-plus" />,
trailingRender: () => <span class="text-xs text-muted-foreground">⌘N</span>,
},
{
value: 'open-inbox',
label: 'Open Inbox',
leadingRender: () => <Icon name="i-lucide-inbox" />,
trailingRender: () => <span class="text-xs text-muted-foreground">GI</span>,
},
{
value: 'sync-roadmap',
label: 'Sync Roadmap',
leadingRender: () => <Icon name="i-lucide-refresh-cw" />,
description: 'Pull the latest planning updates',
},
],
},
{
id: 'navigation',
label: 'Navigation',
items: [
{
value: 'go-dashboard',
label: 'Dashboard',
leadingRender: () => <Icon name="i-lucide-layout-dashboard" />,
},
{
value: 'go-projects',
label: 'Projects',
leadingRender: () => <Icon name="i-lucide-folder-kanban" />,
},
{
value: 'go-settings',
label: 'Settings',
leadingRender: () => <Icon name="i-lucide-settings" />,
description: 'Preferences',
},
{
value: 'go-billing',
label: 'Billing',
leadingRender: () => <Icon name="i-lucide-credit-card" />,
disabled: true,
},
],
},
]
return (
<div class="max-w-full w-lg">
<Dialog
open={open()}
onOpenChange={setOpen}
close={false}
classes={{ body: 'p-0 mb-0' }}
body={<CommandPalette groups={BASIC_GROUPS} showClose onClose={() => setOpen(false)} />}
>
<Button variant="outline">Open palette</Button>
</Dialog>
</div>
)
}

Compose multi-step flows outside the component by swapping the groups prop in a wrapper and set closeOnSelect={false} while the palette remains open between steps.

function SubNavigation() {
const ROOT_GROUPS: CommandPaletteT.Group[] = [
{
id: 'main',
label: 'Commands',
items: [
{
value: 'create',
label: 'Create',
leadingRender: () => <Icon name="i-lucide-plus-circle" />,
description: 'Create new resources',
},
{
value: 'share',
label: 'Share',
leadingRender: () => <Icon name="i-lucide-share-2" />,
description: 'Share with others',
},
{
value: 'delete',
label: 'Delete',
leadingRender: () => <Icon name="i-lucide-trash-2" />,
},
],
},
]
const CREATE_GROUPS: CommandPaletteT.Group[] = [
{
id: 'create',
label: 'Create',
items: [
{
value: 'create-new-file',
label: 'New File',
leadingRender: () => <Icon name="i-lucide-file-plus" />,
},
{
value: 'create-new-folder',
label: 'New Folder',
leadingRender: () => <Icon name="i-lucide-folder-plus" />,
},
{
value: 'create-new-project',
label: 'New Project',
leadingRender: () => <Icon name="i-lucide-git-branch" />,
},
],
},
]
const SHARE_GROUPS: CommandPaletteT.Group[] = [
{
id: 'share',
label: 'Share',
items: [
{
value: 'share-copy-link',
label: 'Copy Link',
leadingRender: () => <Icon name="i-lucide-link" />,
trailingRender: () => <span class="text-xs text-muted-foreground">⌘L</span>,
},
{
value: 'share-send-email',
label: 'Send via Email',
leadingRender: () => <Icon name="i-lucide-mail" />,
},
],
},
]
const [open, setOpen] = createSignal(false)
const [view, setView] = createSignal<'root' | 'create' | 'share'>('root')
const groups = createMemo(() => {
switch (view()) {
case 'create':
return CREATE_GROUPS
case 'share':
return SHARE_GROUPS
default:
return ROOT_GROUPS
}
})
const onSelect = (item: CommandPaletteT.Item) => {
if (item.value === 'create') {
setView('create')
} else if (item.value === 'share') {
setView('share')
}
}
return (
<div class="flex flex-col gap-3 max-w-full w-lg">
<div class="flex gap-3 items-center justify-between">
<p class="text-sm text-muted-foreground">
Drive multi-step navigation outside the component by swapping the `groups` prop.
</p>
<Button
size="sm"
variant="outline"
disabled={view() === 'root'}
onClick={() => setView('root')}
>
Back
</Button>
</div>
<Dialog
open={open()}
onOpenChange={setOpen}
close={false}
classes={{ body: 'p-0 mb-0' }}
body={<CommandPalette groups={groups()} closeOnSelect={false} onSelect={onSelect} />}
>
<Button variant="outline">Open palette</Button>
</Dialog>
</div>
)
}

Sizes and Virtual Rendering#

Use size="xs" | "sm" | "md" | "lg" | "xl" to size command rows. For large collections, pass virtualRender and scrollToItem; CommandPalette exposes type-safe flattened entries and its mounted scroll element, while scrollToItem receives the source command plus its flattened entry index.

API Reference#

Attributes#

Slotroot0 attributes
Command palette container that owns search and option list.
No attribute metadata for this slot.

Props#

PropTypeDefaultDescription
autofocusboolean | undefinedtrue
Whether to focus the search input automatically on mount.
classClassValue
Class applied to the component root or trigger element.
classesCommandPaletteT.Classes | undefined
closeIconIconT.Nameicon-close
Icon name for the palette close button.
closeOnSelectboolean | undefinedtrue
Whether to request closing the palette after an enabled item is selected.
descriptionPositionCommandPaletteT.DescriptionPosition | undefinedbottom
Where descriptions render by default.
disableFilterboolean | undefinedfalse
Disable built-in search filtering and render all provided items.
emptyRenderComponentOrElement<CommandPaletteT.EmptyRenderProps<TItem>> | undefined
Custom empty state renderer.
filterItems((args: { groups: CommandPaletteT.Group<TItem>[]; searchTerm: string; }) => CommandPaletteT.Group<TItem>[]) | undefined
Custom filter function that fully controls which groups and items are visible.
footerRenderComponentOrElement<CommandPaletteT.FooterRenderProps<TItem>> | undefined
Custom footer renderer.
getItemSearchText((item: TItem, group: CommandPaletteT.Group<TItem>) => string) | undefined
Custom search text builder for built-in filtering.
groupsCommandPaletteT.Group<TItem>[] | undefined[]
Command groups to display initially.
inputPropsJSX.HTMLAttributes<HTMLInputElement> | undefined
Additional props of input
itemProps((context: CommandPaletteT.ItemRenderProps<TItem>) => ElementProps<HTMLDivElement> | undefined) | undefined
Additional attributes for a command row.
itemRenderComponentOrElement<CommandPaletteT.ItemRenderProps<TItem>> | undefined
Custom command row content renderer.
leadingIconIconT.Nameicon-search
Icon name of input's leading icon.
listboxPropsElementProps<HTMLDivElement> | undefined
Additional attributes for the command listbox.
loadingboolean | undefinedfalse
Whether the palette is in a loading state.
loadingIconIconT.Nameicon-loading
Icon name of input's leading icon for the loading state.
onClose(() => void) | undefined
Callback triggered when the close button is clicked or selection requests closing.
onSearchTermChange((term: string) => void) | undefined
Callback triggered when the search term changes.
onSelect((item: TItem) => void) | undefined
Callback triggered when an enabled item is selected.
placeholderstring | undefinedSearch...
Placeholder text for the search input.
refJSX.HTMLElementTags["div"] extends { ref?: infer Ref; } ? Ref : never | undefined
scrollToItem((item: TItem, entryIndex: number) => void) | undefined
Scrolls a highlighted command into view using its flattened entry index.
searchMaxLengthnumber | undefined
Maximum allowed length for the search text.
searchTermstring | undefined
Controlled search term.
showCloseboolean | undefinedfalse
Whether to show a close button in the header.
size"xs" | "sm" | "md" | "lg" | "xl" | undefined
styleJSX.CSSProperties | undefined
stylesCommandPaletteT.Styles | undefined
virtualRenderComponent<CommandPaletteT.VirtualRenderProps<TItem>> | undefined
Renders flattened group labels and commands through a virtualization layer.

Items#

PropTypeDefaultDescription
alwaysShowboolean | undefined
Whether this item should be excluded from built-in search filtering.
descriptionstring | undefined
Secondary description text shown for the item.
descriptionPositionCommandPaletteT.DescriptionPosition | undefined
Where the item description is rendered. Overrides the root setting.
disabledboolean | undefined
Whether the item is disabled and cannot be selected.
keywordsstring[] | undefined
Additional keywords included in built-in search matching.
labelstring | undefined
Primary label for the item.
leadingRenderComponentOrElement<CommandPaletteT.ItemRenderProps> | undefined
Custom visual rendered at the start of the item.
onSelect(() => void) | undefined
Callback triggered when the item is selected.
trailingRenderComponentOrElement<CommandPaletteT.ItemRenderProps> | undefined
Custom visual rendered at the end of the item.
value*string
Unique value for the item.