Navigation and workspace
Command palette
A search dialog that grows out of a small search pill, narrows its list as you type, opens nested lists in place, runs commands on the row, and folds back into the same pill when it closes.
Live preview
Loading preview
A "Search" pill with Cmd K. Groups: Go to (Home, Projects, Settings) and Actions (New project with Cmd N, Copy link, Change theme... with Light, Dark and System). Copy link waits 700ms, then shows the check and the palette folds back into the pill. Replay opens the palette, types "proj" (two rows left), clears, opens Change theme, picks Dark, opens again and runs Copy link.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add command-paletteUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { CommandPalette, type CommandPaletteItem } from './command-palette';
/** Own 16px marks for the demo rows. */
const MARKS = {
home: 'M2 6.667 8 2l6 4.667v6.666A1.333 1.333 0 0 1 12.667 14.667H3.333A1.333 1.333 0 0 1 2 13.333zM6 14.667V8h4v6.667',
folder:
'M14.667 12a1.333 1.333 0 0 1-1.334 1.333H2.667A1.333 1.333 0 0 1 1.333 12V3.333C1.333 2.597 1.93 2 2.667 2H6l1.333 2h6c.737 0 1.334.597 1.334 1.333z',
settings:
'M8 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM12.933 10a1.1 1.1 0 0 0 .22 1.213l.04.04a1.333 1.333 0 1 1-1.886 1.887l-.04-.04a1.1 1.1 0 0 0-1.867.78v.12a1.333 1.333 0 1 1-2.667 0v-.06a1.1 1.1 0 0 0-1.733-.84l-.04.04A1.333 1.333 0 1 1 3.073 11.253l.04-.04A1.1 1.1 0 0 0 2.333 9.347H2.2a1.333 1.333 0 1 1 0-2.667h.06a1.1 1.1 0 0 0 .84-1.733l-.04-.04A1.333 1.333 0 1 1 4.947 3.02l.04.04a1.1 1.1 0 0 0 1.213.22h.053a1.1 1.1 0 0 0 .667-1.007V2.2a1.333 1.333 0 1 1 2.667 0v.06a1.1 1.1 0 0 0 .666 1.007 1.1 1.1 0 0 0 1.214-.22l.04-.04a1.333 1.333 0 1 1 1.886 1.886l-.04.04a1.1 1.1 0 0 0-.22 1.214v.053a1.1 1.1 0 0 0 1.007.667h.133a1.333 1.333 0 1 1 0 2.666h-.06a1.1 1.1 0 0 0-1.007.667',
plus: 'M8 3.333v9.334M3.333 8h9.334',
link: 'M6.667 8.667a3.333 3.333 0 0 0 5.026.36l2-2a3.333 3.333 0 0 0-4.713-4.714l-1.147 1.14M9.333 7.333a3.333 3.333 0 0 0-5.026-.36l-2 2a3.333 3.333 0 0 0 4.713 4.714l1.14-1.14',
palette:
'M8 14.667A6.667 6.667 0 1 1 14.667 8c0 1.473-1.194 2-2.667 2h-1.333a1.333 1.333 0 0 0-1 2.2c.333.4.333.8.333 1.133 0 .8-.667 1.334-2 1.334zM4.667 8h.006M6 5.333h.007M9.333 4.667h.007M11.333 6.667h.007',
};
function Mark({ d }: { d: string }) {
return (
<svg
aria-hidden="true"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={d} />
</svg>
);
}
/** Sets a field's text the way typing does, so React and Vue both see the change. */
function typeInto(input: HTMLInputElement | null | undefined, text: string) {
if (!input) return;
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, text);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
export default function CommandPaletteDemo({ replay = 0 }: { replay?: number }) {
const [note, setNote] = React.useState('Pick a command to see the result here.');
const rootRef = React.useRef<HTMLDivElement>(null);
const items = React.useMemo<CommandPaletteItem[]>(() => {
const theme = (name: string): CommandPaletteItem => ({
id: `theme-${name}`,
label: name,
onSelect: () => setNote(`Theme set to ${name}.`),
});
return [
{
id: 'home',
label: 'Home',
group: 'Go to',
icon: <Mark d={MARKS.home} />,
onSelect: () => setNote('Opened Home.'),
},
{
id: 'projects',
label: 'Projects',
group: 'Go to',
icon: <Mark d={MARKS.folder} />,
onSelect: () => setNote('Opened Projects.'),
},
{
id: 'settings',
label: 'Settings',
group: 'Go to',
icon: <Mark d={MARKS.settings} />,
onSelect: () => setNote('Opened Settings.'),
},
{
id: 'new',
label: 'New project',
group: 'Actions',
icon: <Mark d={MARKS.plus} />,
shortcut: ['mod', 'N'],
keywords: ['create'],
onSelect: () => setNote('Started a new project.'),
},
{
id: 'copy',
label: 'Copy link',
group: 'Actions',
icon: <Mark d={MARKS.link} />,
onSelect: () =>
new Promise<void>((resolve) =>
window.setTimeout(() => {
setNote('Link copied.');
resolve();
}, 700),
),
},
{
id: 'theme',
label: 'Change theme',
group: 'Actions',
icon: <Mark d={MARKS.palette} />,
items: [theme('Light'), theme('Dark'), theme('System')],
},
];
}, []);
// Replay: open, type "proj", clear, open Change theme and pick Dark, then open again and run Copy link.
React.useEffect(() => {
if (replay === 0) return;
const root = rootRef.current;
const pill = () => root?.querySelector<HTMLButtonElement>('button[aria-haspopup="dialog"]');
const field = () => root?.querySelector<HTMLInputElement>('[role="combobox"]');
const key = (k: string) =>
field()?.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true }));
const steps: [number, () => void][] = [
[0, () => pill()?.click()],
[700, () => typeInto(field(), 'p')],
[800, () => typeInto(field(), 'pr')],
[900, () => typeInto(field(), 'pro')],
[1000, () => typeInto(field(), 'proj')],
[1900, () => typeInto(field(), '')],
[2400, () => key('End')],
[2800, () => key('Enter')],
[3500, () => key('ArrowDown')],
[3900, () => key('Enter')],
[4900, () => pill()?.click()],
[5600, () => typeInto(field(), 'copy')],
[6200, () => key('Enter')],
];
const timers = steps.map(([at, run]) => window.setTimeout(run, at));
return () => {
for (const timer of timers) window.clearTimeout(timer);
};
}, [replay]);
return (
<div ref={rootRef} className="flex min-h-48 flex-col items-center justify-center gap-3">
<CommandPalette items={items} />
<p className="text-sm text-tertiary" aria-live="polite">
{note}
</p>
</div>
);
}
API
Parts: trigger pill (search icon, label, shortcut keys), veil, panel (dialog), pill face copy (fades during the morph), field row (search icon, place chip, input, Esc key), place chip (text morph), list (height eases), highlight, group heading, row (icon, label with matched letters, description, right edge), row right edge (shortcut or chevron, spinner, check), empty line (text morph), footer (key hints, back hint folds in).
| Prop | Type | Default | Description |
|---|---|---|---|
| items | CommandItem[] | - | Rows: { id, label, group?, icon?, shortcut?, keywords?, description?, href?, onSelect?, items? }. items opens a nested list; onSelect can return a promise to show progress on the row. Required. |
| placeholder | string | "Search commands and pages" | Field placeholder on the first list. A nested list uses "Search" and its own title. |
| hotkey | string | "k" | Letter that opens and closes the palette with Cmd (Mac) or Ctrl. An empty string turns the shortcut off. |
| open | boolean | undefined | Open state when controlled. The palette runs the same open and close motion when this changes. |
| defaultOpen | boolean | false | Open state on first render when not controlled. |
| showTrigger | boolean | true | Shows the search pill. Without it, the palette opens from the shortcut or the open prop and fades in at the top of the page. |
| triggerLabel | string | "Search" | Text on the search pill. |
| Event | Description |
|---|---|
| openChange | Fires with the new open state (pill click, shortcut, veil click, Escape, or a finished command). |
| Slot | Description |
|---|---|
| icon | Per item, through items[].icon: a 16px icon at the start of the row. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| CP1 | hover pill | trigger pill | box-shadow (border), color | border-primary, text-tertiary → stronger border, text-primary | duration-lg (320)ease-out | Swaps at once |
| CP2 | pill click or shortcut (Closed -> Open) | panel | left, top, width, height, border-radius, background-color | pill box, radius = pill height / 2, bg-primary → 580px wide at 16% from the top (min 48px), fitted height, radius 20px, popover background | duration-2xl (420)ease-out | Shows at once |
| CP3 | open with no pill on screen | panel | opacity + transform scale, translateY | 0, scale-press, -distance-nudge → 1, 1, 0 | duration-2xl (420)ease-out | Shows at once |
| CP4 | open | veil | opacity | 0 → 1 | duration-2xl (420)ease-out | Shows at once |
| CP5 | open | pill face copy | opacity | 1 → 0 | duration-2xs (120)ease-exit | Hides at once |
| CP6 | open | field row, list and footer | opacity | 0 → 1 | duration-md (260)ease-exitdelay 120ms | Shows at once |
| CP7 | Escape on the first list, veil click, shortcut, or a finished command (Open -> Closed) | panel | as CP2 in reverse, back to the pill box and the page background | panel box → pill box | duration-2xl (420)ease-out | Hides at once |
| CP8 | close | field row, list and footer | opacity | 1 → 0 | duration-2xs (120)ease-exit | Hides at once |
| CP9 | close | pill face copy | opacity | 0 → 1 | duration-sm (200)ease-exitdelay 200ms | Shows at once |
| CP10 | close | veil | opacity | 1 → 0 | duration-2xl (420)ease-out | Hides at once |
| CP11 | close with no pill box (pill off screen or hidden) | trigger pill | opacity (after a morph into the pill, the pill takes over with no fade) | 0 → 1 | duration-xs (160)ease-exit | Shows at once |
| CP12 | type, or results change | list | height (fits the rows, max 352px) | old height → new height | duration-lg (320)ease-out | Resizes at once |
| CP13 | arrow keys, Home, End, or pointer over a row | highlight | transform translateY | old row top → active row top | duration-sm (200)ease-out | Moves at once |
| CP14 | no rows match, or the query changes while empty | empty line | text morph | old text → new text | duration-3xl (460)ease-out | Swaps at once |
| CP15 | choose a row with a nested list (Nested) | place chip + back hint in the footer | grid-template-columns + opacity | 0fr, 0 → 1fr, 1 | duration-xl (380)ease-out | Shows at once |
| CP16 | go into or out of a nested list | place chip text | text morph | old title → new title | duration-3xl (460)ease-out | Swaps at once |
| CP17 | go into a nested list (forward) or back (Backspace on an empty field, or Escape) | new list | opacity + filter blur + transform translateX | 0, blur-text, distance-slide x direction → 1, 0, 0 | duration-xl (380)ease-outdelay 40ms | Shows at once |
| CP18 | go into a nested list or back | old list (copy kept for the exit) | opacity + filter blur + transform translateX | 1, 0, 0 → 0, blur-text, -distance-slide x direction | duration-md (260)ease-out | Hides at once |
| CP19 | choose a command that returns a promise (Running) | shortcut or chevron, then spinner | opacity + filter blur (icon swap) | 1, 0 → 0, blur-swap | duration-sm (200)ease-out | Swaps at once |
| CP20 | choose a command that returns a promise (Running) | shortcut or chevron, then spinner | transform scale (icon swap) | 1 → scale-swap | duration-lg (320)ease-out | Swaps at once |
| CP21 | while Running | spinner | transform rotate | 0deg → 360deg, repeats | duration-6xl (900)ease-linear | No loop |
| CP22 | command resolves (Running -> Done) | check | stroke-dashoffset (draw), after the spinner swaps out as CP19 and CP20 | 1 → 0 | duration-xl (380)ease-outdelay 80ms | Shows at once |
| CP23 | 520ms after Done | panel | close, as CP7 to CP10 | Done → Closed | duration-2xl (420)ease-outdelay 520ms | Hides at once |
| CP24 | command rejects | row | transform translateX shake (row returns to its shortcut) | 0 → -4px, 4px, -2px, 0 | duration-xl (380)ease-exit | Stays still |
Accessibility
- The pill is a button with aria-haspopup dialog and aria-expanded; the panel is a modal dialog labelled "Command palette".
- Focus moves to the field when the panel opens and returns to the element that had it (or the pill) when it closes; the page behind does not scroll.
- The field is a combobox (aria-autocomplete list, aria-controls the list, aria-activedescendant the active row); the list is a listbox labelled "Commands" and each row is an option with aria-selected.
- Arrow keys move the active row and wrap; Home and End jump when the field is empty; Enter chooses; Tab stays in the field.
- Escape clears the query first, then goes back one list, then closes; Backspace on an empty field goes back one list.
- While a command runs, the other rows are aria-disabled; the empty line has role status so the result count change is read.
- Matched letters use weight and color, not color alone; group headings are role presentation.
- The leaving list copy is inert and aria-hidden.
- Reduced motion: no morph, slide, blur or shake; the panel and lists show and hide at once, and the spinner does not turn.