Skip to content
Caseroom

Components · Overlays

Command menu

A command palette: filter as you type, arrows and Enter, grouped commands, ⌘K or Ctrl+K to open.

9.7% of the 2,062 sites in The UI Index have a search (200 sites).

DashboardG D
DeploysG P
SettingsG S
New project
Invite a teammate
Toggle dark mode

Both previews render the same markup; the HTML tab below is that markup without a framework.

Install

React
npx shadcn@latest add https://www.themes.dev/caseroom/r/command-menu.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/command-menu.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS and the script.

command-menu.client.ts
// Command menu behaviour, shared by the Astro, React and HTML versions (WAI-ARIA combobox with a listbox popup).
// The dialog itself (opening, focus, backdrop, focus return) is dialog.client's. Here: filtering as you type (label
// and keywords, case-insensitive; empty groups hide; "No results" is announced), aria-activedescendant, Up/Down
// (wrapping) and Enter, Escape that clears a non-empty field before it closes, a click on an option, and ⌘K /
// Ctrl+K to open the page's first [data-command] menu. Running a command dispatches a bubbling `command-select`
// event ({ value, label }) from the dialog and closes it.
import { openDialog } from '../dialog/dialog.client';

declare global {
  interface Window {
    __caseroomCommand?: boolean;
  }
}

const parts = (d: HTMLElement) => ({
  input: d.querySelector<HTMLInputElement>('[role=combobox]')!,
  options: [...d.querySelectorAll<HTMLElement>('[role=option]')],
  visible: () => [...d.querySelectorAll<HTMLElement>('[role=option]:not([hidden])')],
  empty: d.querySelector<HTMLElement>('[data-command-empty]'),
});

function highlight(d: HTMLElement, option: HTMLElement | undefined) {
  const { input, options } = parts(d);
  for (const o of options) o.setAttribute('aria-selected', String(o === option));
  if (option) {
    input.setAttribute('aria-activedescendant', option.id);
    option.scrollIntoView({ block: 'nearest' });
  } else input.removeAttribute('aria-activedescendant');
}

function filter(d: HTMLElement) {
  const { input, options, visible, empty } = parts(d);
  const q = input.value.trim().toLowerCase();
  for (const o of options) o.hidden = !!q && !`${o.textContent} ${o.dataset.keywords ?? ''}`.toLowerCase().includes(q);
  for (const g of d.querySelectorAll<HTMLElement>('[role=group]')) g.hidden = !g.querySelector('[role=option]:not([hidden])');
  const shown = visible();
  if (empty) empty.textContent = shown.length ? '' : `No results for “${input.value.trim()}”`;
  highlight(d, shown[0]);
}

function run(d: HTMLDialogElement, option: HTMLElement) {
  d.dispatchEvent(new CustomEvent('command-select', { bubbles: true, detail: { value: option.dataset.value, label: option.textContent?.trim() } }));
  d.close();
}

function reset(d: HTMLElement) {
  const { input } = parts(d);
  input.value = '';
  filter(d);
}

if (typeof window !== 'undefined' && !window.__caseroomCommand) {
  window.__caseroomCommand = true;
  document.addEventListener('input', (e) => {
    const d = (e.target as Element).closest?.<HTMLDialogElement>('dialog[data-command]');
    if (d && (e.target as Element).matches('[role=combobox]')) filter(d);
  });
  document.addEventListener(
    'keydown',
    (e) => {
      // ⌘K / Ctrl+K anywhere opens the menu (the first one on the page).
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
        const d = document.querySelector<HTMLDialogElement>('dialog[data-command]');
        if (d && !d.open) {
          e.preventDefault();
          reset(d);
          openDialog(d, document.activeElement as HTMLElement);
        }
        return;
      }
      const d = (e.target as Element).closest?.<HTMLDialogElement>('dialog[data-command]');
      if (!d || !(e.target as Element).matches('[role=combobox]')) return;
      const shown = parts(d).visible();
      const current = shown.findIndex((o) => o.getAttribute('aria-selected') === 'true');
      if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
        e.preventDefault();
        if (!shown.length) return;
        const next = e.key === 'ArrowDown' ? (current + 1) % shown.length : (current - 1 + shown.length) % shown.length;
        highlight(d, shown[next]);
      } else if (e.key === 'Enter') {
        e.preventDefault();
        if (shown[current]) run(d, shown[current]);
      } else if (e.key === 'Escape' && parts(d).input.value) {
        // First Escape clears the search; the next one closes (the dialog's own Escape).
        e.preventDefault();
        reset(d);
      }
    },
    true,
  );
  document.addEventListener('click', (e) => {
    const option = (e.target as Element).closest<HTMLElement>('dialog[data-command] [role=option]');
    if (option) run(option.closest('dialog')!, option);
  });
  document.addEventListener('pointermove', (e) => {
    const option = (e.target as Element).closest?.<HTMLElement>('dialog[data-command] [role=option]');
    if (option && option.getAttribute('aria-selected') !== 'true') highlight(option.closest('dialog')!, option);
  });
  // Every time it opens, it starts empty with the first command highlighted.
  document.addEventListener(
    'toggle',
    (e) => {
      const d = e.target as HTMLElement;
      if (d instanceof HTMLDialogElement && d.matches('[data-command]') && d.open) reset(d);
    },
    true,
  );
}

export {};
command-menu.tsx
import { useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
import './command-menu.client';
import { command, SEARCH_PATH, type CommandGroup } from './command-menu.variants';

export type { CommandGroup };

export interface CommandMenuProps {
  /** Unique on the page. A trigger opens it with command="show-modal" commandfor={id}; ⌘K / Ctrl+K opens it too. */
  id: string;
  /** The accessible name of the dialog and the list, e.g. "Commands". */
  label: string;
  groups: CommandGroup[];
  placeholder?: string;
  /** Called with the command that was run. */
  onSelect?: (detail: { value: string; label: string }) => void;
  className?: string;
}

/** A command palette: a modal with a filtering combobox and a grouped listbox (WAI-ARIA), ⌘K / Ctrl+K to open. */
export function CommandMenu({ id, label, groups, placeholder = 'Type a command or search…', onSelect, className }: CommandMenuProps) {
  const ref = useRef<HTMLDialogElement>(null);
  useEffect(() => {
    const d = ref.current;
    if (!d || !onSelect) return;
    const on = (e: Event) => onSelect((e as CustomEvent).detail);
    d.addEventListener('command-select', on);
    return () => d.removeEventListener('command-select', on);
  }, [onSelect]);
  return (
    <dialog ref={ref} id={id} data-dialog="" data-command="" aria-label={label} className={cn(command.root, className)}>
      <div data-dialog-body="">
        <div className={command.search}>
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true" className={command.searchIcon}><path d={SEARCH_PATH} /></svg>
          <input type="text" role="combobox" aria-expanded="true" aria-controls={`${id}-list`} aria-autocomplete="list" aria-label={label} autoComplete="off" spellCheck="false" placeholder={placeholder} className={command.input} />
        </div>
        <div id={`${id}-list`} role="listbox" aria-label={label} className={command.list}>
          {groups.map((g, gi) => (
            <div key={g.heading} role="group" aria-labelledby={`${id}-g${gi}`} className={command.group}>
              <div id={`${id}-g${gi}`} role="presentation" className={command.heading}>{g.heading}</div>
              {g.items.map((it) => (
                <div key={it.value} role="option" id={`${id}-${it.value}`} aria-selected="false" data-value={it.value} data-keywords={it.keywords} className={command.option}>
                  {it.label}
                  {it.shortcut && <kbd className={command.shortcut}>{it.shortcut}</kbd>}
                </div>
              ))}
            </div>
          ))}
        </div>
        <p data-command-empty="" role="status" className={command.empty}></p>
      </div>
      <div className={command.footer} aria-hidden="true">
        <span><kbd className={command.key}>↑</kbd> <kbd className={command.key}>↓</kbd> to move</span>
        <span><kbd className={command.key}>↵</kbd> to run</span>
        <span><kbd className={command.key}>esc</kbd> to close</span>
      </div>
    </dialog>
  );
}
command-menu.variants.ts
import { dialog } from '../dialog/dialog.variants';

/** A command menu: a modal dialog near the top with a search field (combobox) and a list of commands (listbox),
 *  grouped. Typing filters, arrows move the highlight, Enter runs, Escape clears the field and then closes. Opened
 *  by its trigger or by ⌘K / Ctrl+K. */
export const command = {
  root: `${dialog.root} mt-[12vh] mb-auto max-w-lg`,
  search: 'flex items-center gap-2 border-b px-4',
  searchIcon: 'size-4 shrink-0 text-muted-foreground',
  input: 'h-12 w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
  list: 'max-h-80 overflow-y-auto p-2',
  group: 'py-1',
  heading: 'px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground',
  option:
    'flex h-10 cursor-pointer items-center gap-3 rounded-md px-2 text-sm select-none aria-selected:bg-accent active:bg-pressed [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground',
  shortcut: 'ml-auto font-sans text-[13px] text-muted-foreground',
  // Always present (a live region must exist before it speaks); takes no room while empty.
  empty: 'px-4 py-8 text-center text-sm text-muted-foreground empty:p-0',
  footer: 'flex flex-wrap items-center gap-x-4 gap-y-1 border-t bg-muted/50 px-4 py-2.5 text-xs text-muted-foreground',
  key: 'inline-flex h-5 min-w-5 items-center justify-center rounded border bg-background px-1 font-sans text-[11px] text-foreground',
};

export type CommandItem = { value: string; label: string; shortcut?: string; keywords?: string };
export type CommandGroup = { heading: string; items: CommandItem[] };
export const SEARCH_PATH = 'M7 12.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM11 11l3.5 3.5';