open atlas
← All projects

frontend · intermediate · 3d

Command palette

A ⌘K command palette with fuzzy ranking, async action sources, and complete keyboard control (arrows, Enter, Escape, scoping) — the interaction layer every power-user tool needs.

Command palettes are deceptively small but touch a surprising range of hard problems: fuzzy scoring that feels right, async sources without race conditions, nested navigation state, and accessible keyboard control. Build it once properly and you'll never reach for a library again — and you'll understand why every library gets one of these details wrong.

Deliverable

A palette that opens with ⌘K, fuzzy-filters registered commands as you type, supports nested scoping (e.g., entering a 'Theme >' submenu), and is fully keyboard-only operable.

Milestones

0/3 · 0%
  1. 01Registry + keyboard control

    Build a command registry with synchronous commands, open/close via ⌘K, and arrow + Enter + Escape keyboard handling.

    Definition of done
    • ⌘K opens/closes the palette; Arrow keys move the active item, Enter runs it, Escape closes — all without the mouse.
    • Focus is trapped inside the open palette and restored to the trigger on close.
  2. 02Fuzzy ranking

    Add fuzzy filtering with a scoring function that ranks exact-prefix matches above scattered matches, highlighting matched characters.

    Definition of done
    • Typing ranks exact-prefix matches above scattered subsequence matches, and matched characters are highlighted.
    • An empty query shows all commands; no match shows an explicit empty state.
  3. 03Async sources with debounce

    Support async action sources (e.g., a search API) with debouncing, a loading indicator, and graceful cancellation of stale requests.

    Definition of done
    • An async source is debounced, shows a loading state, and a stale in-flight request is cancelled when input changes.
    • Results from a cancelled request never overwrite results from the latest query.

Starter

  • README.md
  • src/palette.ts
  • test/palette.test.ts
Download starter (.zip)

Unzip, implement the stubs, then run the tests until they pass: bun test

Rubric

Junior Mid Senior
Match quality Filters items by substring inclusion; all matching items surface regardless of position in the string. Implements subsequence matching (not substring) and ranks prefix / contiguous matches above scattered ones so the intended item appears first. Scoring function is pure and separately testable; ties are broken deterministically; the function handles Unicode boundaries correctly and its time complexity is stated and justified (O(n·m) per item, total O(N·m log N)).
Keyboard model correctness Arrow keys move the selection and Enter triggers the highlighted item; wrapping is absent or breaks at boundaries. Selection wraps correctly in both directions including with a single-item or empty list; reduce is a pure function with no side effects. Reducer is the single source of truth for all keyboard state; focus management (trap on open, restore on close) is wired outside the reducer so the pure logic remains testable in Node without a DOM; edge cases (empty list, rapid key presses) are covered by unit tests.
Performance under load Re-ranks the full list on every keypress synchronously; no noticeable issue with short lists (< 50 items). Async sources are debounced; the ranking function is memoized or skipped when the query is unchanged; stale in-flight requests are cancelled. A list of 10 000+ commands renders without jank: either virtualized (only visible rows in the DOM) or pre-indexed (trie / inverted index built once on registration so per-keystroke work is sub-linear). The choice is justified with a measured trade-off between memory and CPU.
Reference walkthrough (spoiler)

Subsequence vs substring: fuzzy matching checks that every character of the query appears in the item in order, but not necessarily adjacently — 'fc' matches 'fuzzyCheck' because 'f' appears before 'c'. Levenshtein edit-distance is a different measure (minimum insertions/deletions/substitutions) that penalises gaps uniformly; subsequence scoring is cheaper (O(n) per candidate) and feels more natural for command search because it rewards matching the initials of camelCase words.

Keeping ranking a pure function: the scoring logic should take (items, query) and return a sorted array with no side effects — no state mutations, no DOM access, no async work. This boundary keeps it unit-testable in Node, makes memoization trivial (same inputs → same output), and allows the rendering layer to stay a thin view that only maps ranked results to DOM nodes.

Debouncing and cancellation for async sources: debounce delays invoking the async source until the user pauses typing (typically 150–250 ms), avoiding a network round trip on every keystroke. AbortController lets you cancel the in-flight fetch when the next keypress fires before the previous result arrives, preventing a slow response from an earlier query overwriting a faster response from a later one.

Virtualizing a large command list: rendering thousands of DOM nodes at once causes layout thrashing. A virtualizer (e.g. only rendering the 8–12 visible rows plus a small overscan buffer) keeps the DOM constant-size regardless of list length. The trade-off is scroll-position bookkeeping complexity; for lists under ~200 items the overhead of virtualization exceeds its benefit, so measure before adding it.

Make it senior

  • Add scoping: a command can push a new context (e.g., 'Switch project >') with its own filtered list, with breadcrumb navigation and Backspace to pop the scope.
  • Make the palette accessible: correct ARIA combobox + listbox roles, live region announcements for result count, and a focus trap that restores focus on close.

Skills

fuzzy search / scoring (fzf-style)async data sources with debouncefocus trap + ARIA comboboxcommand registry pattern

Suggested stack

preacttypescript