ESC

Type to search the knowledge base.

Command Palette

Filter a command list in a dialog, arrow keys, Enter to run, Escape to close, and a focus trap so Tab never leaks to the page.

advanced8 min read
  • machine-coding
  • interview
  • a11y
  • react

Cmd+K that opens a pretty list and lets Tab walk into the page behind it is a popup, not a palette. A command palette is a modal dialog: filter the list, move a highlight with arrows, Enter runs the command, Escape closes and restores focus to the opener. Tab never leaks.

This round is the accessible modal plus a listbox, not a new search backend. Filter is label substring. Highlight is an index into the filtered array. When the filter shrinks, clamp that index. Forgetting the clamp is how Enter runs a command that is no longer on screen.

Key takeaways

  • Cmd+K / Ctrl+K opens. preventDefault so the browser find bar does not steal it.
  • role="dialog" and aria-modal="true". Escape restores focus to the opener.
  • Filter by label. Highlight index is clamped to the filtered list.
  • Enter runs the highlighted command and closes. Options are not tab stops.
  • Focus trap: Tab cycles the input and the close button. It never reaches the page.

Filter a command list, arrow keys, Enter, and focus trap in a dialog

Commands are data:

type Command = {
  id: string;
  label: string;
  group?: string;
  run: () => void;
};

function filterCommands(commands: readonly Command[], query: string) {
  const q = query.trim().toLowerCase();
  if (!q) return commands.slice();
  return commands.filter((c) => c.label.toLowerCase().includes(q));
}

Empty query shows the full list. No matches is an empty list plus a status text, not a crash on filtered[0].

Highlight:

function clampIndex(i: number, n: number) {
  if (n <= 0) return 0;
  return Math.min(n - 1, Math.max(0, i));
}

function moveHighlight(i: number, n: number, delta: number) {
  if (n <= 0) return 0;
  return (i + delta + n) % n;
}

Wrapping arrows feel like a palette. Clamping at the ends is also fine. Pick one. Reset the highlight to 0 when the query string changes, or clamp. Resetting to 0 is easier to explain.

Open/close is the modal checklist:

  1. Save document.activeElement.
  2. Move focus to the filter input.
  3. Trap Tab inside the dialog.
  4. Escape or successful Enter: close, restore the opener.
  5. aria-modal="true" on role="dialog".

Native <dialog>.showModal() gives top layer, Escape in many browsers, and a focus trap. Implementing the trap by hand is what they score if the prompt says “don’t use dialog.” Mention both. The dialog element and the APG modal pattern are the two legal sources.

Focus trap (same shape as the modal page, tighter list of tabbables):

function useFocusTrap(
  rootRef: React.RefObject<HTMLElement | null>,
  enabled: boolean
) {
  useEffect(() => {
    if (!enabled) return;

    function onKeyDown(e: KeyboardEvent) {
      if (e.key !== "Tab") return;
      const root = rootRef.current;
      if (!root) return;
      const nodes = root.querySelectorAll<HTMLElement>(
        'button:not([disabled]), input:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'
      );
      const list = [...nodes].filter((el) => el.offsetParent !== null);
      if (list.length === 0) {
        e.preventDefault();
        root.focus();
        return;
      }
      const first = list[0];
      const last = list[list.length - 1];
      const active = document.activeElement;
      if (e.shiftKey && active === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && active === last) {
        e.preventDefault();
        first.focus();
      }
    }

    document.addEventListener("keydown", onKeyDown, true);
    return () => document.removeEventListener("keydown", onKeyDown, true);
  }, [rootRef, enabled]);
}

Capture phase (true) beats inner listeners that might run first. Options in the list must not be in that tab set. If each row is tabIndex={0}, Tab walks the whole catalog and then the page. Rows are mousedown/click plus aria-activedescendant from the input. That is the combobox pairing inside a dialog, cousin of autocomplete, without a network.

Global shortcut:

function useCommandPaletteHotkey(onToggle: () => void) {
  useEffect(() => {
    function onKeyDown(e: KeyboardEvent) {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
        e.preventDefault();
        onToggle();
      }
    }
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, [onToggle]);
}

preventDefault is required. Otherwise Chrome/Firefox find-in-page eats Cmd+K. Do not also bind / unless you handle inputs: / in a text field is a slash.

Problem statement

Build a command palette. Cmd/Ctrl+K toggles a modal dialog. The user filters commands, moves with arrows, runs with Enter, closes with Escape. Focus returns to the control that opened it. Tab cannot reach the rest of the app while it is open.

Requirements

Must have

  • Cmd/Ctrl+K to open (and prevent browser find)
  • Dialog: role="dialog" aria-modal="true" labelled by title
  • Filter input, list of matching commands
  • Arrow keys move highlight, Enter runs and closes
  • Escape closes, focus restore
  • Focus trap
  • Click a row runs and closes

Should have

  • aria-activedescendant on the input
  • No-results status
  • Scroll the highlighted row into view
  • Close button with an accessible name

Nice to have

  • Group headings
  • Fuzzy filter
  • Recent commands
  • Nested <dialog>.showModal() variant

Planning (5 minutes out loud)

  1. Modal shell first: open, trap, Escape, restore
  2. Filter + list with a highlight index
  3. Enter / click run then close
  4. Hotkey with preventDefault
  5. Do not make every option a tab stop

Architecture

CommandPalette (portal)
├── Backdrop
└── Dialog
    ├── Title + Close
    ├── Input (combobox)
    └── Listbox (options, not tabbable)

API

type CommandPaletteProps = {
  commands: readonly Command[];
  open: boolean;
  onOpenChange: (open: boolean) => void;
};

Controlled open lets the hotkey live in a parent that also owns the trigger button. Uncontrolled with an internal toggle is acceptable if you still restore focus.

Implementation sketch

function CommandPalette({
  commands,
  open,
  onOpenChange,
}: CommandPaletteProps) {
  const [query, setQuery] = useState("");
  const [highlight, setHighlight] = useState(0);
  const panelRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const openerRef = useRef<HTMLElement | null>(null);
  const titleId = useId();
  const listId = useId();
  const filtered = filterCommands(commands, query);
  const active = filtered[highlight];
  const activeId = active ? `${listId}-${active.id}` : undefined;

  useFocusTrap(panelRef, open);

  useEffect(() => {
    if (!open) return;
    openerRef.current = document.activeElement as HTMLElement | null;
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    inputRef.current?.focus();
    function onKey(e: KeyboardEvent) {
      if (e.key === "Escape") {
        e.stopPropagation();
        onOpenChange(false);
      }
    }
    document.addEventListener("keydown", onKey);
    return () => {
      document.body.style.overflow = prevOverflow;
      document.removeEventListener("keydown", onKey);
      openerRef.current?.focus?.();
    };
  }, [open, onOpenChange]);

  useEffect(() => {
    setHighlight(0);
  }, [query]);

  function run(cmd: Command | undefined) {
    if (!cmd) return;
    onOpenChange(false);
    cmd.run();
  }

  if (!open) return null;

  return createPortal(
    <div className="palette-root">
      <div className="palette-backdrop" onClick={() => onOpenChange(false)} />
      <div
        ref={panelRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        className="palette-panel"
        onKeyDown={(e) => {
          if (e.key === "ArrowDown") {
            e.preventDefault();
            setHighlight((i) => moveHighlight(i, filtered.length, 1));
          }
          if (e.key === "ArrowUp") {
            e.preventDefault();
            setHighlight((i) => moveHighlight(i, filtered.length, -1));
          }
          if (e.key === "Enter") {
            e.preventDefault();
            run(filtered[highlight]);
          }
        }}
      >
        <h2 id={titleId}>Command palette</h2>
        <button type="button" aria-label="Close" onClick={() => onOpenChange(false)}>
          Close
        </button>
        <input
          ref={inputRef}
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          role="combobox"
          aria-expanded="true"
          aria-controls={listId}
          aria-activedescendant={activeId}
          aria-autocomplete="list"
          placeholder="Type a command"
        />
        <ul id={listId} role="listbox" aria-label="Commands">
          {filtered.map((cmd, i) => (
            <li
              key={cmd.id}
              id={`${listId}-${cmd.id}`}
              role="option"
              aria-selected={i === highlight}
              onMouseEnter={() => setHighlight(i)}
              onClick={() => run(cmd)}
            >
              {cmd.label}
            </li>
          ))}
        </ul>
        {filtered.length === 0 ? (
          <p role="status">No commands match</p>
        ) : null}
      </div>
    </div>,
    document.body
  );
}

preventDefault on arrows stops the input caret from jumping. preventDefault on Enter stops a wrapping <form> from submitting.

Run then close: close first so focus restore happens, then cmd.run(). If run opens another dialog, restoring the opener and then moving into the next modal is the right order. If run is slow, still close; do not leave the palette up as a spinner unless they ask.

Portal to document.body avoids overflow: hidden ancestors clipping the overlay. Same as the modal brief.

When filtered is empty, Enter is a no-op. Do not run commands[highlight] from the unfiltered list.

Accessibility essentials

Piece Approach
Dialog role="dialog" aria-modal="true" aria-labelledby
Filter Label or aria-label, role="combobox" + listbox
Active row aria-activedescendant + aria-selected
Keyboard Arrows, Enter, Escape, Tab trap
Restore Opener focused on close
Empty role="status" “No commands match”
Backdrop Click may close; never the only close

Focus management is the opener/restore story. Accessible modals patterns is the rest of the APG notes. Body scroll lock while open.

Do not put aria-modal on a non-dialog. Do not use role="menu" here: menus do not filter by typing into a text field the same way. Combobox + listbox is the honest pairing.

Interview scoring

Signal What they want
Hotkey Cmd and Ctrl, preventDefault
Dialog Role, modal, title, restore
Trap Tab cannot reach the app
Filter Label match, clamp/reset highlight
Enter Runs highlighted, closes
Options Not tab stops

They will Tab a few times and watch the focus ring. If it lands on a link in the footer, you failed. They will type until one item remains and press Enter. If you still run item 4 from the old list, you failed.

Footguns

  1. No preventDefault on Cmd+K, browser find opens
  2. Tabbable options, trap is a lie
  3. Highlight past filtered.length - 1 after a tighter query
  4. Escape without restore
  5. Running on Enter when the list is empty
  6. Stale onToggle in the hotkey effect if you omit deps or wrap without useCallback

IME: do not treat every keydown as a filter change. The input’s onChange already sees the composed value.

Interview out-loud answer

I would treat the palette as a modal dialog opened with Cmd or Ctrl+K, calling preventDefault so find-in-page does not win. On open I save the opener, focus the filter input, lock body scroll, and trap Tab between the input and close. The list is a listbox; the input is a combobox with aria-activedescendant. Filtering is a case-insensitive label include. Arrows move a clamped highlight. Enter runs that command and closes. Escape closes and restores focus. Options are not tab stops, so Tab never leaks to the page.

Further reading