← Back to question bank
UI ComponentSeniorHard#3029 · 60m

Build a command palette

Design the behavior contract for a command palette. Focus on state, keyboard interaction, empty/loading/error states, and how the component composes with product data.

Answer Strategy

Command palette is the question that exposes whether you split ranking from rendering. The hard parts are not the modal — they are the scoring function (prefix, word-boundary, subsequence, keyword), the keyboard contract (arrows + Enter inside the modal, Escape closes, focus restores), and the shortcut binding that opens the palette globally without stealing focus from inputs.

Make the ranker pure. rankCommands(commands, query) returns sorted commands and is unit-tested without a DOM. The component owns query state, active index, focus management, and dispatch. The parent owns the global keybinding (Cmd+K, /), the command registry, and side effects when a command runs.

Volunteer the failures. Without prefix vs subsequence weighting the most relevant commands sink under fuzzy matches. Without focus restoration the user is dumped at the top of the page after running a command. Without an Escape handler that calls onClose synchronously, the dialog can race with route changes during a perform. Without aria-modal=true and a labelled dialog, screen readers do not announce the palette as a modal layer.

Reference Implementation: Command Palette With Pure Ranker

rankCommands is framework-free. The CommandPalette component owns dialog semantics, focus restoration, and dispatch.

type Command = {
  id: string;
  label: string;
  group: string;
  keywords?: string[];
  perform: () => void | Promise<void>;
};

// Pure ranking: the same function is unit-tested without a DOM. Match priority
// is exact prefix, then word boundary, then character subsequence. Keywords
// extend the searchable surface without bloating the visible label.
function score(command: Command, query: string): number {
  if (!query) return 1;
  const target = (command.label + ' ' + (command.keywords ?? []).join(' ')).toLowerCase();
  const q = query.toLowerCase();
  if (target.startsWith(q)) return 5;
  if (target.split(/\s+/).some((part) => part.startsWith(q))) return 3;
  let cursor = 0;
  for (const char of q) {
    const found = target.indexOf(char, cursor);
    if (found < 0) return 0;
    cursor= found + 1;
  }
  return 1;
}

function rankCommands(commands: Command[], query: string): Command[] {
  return commands
    .map((command)=> ({ command, weight: score(command, query) }))
    .filter((entry)=> entry.weight > 0)
    .sort((a, b) => b.weight - a.weight)
    .map((entry) => entry.command);
}

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

export function CommandPalette({ open, onClose, commands }: CommandPaletteProps) {
  const [query, setQuery] = React.useState('');
  const [activeIndex, setActiveIndex] = React.useState(0);
  const inputRef = React.useRef<HTMLInputElement | null>(null);
  const previousActive = React.useRef<HTMLElement | null>(null);
  const titleId = React.useId();

  const ranked = React.useMemo(() => rankCommands(commands, query), [commands, query]);

  React.useEffect(() => {
    if (!open) return;
    previousActive.current = document.activeElement as HTMLElement | null;
    inputRef.current?.focus();
    setActiveIndex(0);

    function onKey(event: KeyboardEvent) {
      // Cmd/Ctrl+K toggles the palette globally; the parent owns the binding.
      if (event.key === 'Escape') {
        event.preventDefault();
        onClose();
      }
    }
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('keydown', onKey);
      previousActive.current?.focus();
    };
  }, [open, onClose]);

  if (!open) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby={titleId}
      onMouseDown={(event)=> {
        if (event.target= event.currentTarget) onClose();
      }}
    >
      <div className="palette">
        <h2 id={titleId} className="visually-hidden">Command palette</h2>
        <input
          ref={inputRef}
          type="text"
          role="combobox"
          aria-expanded
          aria-controls="command-list"
          aria-activedescendant={'command-' + activeIndex}
          placeholder="Search commands"
          value={query}
          onChange={(event)=> {
            setQuery(event.target.value);
            setActiveIndex(0);
          }}
          onKeyDown={(event)=> {
            if (event.key= 'ArrowDown') {
              event.preventDefault();
              setActiveIndex((index)=> Math.min(index + 1, ranked.length - 1));
            } else if (event.key= 'ArrowUp') {
              event.preventDefault();
              setActiveIndex((index)=> Math.max(0, index - 1));
            } else if (event.key= 'Enter') {
              event.preventDefault();
              const command= ranked[activeIndex];
              if (command) {
                command.perform();
                onClose();
              }
            }
          }}
        />
        <ul id="command-list" role="listbox">
          {ranked.map((command, index) => (
            <li
              key={command.id}
              id={'command-' + index}
              role="option"
              aria-selected={index= activeIndex}
              onMouseEnter={()=> setActiveIndex(index)}
              onMouseDown={(event)=> {
                event.preventDefault();
                command.perform();
                onClose();
              }}
            >
              <span>{command.label}</span>
              <span className="muted">{command.group}</span>
            </li>
          ))}
          {ranked.length === 0 && <li role="status">No matching command</li>}
        </ul>
      </div>
    </div>
  );
}

Executable UI Sandbox

UI interview practice should behave like component documentation, not a static snippet. This uses the same isolation pattern as Storybook, Sandpack, CodeSandbox, and StackBlitz: editable source on one side, a sandboxed browser preview on the other. Edit the DOM code, run it, and verify focus, keyboard, pointer, and state behavior in the preview.

Browser sandbox
HTML, CSS, DOM events, focus, and keyboard behavior
Preview running...
Loading editor...

Runnable Playground

Edit the implementation and run the tests directly in the browser. For system design questions, the playground focuses on the core state/data logic that the UI would call.


type Command = {
  id: string;
  label: string;
  group: string;
  keywords?: string[];
  perform: () => void | Promise<void>;
};

function score(command: Command, query: string): number {
  if (!query) return 1;
  const target = (command.label + ' ' + (command.keywords ?? []).join(' ')).toLowerCase();
  const q = query.toLowerCase();
  if (target.startsWith(q)) return 5;
  if (target.split(/\s+/).some((part) => part.startsWith(q))) return 3;
  let cursor = 0;
  for (const char of q) {
    const found = target.indexOf(char, cursor);
    if (found < 0) return 0;
    cursor = found + 1;
  }
  return 1;
}

function rankCommands(commands: Command[], query: string): Command[] {
  return commands
    .map((command) => ({ command, weight: score(command, query) }))
    .filter((entry) => entry.weight > 0)
    .sort((a, b) => b.weight - a.weight)
    .map((entry) => entry.command);
}
TypeScript · runnable

Testing Strategy

Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.

Ranker
Pure tests for empty query, prefix priority, word-boundary boost, subsequence fallback, and keyword extension. No DOM needed.
Modal
Assert role=dialog, aria-modal, aria-labelledby. Test focus moves to the input on open, Escape closes, and focus restores to the opener on close.
Dispatch
Test mouse and keyboard activation, including that mousedown on a row dispatches without dismissing first via input blur. Verify perform errors surface to the parent.
import { describe, it, expect } from 'vitest';

describe('rankCommands', () => {
  const commands = [
    { id: 'open-file', label: 'Open File', group: 'File', perform: () => {} },
    { id: 'open-recent', label: 'Open Recent', group: 'File', keywords: ['history'], perform: () => {} },
    { id: 'save', label: 'Save', group: 'File', perform: () => {} },
  ];

  it('returns all commands when query is empty', () => {
    expect(rankCommands(commands, '')).toHaveLength(3);
  });

  it('prioritizes prefix matches over subsequence matches', () => {
    const ranked = rankCommands(commands, 'open');
    expect(ranked[0].id).toBe('open-file');
  });

  it('matches via keywords', () => {
    const ranked = rankCommands(commands, 'history');
    expect(ranked[0].id).toBe('open-recent');
  });

  it('returns empty when query has no subsequence match', () => {
    expect(rankCommands(commands, 'zzz')).toEqual([]);
  });
});

Interviewer Signal

Shows whether you can build components as interaction systems rather than visual boxes.

Constraints

  • Name the controlled and uncontrolled state.
  • Define keyboard and focus behavior.
  • Include loading, empty, disabled, and error states.

Model Answer Shape

  • Start with the accessibility role and interaction contract.
  • Separate rendering slots from state management.
  • Expose callbacks that describe user intent, not internal implementation details.

Tradeoffs

  • A headless primitive is reusable but slower to consume.
  • A product-specific component ships faster but can trap behavior in one use case.

Edge Cases

  • Focus after close, selection, deletion, or route change.
  • Large datasets and slow network responses.
  • Screen reader labels and live updates.

Testing And Proof

  • Keyboard path through the primary workflow.
  • A11y names, descriptions, and roles.
  • State transition after slow or failed data load.

Follow-Ups

  • How would this component be documented in a design system?
  • What props would you refuse to expose?