Skip to the solution
MediumFrontend13 min read

Build a Command Palette (kbar / cmdk) with Fuzzy Search

847 views

The problem

Question presented to candidate: "Power users want to drive our dashboard from the keyboard. Build a command palette like the ones in Linear or VS Code: Ctrl+K or Cmd+K opens it, you type a few letters such as 'gs' and it finds 'Go to Settings', arrow keys move, Enter runs the command, Escape closes. Write the fuzzy matching yourself instead of pulling in cmdk or kbar."

Requirements:

  • Global hotkeys: Ctrl+K / Cmd+K toggles the palette; / opens it unless the user is typing in a field.
  • A modal overlay with a search input and results grouped (Navigation, Actions, Help), plus a Recent group when the query is empty.
  • A hand-written fuzzy matcher: query letters must appear in order; word starts and consecutive letters rank higher; matched letters are highlighted; keywords such as "theme" also find "Toggle dark mode".
  • Keyboard: ArrowUp / ArrowDown (wrapping), Enter runs, Escape closes, Tab stays inside; mouse hover and click work too.
  • Accessibility: role="dialog" with aria-modal, a combobox input with aria-activedescendant, a listbox of options, focus returned to the previously focused element on close.

What a strong answer should cover:

  • A command registry as plain data (id, label, group, keywords, shortcut, perform) separate from the UI.
  • The matcher: subsequence check, a scoring scheme that can be explained, and how highlighting indices come out of it.
  • Why focus stays in the input and the active option is announced with aria-activedescendant instead of moving focus.
  • Hotkey hygiene: preventDefault for Ctrl+K, ignoring / inside inputs, cleaning up the global listener.
  • Trade-offs versus a library (cmdk, kbar) and what a production matcher adds.

Clarifying questions expected:

  • "How many commands: tens or thousands? Do results come from a server?"
  • "Should Ctrl+K inside the palette close it, or be ignored?"
  • "Do commands have sub-pages (for example Change theme, then pick one)?"

Code / implementation expected: Yes. A fuzzy scoring function, a palette component with keyboard handling, and a host app with a few commands that visibly do something.

  • React
  • Command Palette
  • kbar
  • cmdk
  • Fuzzy Search

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

13 min read ยท 10 sections

React solution

Every file of the working solution. Open it in the playground to run and change it.

ReactComplete solution
import { useCallback, useEffect, useMemo, useState } from "react";
import CommandPalette from "./src/components/CommandPalette";

const isTypingTarget = (el) =>
  el &&
  (el.tagName === "INPUT" ||
    el.tagName === "TEXTAREA" ||
    el.tagName === "SELECT" ||
    el.isContentEditable);

export default function App() {
  const [open, setOpen] = useState(false);
  const [page, setPage] = useState("Dashboard");
  const [dark, setDark] = useState(true);
  const [lastAction, setLastAction] = useState("none yet");
  const [recentIds, setRecentIds] = useState([]);

  // The registry: plain data plus a perform() callback per command.
  const commands = useMemo(() => {
    const go = (name) => () => {
      setPage(name);
      setLastAction("Opened " + name);
    };
    return [
      {
        id: "dashboard",
        group: "Navigation",
        label: "Go to Dashboard",
        shortcut: "G D",
        perform: go("Dashboard"),
      },
      {
        id: "settings",
        group: "Navigation",
        label: "Go to Settings",
        shortcut: "G S",
        perform: go("Settings"),
      },
      {
        id: "billing",
        group: "Navigation",
        label: "Go to Billing",
        perform: go("Billing"),
      },
      {
        id: "team",
        group: "Navigation",
        label: "Go to Team members",
        perform: go("Team members"),
      },
      {
        id: "project",
        group: "Actions",
        label: "Create new project",
        keywords: ["add"],
        perform: () => setLastAction("Created a project"),
      },
      {
        id: "invite",
        group: "Actions",
        label: "Invite teammate",
        keywords: ["add user"],
        perform: () => setLastAction("Invite sent"),
      },
      {
        id: "theme",
        group: "Actions",
        label: "Toggle dark mode",
        keywords: ["theme"],
        shortcut: "Ctrl T",
        perform: () => {
          setDark((d) => !d);
          setLastAction("Toggled theme");
        },
      },
      {
        id: "copy",
        group: "Actions",
        label: "Copy current URL",
        keywords: ["link", "share"],
        perform: () => setLastAction("Copied URL"),
      },
      {
        id: "logout",
        group: "Actions",
        label: "Log out",
        keywords: ["sign out", "exit"],
        perform: () => setLastAction("Logged out"),
      },
      {
        id: "docs",
        group: "Help",
        label: "Open documentation",
        perform: () => setLastAction("Opened docs"),
      },
      {
        id: "keys",
        group: "Help",
        label: "Keyboard shortcuts",
        perform: () => setLastAction("Showed shortcuts"),
      },
      {
        id: "support",
        group: "Help",
        label: "Contact support",
        perform: () => setLastAction("Opened support"),
      },
    ];
  }, []);

  // Global hotkeys: Ctrl+K or Cmd+K toggles, "/" opens unless you are typing.
  useEffect(() => {
    function onKeyDown(e) {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
        e.preventDefault();
        setOpen((o) => !o);
      } else if (
        e.key === "/" &&
        !e.metaKey &&
        !e.ctrlKey &&
        !e.altKey &&
        !isTypingTarget(e.target)
      ) {
        e.preventDefault();
        setOpen(true);
      }
    }
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, []);

  const run = useCallback((command) => {
    setOpen(false);
    setRecentIds((ids) =>
      [command.id, ...ids.filter((id) => id !== command.id)].slice(0, 3),
    );
    command.perform();
  }, []);
  const close = useCallback(() => setOpen(false), []);

  return (
    <div className={"app " + (dark ? "dark" : "light")} data-testid="app">
      <header className="topbar">
        <strong>Acme</strong>
        <button
          className="trigger"
          data-testid="open-palette"
          onClick={() => setOpen(true)}
        >
          Search commands <kbd>Ctrl K</kbd>
        </button>
      </header>
      <main className="content">
        <h1 data-testid="page">{page}</h1>
        <p data-testid="status">
          Theme: {dark ? "dark" : "light"} | Last action: {lastAction}
        </p>
        <label className="note">
          Quick note (typing / here does not open the palette)
          <input data-testid="note" placeholder="Try typing /" />
        </label>
      </main>
      {open && (
        <CommandPalette
          commands={commands}
          recentIds={recentIds}
          onRun={run}
          onClose={close}
        />
      )}
    </div>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 38 of 107 in Machine coding

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

Build a Tabs Component with URL Sync & Lazy Mounting

Continue