Skip to content
    Style: Wireframe

    Components · Overlays

    Command menu

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

    9.6% of the 2,073 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/ui/r/command-menu.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/command-menu.json

    Or copy the files below into your project. They need clsx, tailwind-merge, and the themes.dev UI 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';