DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Building a Keyboard-First Command Palette in the Frontend

Building a Keyboard-First Command Palette in the Frontend

Building a Keyboard-First Command Palette in the Frontend

A command palette is one of the highest-leverage interface patterns you can add to a web app: it turns scattered actions into a fast, searchable, keyboard-friendly entry point. This tutorial shows how to build one from scratch with real code patterns, including focus management, filtering, shortcuts, and accessible behavior.

Why this pattern matters

Command palettes are useful when an app has more than a handful of actions, routes, or tools. They reduce pointer hunting and give power users a faster path through the interface, similar to the way IDEs and modern product tools centralize actions behind a keyboard shortcut.

They also fit well in frontend architecture because the component is self-contained: you can mount it once, feed it a command list, and reuse it across pages. That makes it easier to test, extend, and keep consistent than sprinkling buttons everywhere.

What we are building

We will build a palette that supports:

  • Ctrl/Cmd + K to open and close.
  • Search-as-you-type filtering.
  • Arrow-key navigation.
  • Enter to run a command.
  • Escape to close.
  • A click-to-open trigger for mouse users.

The implementation below uses plain React-style patterns, but the same structure works in Vue, Svelte, or vanilla JavaScript.

Core data shape

Start with a simple command model. Keep labels, keywords, and actions together so filtering and execution stay close to the data. That avoids brittle conditionals later.

type Command = {
  id: string;
  label: string;
  description?: string;
  keywords?: string[];
  run: () => void;
};

const commands: Command[] = [
  {
    id: "new-project",
    label: "Create project",
    description: "Start a new workspace",
    keywords: ["new", "create", "project", "workspace"],
    run: () => console.log("Create project"),
  },
  {
    id: "open-settings",
    label: "Open settings",
    description: "Adjust app preferences",
    keywords: ["settings", "preferences", "config"],
    run: () => console.log("Open settings"),
  },
  {
    id: "invite-user",
    label: "Invite teammate",
    description: "Send an invite link",
    keywords: ["invite", "team", "user", "share"],
    run: () => console.log("Invite teammate"),
  },
];
Enter fullscreen mode Exit fullscreen mode

A clean command object gives you one place to store user-facing text, search terms, and behavior. That makes it easier to localize or instrument later.

Filtering logic

Use normalized string matching rather than exact labels only. Users often type partial words, and keyword fields make the palette feel much smarter without adding much complexity.

function normalize(value: string) {
  return value.toLowerCase().trim();
}

function filterCommands(all: Command[], query: string) {
  const q = normalize(query);
  if (!q) return all;

  return all.filter((command) => {
    const haystack = [
      command.label,
      command.description ?? "",
      ...(command.keywords ?? []),
    ]
      .join(" ")
      .toLowerCase();

    return haystack.includes(q);
  });
}
Enter fullscreen mode Exit fullscreen mode

For larger palettes, you can swap includes for ranked matching, but this version is easy to understand and good enough for most apps. Keep the first implementation simple so the interaction is easy to trust.

React component

This component manages open state, input text, keyboard navigation, and command execution. It also closes on Escape and resets the selection when the query changes.

import React, { useEffect, useMemo, useRef, useState } from "react";

export function CommandPalette({ commands }: { commands: Command[] }) {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [activeIndex, setActiveIndex] = useState(0);
  const inputRef = useRef<HTMLInputElement | null>(null);

  const filtered = useMemo(
    () => filterCommands(commands, query),
    [commands, query]
  );

  useEffect(() => {
    if (open) {
      requestAnimationFrame(() => inputRef.current?.focus());
    }
  }, [open]);

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

  useEffect(() => {
    function onKeyDown(event: KeyboardEvent) {
      const isMac = navigator.platform.toUpperCase().includes("MAC");
      const hotkey = isMac ? event.metaKey : event.ctrlKey;

      if (hotkey && event.key.toLowerCase() === "k") {
        event.preventDefault();
        setOpen((value) => !value);
      }

      if (!open) return;

      if (event.key === "Escape") {
        event.preventDefault();
        setOpen(false);
      }

      if (event.key === "ArrowDown") {
        event.preventDefault();
        setActiveIndex((i) => Math.min(i + 1, filtered.length - 1));
      }

      if (event.key === "ArrowUp") {
        event.preventDefault();
        setActiveIndex((i) => Math.max(i - 1, 0));
      }

      if (event.key === "Enter" && filtered[activeIndex]) {
        event.preventDefault();
        filtered[activeIndex].run();
        setOpen(false);
      }
    }

    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [activeIndex, filtered, open]);

  return (
    <>
      <button onClick={() => setOpen(true)}>Open command palette</button>

      {open && (
        <div role="dialog" aria-modal="true" className="palette">
          <label htmlFor="command-search" className="sr-only">
            Search commands
          </label>

          <input
            id="command-search"
            ref={inputRef}
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="Type a command..."
          />

          <ul role="listbox" aria-label="Commands">
            {filtered.map((command, index) => (
              <li
                key={command.id}
                role="option"
                aria-selected={index === activeIndex}
                className={index === activeIndex ? "active" : ""}
                onMouseEnter={() => setActiveIndex(index)}
                onMouseDown={(e) => e.preventDefault()}
                onClick={() => {
                  command.run();
                  setOpen(false);
                }}
              >
                <strong>{command.label}</strong>
                {command.description && <span>{command.description}</span>}
              </li>
            ))}
          </ul>
        </div>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

A few details matter here. onMouseDown={(e) => e.preventDefault()} keeps the input from losing focus before the click handler runs, which avoids a common “click causes blur” bug. The aria roles help screen readers interpret the component as a search-driven list of selectable actions.

Better keyboard behavior

The biggest mistake teams make is handling only the open/close shortcut and forgetting the rest of the interaction model. A real palette should let users move through results without leaving the keyboard, and it should behave predictably when the result set changes.

Here are the patterns to keep:

  1. Open with one shortcut.
  2. Focus the search field immediately.
  3. Keep arrow navigation bounded to the available results.
  4. Run the selected action with Enter.
  5. Close with Escape.
  6. Reset selection when the query changes.

Those rules make the component feel stable instead of “clever.” That predictability is what turns it into a productivity tool rather than a novelty.

Accessible markup

A command palette is a modal-like surface, so the structure should communicate that to assistive technologies. A dialog container with a labeled search field and listbox-style results is a practical pattern for this kind of interface.

A few accessibility details to keep in mind:

  • Give the input a real label, even if it is visually hidden.
  • Ensure the close button is reachable by keyboard.
  • Make sure focus lands somewhere useful when the palette opens.
  • Do not rely on color alone to show the active item.
  • Keep text in the results concise so screen readers do not have to wade through noise.

These are basic, but they prevent the palette from becoming a keyboard trap or a screen-reader dead end.

Styling approach

The palette should feel like a floating utility layer rather than a full page. Keep the layout simple: a centered panel, a high-contrast active row, and enough spacing for quick scanning.

.palette {
  position: fixed;
  inset: 10vh auto auto 50%;
  transform: translateX(-50%);
  width: min(640px, calc(100vw - 2rem));
  background: white;
  border: 1px solid #ddd;
  border-radius: 16px;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18);
  padding: 1rem;
}

.palette input {
  width: 100%;
  padding: 0.85rem 1rem;
  font-size: 1rem;
  margin-bottom: 0.75rem;
}

.palette ul {
  list-style: none;
  margin: 0;
  padding: 0;
  max-height: 360px;
  overflow: auto;
}

.palette li {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.75rem 1rem;
  border-radius: 10px;
  cursor: pointer;
}

.palette li.active {
  background: #111827;
  color: white;
}
Enter fullscreen mode Exit fullscreen mode

A good palette should feel fast to read. When the active item is obvious, users can move through commands almost as quickly as they can think of them.

Adding sections

Most real apps need more than one command group. You might want routes, project actions, and account actions separated into sections, with headers and different empty states.

const sections = [
  {
    title: "Navigation",
    commands: [
      { id: "home", label: "Go home", run: () => console.log("home") },
      { id: "billing", label: "Open billing", run: () => console.log("billing") },
    ],
  },
  {
    title: "Actions",
    commands: [
      { id: "save", label: "Save draft", run: () => console.log("save") },
      { id: "export", label: "Export data", run: () => console.log("export") },
    ],
  },
];
Enter fullscreen mode Exit fullscreen mode

In the UI, render the section title above each filtered group and keep the active index logic per section or flatten the list with section metadata. Flattening is often simpler because it makes keyboard traversal uniform.

Production patterns

Once the basic version works, the next step is making it durable. Cache expensive computed results, prevent duplicate global listeners, and keep command definitions close to the feature they control.

A few practical upgrades:

  • Add recent commands so commonly used actions appear first.
  • Track empty-query behavior separately from no-results behavior.
  • Debounce search only if your command list is very large.
  • Preload code for heavy commands like settings or reports.
  • Write tests for shortcut handling and selection movement.

These upgrades keep the palette responsive as your app grows. They also stop a simple UI pattern from becoming an accidental performance or maintainability problem.

Testing the component

Test the interaction, not just the markup. The highest-value checks are opening the palette, filtering results, moving selection with the keyboard, and executing the selected command.

A useful test plan looks like this:

  1. Press Ctrl/Cmd + K and verify the palette opens.
  2. Type a partial term and verify filtering.
  3. Press ArrowDown and ArrowUp and verify selection changes.
  4. Press Enter and verify the command runs.
  5. Press Escape and verify the dialog closes.

That set of tests protects the feature against the regressions users notice immediately. The palette is an interaction surface, so behavior matters more than snapshot coverage.

A practical extension

A great next step is linking the palette to actual app routes and actions. For example, one command can navigate to /settings, another can open a modal, and a third can trigger a lightweight action like copying an invite link.

const commands: Command[] = [
  {
    id: "go-settings",
    label: "Settings",
    keywords: ["preferences", "config"],
    run: () => router.push("/settings"),
  },
  {
    id: "copy-link",
    label: "Copy invite link",
    keywords: ["share", "invite", "link"],
    run: async () => {
      await navigator.clipboard.writeText("https://app.example.com/invite");
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

That is where the pattern becomes genuinely valuable: it stops being a demo and becomes a universal entry point for the product.

-

Rizwan Saleem | https://rizwansaleem.co

Sources

Top comments (0)