Lunia DS

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.

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-palette

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
itemsCommandItem[]-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.
placeholderstring"Search commands and pages"Field placeholder on the first list. A nested list uses "Search" and its own title.
hotkeystring"k"Letter that opens and closes the palette with Cmd (Mac) or Ctrl. An empty string turns the shortcut off.
openbooleanundefinedOpen state when controlled. The palette runs the same open and close motion when this changes.
defaultOpenbooleanfalseOpen state on first render when not controlled.
showTriggerbooleantrueShows the search pill. Without it, the palette opens from the shortcut or the open prop and fades in at the top of the page.
triggerLabelstring"Search"Text on the search pill.
EventDescription
openChangeFires with the new open state (pill click, shortcut, veil click, Escape, or a finished command).
SlotDescription
iconPer 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
CP1hover pilltrigger pillbox-shadow (border), colorborder-primary, text-tertiary → stronger border, text-primaryduration-lg (320)ease-outSwaps at once
CP2pill click or shortcut (Closed -> Open)panelleft, top, width, height, border-radius, background-colorpill box, radius = pill height / 2, bg-primary → 580px wide at 16% from the top (min 48px), fitted height, radius 20px, popover backgroundduration-2xl (420)ease-outShows at once
CP3open with no pill on screenpanelopacity + transform scale, translateY0, scale-press, -distance-nudge → 1, 1, 0duration-2xl (420)ease-outShows at once
CP4openveilopacity0 → 1duration-2xl (420)ease-outShows at once
CP5openpill face copyopacity1 → 0duration-2xs (120)ease-exitHides at once
CP6openfield row, list and footeropacity0 → 1duration-md (260)ease-exitdelay 120msShows at once
CP7Escape on the first list, veil click, shortcut, or a finished command (Open -> Closed)panelas CP2 in reverse, back to the pill box and the page backgroundpanel box → pill boxduration-2xl (420)ease-outHides at once
CP8closefield row, list and footeropacity1 → 0duration-2xs (120)ease-exitHides at once
CP9closepill face copyopacity0 → 1duration-sm (200)ease-exitdelay 200msShows at once
CP10closeveilopacity1 → 0duration-2xl (420)ease-outHides at once
CP11close with no pill box (pill off screen or hidden)trigger pillopacity (after a morph into the pill, the pill takes over with no fade)0 → 1duration-xs (160)ease-exitShows at once
CP12type, or results changelistheight (fits the rows, max 352px)old height → new heightduration-lg (320)ease-outResizes at once
CP13arrow keys, Home, End, or pointer over a rowhighlighttransform translateYold row top → active row topduration-sm (200)ease-outMoves at once
CP14no rows match, or the query changes while emptyempty linetext morphold text → new textduration-3xl (460)ease-outSwaps at once
CP15choose a row with a nested list (Nested)place chip + back hint in the footergrid-template-columns + opacity0fr, 0 → 1fr, 1duration-xl (380)ease-outShows at once
CP16go into or out of a nested listplace chip texttext morphold title → new titleduration-3xl (460)ease-outSwaps at once
CP17go into a nested list (forward) or back (Backspace on an empty field, or Escape)new listopacity + filter blur + transform translateX0, blur-text, distance-slide x direction → 1, 0, 0duration-xl (380)ease-outdelay 40msShows at once
CP18go into a nested list or backold list (copy kept for the exit)opacity + filter blur + transform translateX1, 0, 0 → 0, blur-text, -distance-slide x directionduration-md (260)ease-outHides at once
CP19choose a command that returns a promise (Running)shortcut or chevron, then spinneropacity + filter blur (icon swap)1, 0 → 0, blur-swapduration-sm (200)ease-outSwaps at once
CP20choose a command that returns a promise (Running)shortcut or chevron, then spinnertransform scale (icon swap)1 → scale-swapduration-lg (320)ease-outSwaps at once
CP21while Runningspinnertransform rotate0deg → 360deg, repeatsduration-6xl (900)ease-linearNo loop
CP22command resolves (Running -> Done)checkstroke-dashoffset (draw), after the spinner swaps out as CP19 and CP201 → 0duration-xl (380)ease-outdelay 80msShows at once
CP23520ms after Donepanelclose, as CP7 to CP10Done → Closedduration-2xl (420)ease-outdelay 520msHides at once
CP24command rejectsrowtransform translateX shake (row returns to its shortcut)0 → -4px, 4px, -2px, 0duration-xl (380)ease-exitStays 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.