Skip to content
UI Kit
Accent color

Command palette for React

Search and run anything. Ctrl K or ⌘K opens it, arrows move, Enter runs, and groups keep it calm.

One of 9 components in Overlays

2 variants

Search and run anything from the keyboard

A command palette gives power users one place to search and act, without hunting through menus. This one opens with Ctrl K on Windows and Linux and Cmd K on a Mac, filters as you type, moves with the arrow keys and runs the highlighted item with Enter.

Command list is the inline version that sits in a page or a panel, for example as the search of a docs site. Palette is the floating dialog over the whole app, with groups such as Pages, Actions and Recent, an icon and a shortcut per item, and an empty state that suggests what to type.

Name items by what they do, Create invoice, not Invoices new. Put the five most used actions in the empty state, people open the palette before they know what to type. Every action should also be reachable without the palette, it is a shortcut, not the only way.

Variants

Command list

The palette content in place. Try typing, the list filters across labels, hints and keywords.

Palette

In a modal near the top of the screen. Press Ctrl K or ⌘K, or use the button.

Details

What Command palette brings along, read from the registry and the source.

Category
Overlays
Variants
2
Version
1.0.1 · since 1.0.0
File
components/ui/command-palette.tsx
Brings along
utilskiticondialog
npm packages
None
Languages
English and German, 7 texts of its own
Studio
Adjust in code

Props

The CommandProps interface from components/ui/command-palette.tsx, with the comments from the code.

  • itemsrequired
    CommandItem[]
  • defaultQueryoptional
    string

    Pre-filled search, for demos

  • onDoneoptional
    () => void

    Called after an item ran, e.g. to close the palette

  • autoFocusoptional
    boolean

Built-in texts

Command palette writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 7 texts
placeholderEnglish Type a command or searchGerman Befehl eingeben oder suchen
emptyEnglish Nothing found. Try another word.German Nichts gefunden. Versuch ein anderes Wort.
navigateEnglish to moveGerman bewegen
selectEnglish to openGerman öffnen
closeEnglish to closeGerman schließen
labelEnglish Command menuGerman Befehlsmenü
resultsEnglish resultsGerman Treffer

Accessibility

Read from the source of the component.

  • Command palette handles the keys Enter, Arrow up, Arrow down, Home, End and Space itself.
  • Built on the native dialog element. The browser keeps focus inside and closes it with Escape.
  • Sets aria-activedescendant, aria-autocomplete, aria-controls, aria-label, aria-labelledby, aria-live and aria-selected, so screen readers announce purpose and state.
  • Uses the roles combobox, group, listbox and option.
  • Announces changes through a live region without moving focus.
  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 291 lines
components/ui/command-palette.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { scrim, useDialogState, useModalMotion } from '@/components/ui/dialog';

const TEXTS = {
  en: { placeholder: 'Type a command or search', empty: 'Nothing found. Try another word.', navigate: 'to move', select: 'to open', close: 'to close', label: 'Command menu', results: 'results' },
  de: { placeholder: 'Befehl eingeben oder suchen', empty: 'Nichts gefunden. Versuch ein anderes Wort.', navigate: 'bewegen', select: 'öffnen', close: 'schließen', label: 'Befehlsmenü', results: 'Treffer' },
};
type Texts = (typeof TEXTS)['en'];

const CSS = '.kitfx-cmd-empty{animation:kitfx-cmd-in .45s var(--ease-kit) both}@keyframes kitfx-cmd-in{from{opacity:0;transform:translateY(6px) scale(.98)}}@media (prefers-reduced-motion:reduce){.kitfx-cmd-empty{animation:none}}';

export interface CommandItem {
  id: string;
  label: string;
  group?: string;
  icon?: IconName;
  /** Quiet text after the label, e.g. a path or a category */
  hint?: string;
  /** Shortcut keys shown at the end, e.g. ['⌘', 'N'] */
  keys?: string[];
  /** More words that should find this item */
  keywords?: string[];
  onSelect?: () => void;
  href?: string;
}

const norm = (s: string) => s.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');

export interface CommandProps {
  items: CommandItem[];
  /** Pre-filled search, for demos */
  defaultQuery?: string;
  /** Called after an item ran, e.g. to close the palette */
  onDone?: () => void;
  autoFocus?: boolean;
  labels?: Partial<Texts>;
  className?: string;
}

/**
 * The searchable command list on its own: input, groups, keyboard (arrows, Home, End, Enter).
 * Use it inline (a launcher page, a sidebar) or inside <CommandPalette>.
 * The highlight glides between rows, rows glide to their new place while you filter.
 */
export function Command({ items, defaultQuery = '', onDone, autoFocus, labels, className }: CommandProps) {
  const t = useTexts(TEXTS, labels);
  const [query, setQuery] = useState(defaultQuery);
  const [active, setActive] = useState(0);
  const id = useId();
  const listRef = useRef<HTMLDivElement>(null);
  const [mark, setMark] = useState<{ y: number; h: number; animate: boolean } | null>(null);
  const places = useRef<Map<string, number> | null>(null);

  const shown = useMemo(() => {
    const words = norm(query).split(/\s+/).filter(Boolean);
    if (!words.length) return items;
    return items.filter((i) => {
      const hay = norm([i.label, i.hint, i.group, ...(i.keywords ?? [])].join(' '));
      return words.every((w) => hay.includes(w));
    });
  }, [items, query]);

  const groups = useMemo(() => {
    const out: { name?: string; rows: { item: CommandItem; index: number }[] }[] = [];
    shown.forEach((item, index) => {
      const g = out.find((x) => x.name === item.group);
      if (g) g.rows.push({ item, index });
      else out.push({ name: item.group, rows: [{ item, index }] });
    });
    // Keep the visual order and the keyboard order the same
    let k = 0;
    out.forEach((g) => g.rows.forEach((r) => (r.index = k++)));
    return out;
  }, [shown]);
  const flat = groups.flatMap((g) => g.rows.map((r) => r.item));

  useEffect(() => setActive(0), [query]);

  // The highlight is one surface that travels to the active row
  useLayoutEffect(() => {
    const el = document.getElementById(`${id}-${active}`);
    if (!el || !flat.length) {
      setMark((m) => (m ? null : m));
      return;
    }
    setMark((m) => {
      const next = { y: el.offsetTop, h: el.offsetHeight, animate: m !== null };
      return m && m.y === next.y && m.h === next.h ? m : next;
    });
    const list = listRef.current;
    if (!list) return;
    const r = el.getBoundingClientRect();
    const lr = list.getBoundingClientRect();
    if (r.top < lr.top + 8) list.scrollTop -= lr.top + 8 - r.top;
    else if (r.bottom > lr.bottom - 8) list.scrollTop += r.bottom - lr.bottom + 8;
  }, [active, id, shown]);

  // Rows that stay glide from their old place, new rows fade in
  useLayoutEffect(() => {
    const list = listRef.current;
    if (!list) return;
    const nodes = Array.from(list.querySelectorAll<HTMLElement>('[data-flip]'));
    const now = new Map(nodes.map((n) => [n.dataset.flip!, n.offsetTop]));
    const before = places.current;
    places.current = now;
    if (!before || prefersReducedMotion()) return;
    let fresh = 0;
    nodes.forEach((n) => {
      const was = before.get(n.dataset.flip!);
      const top = now.get(n.dataset.flip!)!;
      if (was === undefined) {
        n.animate([{ opacity: 0, transform: 'translateY(6px)' }, { opacity: 1, transform: 'none' }], { duration: 320, delay: Math.min(fresh++, 6) * 25, easing: 'cubic-bezier(0.2,0.7,0.2,1)', fill: 'backwards' });
      } else if (was !== top) {
        n.animate([{ transform: `translateY(${was - top}px)` }, { transform: 'none' }], { duration: 340, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
      }
    });
  }, [shown]);

  const run = (item?: CommandItem) => {
    if (!item) return;
    item.onSelect?.();
    if (item.href) window.location.assign(item.href);
    onDone?.();
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    const n = flat.length;
    if (!n) return;
    if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => (a + 1) % n); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => (a - 1 + n) % n); }
    else if (e.key === 'Home' && e.ctrlKey) { e.preventDefault(); setActive(0); }
    else if (e.key === 'End' && e.ctrlKey) { e.preventDefault(); setActive(n - 1); }
    else if (e.key === 'Enter') { e.preventDefault(); run(flat[active]); }
  };

  return (
    <div className={cn('flex min-h-0 flex-col overflow-hidden rounded-2xl border border-border bg-popover text-popover-foreground shadow-[var(--highlight),var(--shadow-lg)]', className)}>
      <style href="kitfx-cmd" precedence="default">
        {CSS}
      </style>
      <div className="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4">
        <Icon name="search" size={18} className="text-subtle-foreground" />
        <input
          role="combobox"
          aria-expanded
          aria-controls={`${id}-list`}
          aria-activedescendant={flat.length ? `${id}-${active}` : undefined}
          aria-autocomplete="list"
          aria-label={t.label}
          autoFocus={autoFocus}
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onKeyDown={onKeyDown}
          placeholder={t.placeholder}
          spellCheck={false}
          className="h-full w-0 min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-subtle-foreground sm:text-[15px]"
        />
      </div>
      <div ref={listRef} id={`${id}-list`} role="listbox" aria-label={t.label} className="kit-scrollbar relative isolate min-h-0 flex-1 overflow-y-auto overscroll-contain p-2">
        {mark && (
          <span
            aria-hidden
            className={cn('pointer-events-none absolute inset-x-2 top-0 -z-10 rounded-lg bg-accent', mark.animate && 'transition-transform duration-200 ease-[var(--ease-kit)] motion-reduce:transition-none')}
            style={{ height: mark.h, transform: `translateY(${mark.y}px)` }}
          />
        )}
        {groups.map((g, gi) => (
          <div key={g.name ?? gi} role="group" aria-labelledby={g.name ? `${id}-g${gi}` : undefined} className={cn(gi > 0 && 'mt-2')}>
            {g.name && (
              <div id={`${id}-g${gi}`} data-flip={`g:${g.name}`} className="px-2 pb-1.5 pt-2 text-[11px] font-medium uppercase tracking-[0.08em] text-subtle-foreground">
                {g.name}
              </div>
            )}
            {g.rows.map(({ item, index }) => {
              const on = index === active;
              return (
                <div
                  key={item.id}
                  id={`${id}-${index}`}
                  data-flip={item.id}
                  role="option"
                  aria-selected={on}
                  onPointerMove={() => setActive(index)}
                  onPointerDown={(e) => e.preventDefault()}
                  onClick={() => run(item)}
                  className={cn('group/opt flex min-h-11 cursor-pointer items-center gap-3 rounded-lg px-2 text-sm text-foreground sm:min-h-10', on && !mark && 'bg-accent')}
                >
                  {item.icon && (
                    <span className={cn('grid size-7 shrink-0 place-items-center rounded-md border border-border bg-surface text-muted-foreground transition-[color,scale] duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none', on && 'scale-105 text-foreground')}>
                      <Icon name={item.icon} size={15} />
                    </span>
                  )}
                  <span className="flex min-w-0 flex-1 items-baseline gap-2">
                    <span className="truncate">{item.label}</span>
                    {item.hint && <span className="shrink-0 text-subtle-foreground">{item.hint}</span>}
                  </span>
                  {item.keys && (
                    <span className="flex shrink-0 gap-1 max-sm:hidden">
                      {item.keys.map((k) => (
                        <kbd key={k} className="grid h-[22px] min-w-[22px] place-items-center rounded-[5px] border border-border bg-surface px-1.5 font-sans text-[11px] font-medium text-muted-foreground">
                          {k}
                        </kbd>
                      ))}
                    </span>
                  )}
                  {!item.keys && (
                    <Icon
                      name="corner-down-left"
                      size={15}
                      className={cn('shrink-0 text-subtle-foreground transition-[opacity,translate] duration-200 ease-[var(--ease-kit)] max-sm:hidden motion-reduce:transition-none', on ? 'translate-x-0 opacity-100' : '-translate-x-1 opacity-0')}
                    />
                  )}
                </div>
              );
            })}
          </div>
        ))}
        {!flat.length && (
          <div className="kitfx-cmd-empty grid place-items-center gap-3 px-6 py-12 text-center">
            <span className="grid size-10 place-items-center rounded-full bg-muted text-muted-foreground">
              <Icon name="search" size={18} />
            </span>
            <p className="text-sm text-muted-foreground">{t.empty}</p>
          </div>
        )}
      </div>
      <span aria-live="polite" className="sr-only">
        {query ? `${flat.length} ${t.results}` : ''}
      </span>
      <div className="flex shrink-0 items-center gap-4 border-t border-border bg-surface px-4 py-2.5 text-xs text-muted-foreground max-sm:hidden">
        {[
          [['↑', '↓'], t.navigate],
          [['↵'], t.select],
          [['Esc'], t.close],
        ].map(([keys, label]) => (
          <span key={label as string} className="flex items-center gap-1.5">
            {(keys as string[]).map((k) => (
              <kbd key={k} className="grid h-5 min-w-5 place-items-center rounded-[4px] border border-border bg-background px-1 font-sans text-[10.5px] font-medium">
                {k}
              </kbd>
            ))}
            {label as string}
          </span>
        ))}
      </div>
    </div>
  );
}

export interface CommandPaletteProps extends Omit<CommandProps, 'onDone' | 'autoFocus'> {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** The key that opens it with Ctrl or ⌘. Default "k". false turns the shortcut off. */
  shortcut?: string | false;
}

/**
 * The command menu in a modal: Ctrl K or ⌘K opens it anywhere, Escape closes it.
 * It grows out of the button that opened it and shrinks back when it closes.
 *   <CommandPalette items={[{ id: 'new', label: 'New invoice', group: 'Actions', icon: 'plus', keys: ['N'] }]} />
 */
export function CommandPalette({ open: openProp, defaultOpen, onOpenChange, shortcut = 'k', ...rest }: CommandPaletteProps) {
  const [open, setOpen] = useDialogState(openProp, defaultOpen, onOpenChange);
  const [modal, present] = useModalMotion(open, setOpen);
  const t = useTexts(TEXTS, rest.labels);
  useEffect(() => {
    if (!shortcut) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key.toLowerCase() === shortcut && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen(!open);
      }
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [shortcut, open, setOpen]);
  return (
    <dialog
      {...modal}
      aria-label={t.label}
      className={cn('mx-auto mt-[12vh] mb-auto w-[calc(100%-1.5rem)] max-w-[40rem] overflow-visible bg-transparent p-0 text-left outline-none data-[closing]:pointer-events-none max-sm:mt-3', scrim)}
    >
      {present && <Command {...rest} autoFocus onDone={() => setOpen(false)} className="max-h-[min(30rem,76vh)]" />}
    </dialog>
  );
}

Frequent questions

How do I install Command palette?

With aiw add @ai-website/command-palette. The command puts components/ui/command-palette.tsx into your project, the code is yours from then on. It brings along icon and dialog. It needs no further npm packages.

Which variants does Command palette have?

2 variants, Command list and Palette.

Can I translate the texts of Command palette?

Yes. Command palette carries 7 texts of its own in English and German, such as “Type a command or search”, “Nothing found. Try another word.” and “to move”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Command palette?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Command palette with aiw_components and install it with aiw_add @ai-website/command-palette.

Often searched

Jump to

Copied to the clipboard