Use Cases (Updated: 6/2/2026)

Build a React Command Palette with Claude Code: Search, ARIA, and Keyboard Navigation

Build an accessible React command palette with Claude Code: search, keyboard navigation, ARIA roles, and copy-paste TS code.

Build a React Command Palette with Claude Code: Search, ARIA, and Keyboard Navigation

A command palette is a fast path through your product

A command palette is the small Cmd+K or Ctrl+K window that lets users search actions instead of walking through navigation. You see it in VS Code, Linear, Slack, and Notion because it gives experienced users a direct route to the next task.

The trap is building only the visual shell. A production command palette also needs keyboard navigation, focus management, ARIA roles, search filtering that does not freeze typing, and guardrails for risky actions. ARIA means the attributes that explain UI meaning to assistive technology. A combobox is an input paired with a list of suggestions. Those terms matter because a command palette is not just a modal; it is an interactive search control inside a modal dialog.

When Masa first tested this pattern for a small editorial admin screen, the first generated version looked fine but failed in practical ways: Enter fired while Japanese IME composition was still active, the selected option was not announced to screen readers, and an empty result set could still try to run the previous command. The implementation below fixes those details and is designed for Claude Code review.

For related foundations, read keyboard shortcuts, accessibility implementation, and performance optimization. Official references worth keeping open are React useDeferredValue, React useMemo, WAI-ARIA Combobox Pattern, WAI-ARIA Modal Dialog Pattern, and the Claude Code docs.

Implementation plan

This guide uses no UI dependency so the moving parts are easy to audit. Libraries such as cmdk can be a good choice later, but first make the responsibilities explicit.

RequirementImplementation detail
Open quicklyGlobal Cmd+K / Ctrl+K shortcut
SearchFilter by label, category, description, and keywords
ResponsivenessuseDeferredValue keeps typing responsive; useMemo caches filtered results
Keyboard supportArrow keys, Home, End, Enter, Escape, and Tab are handled
ARIAdialog, combobox, listbox, option, and aria-activedescendant
SafetyDestructive commands confirm or route to a review screen

Ask Claude Code with concrete constraints:

Build a dependency-free React 18+ TypeScript command palette. Include Cmd+K/Ctrl+K, search filtering, useDeferredValue, useMemo, ARIA roles, aria-activedescendant, IME-safe Enter handling, a simple focus trap, and an example destructive command that asks for confirmation. Split it into CommandPalette.tsx, useCommandActions.ts, and command-palette.css.

Minimal setup

Drop these files into an existing React app, or create a small Vite app for testing.

npm create vite@latest command-palette-demo -- --template react-ts
cd command-palette-demo
npm install
npm run dev

Copy-paste CommandPalette.tsx

import {
  useCallback,
  useDeferredValue,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
} from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import "./command-palette.css";

export type Command = {
  id: string;
  label: string;
  category: string;
  description?: string;
  keywords?: string[];
  shortcut?: string;
  disabled?: boolean;
  run: () => void | Promise<void>;
};

type CommandPaletteProps = {
  commands: Command[];
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  placeholder?: string;
  emptyLabel?: string;
};

const normalize = (value: string) => value.trim().toLocaleLowerCase();

function rankCommand(command: Command, terms: string[], fullQuery: string) {
  if (command.disabled) return -1;
  if (terms.length === 0) return 1;

  const label = command.label.toLocaleLowerCase();
  const category = command.category.toLocaleLowerCase();
  const description = command.description?.toLocaleLowerCase() ?? "";
  const keywords = (command.keywords ?? []).join(" ").toLocaleLowerCase();
  const haystack = `${label} ${category} ${description} ${keywords}`;

  if (terms.some((term) => !haystack.includes(term))) return -1;
  if (label === fullQuery) return 100;
  if (label.startsWith(fullQuery)) return 80;
  if (label.includes(fullQuery)) return 60;
  if (category.includes(fullQuery)) return 40;
  return 20;
}

export function CommandPalette({
  commands,
  open: controlledOpen,
  onOpenChange,
  placeholder = "Search commands...",
  emptyLabel = "No commands found",
}: CommandPaletteProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [activeIndex, setActiveIndex] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);
  const closeButtonRef = useRef<HTMLButtonElement>(null);
  const listboxId = useId();
  const titleId = useId();

  const open = controlledOpen ?? uncontrolledOpen;
  const deferredQuery = useDeferredValue(query);
  const normalizedQuery = normalize(deferredQuery);

  const setOpen = useCallback(
    (nextOpen: boolean) => {
      if (controlledOpen === undefined) {
        setUncontrolledOpen(nextOpen);
      }
      onOpenChange?.(nextOpen);
    },
    [controlledOpen, onOpenChange],
  );

  const visibleCommands = useMemo(() => {
    const terms = normalizedQuery.split(/\s+/).filter(Boolean);

    return commands
      .map((command) => ({
        command,
        score: rankCommand(command, terms, normalizedQuery),
      }))
      .filter((item) => item.score >= 0)
      .sort(
        (a, b) =>
          b.score - a.score || a.command.label.localeCompare(b.command.label),
      )
      .map((item) => item.command);
  }, [commands, normalizedQuery]);

  const activeCommand = visibleCommands[activeIndex];
  const activeOptionId =
    activeCommand === undefined
      ? undefined
      : `${listboxId}-option-${activeIndex}`;

  useEffect(() => {
    const handleGlobalKeyDown = (event: globalThis.KeyboardEvent) => {
      if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
        event.preventDefault();
        setOpen(!open);
      }
    };

    window.addEventListener("keydown", handleGlobalKeyDown);
    return () => window.removeEventListener("keydown", handleGlobalKeyDown);
  }, [open, setOpen]);

  useEffect(() => {
    if (!open) return;

    setQuery("");
    setActiveIndex(0);
    const frameId = window.requestAnimationFrame(() => {
      inputRef.current?.focus();
    });

    return () => window.cancelAnimationFrame(frameId);
  }, [open]);

  useEffect(() => {
    setActiveIndex((currentIndex) => {
      if (visibleCommands.length === 0) return 0;
      return Math.min(currentIndex, visibleCommands.length - 1);
    });
  }, [visibleCommands.length]);

  const runCommand = useCallback(
    (command: Command | undefined) => {
      if (!command || command.disabled) return;

      setOpen(false);
      setQuery("");

      Promise.resolve(command.run()).catch((error) => {
        console.error("Command failed", error);
      });
    },
    [setOpen],
  );

  const moveActiveIndex = useCallback(
    (delta: number) => {
      setActiveIndex((currentIndex) => {
        if (visibleCommands.length === 0) return 0;
        return (
          (currentIndex + delta + visibleCommands.length) %
          visibleCommands.length
        );
      });
    },
    [visibleCommands.length],
  );

  const handleInputKeyDown = useCallback(
    (event: ReactKeyboardEvent<HTMLInputElement>) => {
      if (event.nativeEvent.isComposing) return;

      switch (event.key) {
        case "ArrowDown":
          event.preventDefault();
          moveActiveIndex(1);
          break;
        case "ArrowUp":
          event.preventDefault();
          moveActiveIndex(-1);
          break;
        case "Home":
          event.preventDefault();
          setActiveIndex(0);
          break;
        case "End":
          event.preventDefault();
          setActiveIndex(Math.max(visibleCommands.length - 1, 0));
          break;
        case "Enter":
          event.preventDefault();
          runCommand(activeCommand);
          break;
        case "Escape":
          event.preventDefault();
          setOpen(false);
          break;
      }
    },
    [activeCommand, moveActiveIndex, runCommand, setOpen, visibleCommands.length],
  );

  const handleDialogKeyDown = useCallback(
    (event: ReactKeyboardEvent<HTMLDivElement>) => {
      if (event.key !== "Tab") return;

      const focusable = [inputRef.current, closeButtonRef.current].filter(
        Boolean,
      ) as HTMLElement[];
      const first = focusable[0];
      const last = focusable[focusable.length - 1];

      if (!first || !last) return;

      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    },
    [],
  );

  if (!open) return null;

  return (
    <div className="cp-backdrop" onMouseDown={() => setOpen(false)}>
      <div
        className="cp-dialog"
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        onMouseDown={(event) => event.stopPropagation()}
        onKeyDown={handleDialogKeyDown}
      >
        <div className="cp-header">
          <h2 id={titleId}>Command palette</h2>
          <button
            ref={closeButtonRef}
            type="button"
            className="cp-close"
            onClick={() => setOpen(false)}
            aria-label="Close command palette"
          >
            Esc
          </button>
        </div>

        <label className="cp-search-label" htmlFor={`${listboxId}-input`}>
          Search commands
        </label>
        <input
          ref={inputRef}
          id={`${listboxId}-input`}
          className="cp-input"
          value={query}
          onChange={(event) => {
            setQuery(event.target.value);
            setActiveIndex(0);
          }}
          onKeyDown={handleInputKeyDown}
          role="combobox"
          aria-autocomplete="list"
          aria-expanded="true"
          aria-controls={listboxId}
          aria-activedescendant={activeOptionId}
          placeholder={placeholder}
        />

        <ul id={listboxId} className="cp-list" role="listbox">
          {visibleCommands.length === 0 ? (
            <li className="cp-empty">{emptyLabel}</li>
          ) : (
            visibleCommands.map((command, index) => (
              <li
                id={`${listboxId}-option-${index}`}
                key={command.id}
                className="cp-option"
                role="option"
                aria-selected={index === activeIndex}
                data-active={index === activeIndex ? "true" : "false"}
                onMouseMove={() => setActiveIndex(index)}
                onMouseDown={(event) => event.preventDefault()}
                onClick={() => runCommand(command)}
              >
                <span className="cp-option-main">
                  <span className="cp-option-label">{command.label}</span>
                  {command.description ? (
                    <span className="cp-option-description">
                      {command.description}
                    </span>
                  ) : null}
                </span>
                <span className="cp-option-meta">
                  <span className="cp-category">{command.category}</span>
                  {command.shortcut ? (
                    <kbd className="cp-shortcut">{command.shortcut}</kbd>
                  ) : null}
                </span>
              </li>
            ))
          )}
        </ul>

        <div className="cp-footer" aria-hidden="true">
          <kbd>↑</kbd>
          <kbd>↓</kbd>
          <span>Move</span>
          <kbd>Enter</kbd>
          <span>Run</span>
        </div>
      </div>
    </div>
  );
}

Define commands outside the UI

Keep command definitions separate from the palette component. That makes routing, analytics, permission checks, and confirmation flows easier to change later.

import type { Command } from "./CommandPalette";

type Navigate = (href: string) => void;

export function createCommandActions(navigate: Navigate): Command[] {
  return [
    {
      id: "new-draft",
      label: "Write a new post",
      category: "Content",
      description: "Create an empty editorial draft",
      keywords: ["create", "post", "article", "draft"],
      shortcut: "N",
      run: () => navigate("/editor/new"),
    },
    {
      id: "media-library",
      label: "Open media library",
      category: "Media",
      description: "Manage hero images and screenshots",
      keywords: ["image", "asset", "hero", "upload"],
      shortcut: "G M",
      run: () => navigate("/media"),
    },
    {
      id: "toggle-theme",
      label: "Toggle theme",
      category: "Settings",
      description: "Switch between light and dark themes",
      keywords: ["dark", "light", "appearance"],
      shortcut: "T",
      run: () => {
        const root = document.documentElement;
        root.dataset.theme = root.dataset.theme === "dark" ? "light" : "dark";
      },
    },
    {
      id: "publish-current",
      label: "Publish current post",
      category: "Publishing",
      description: "Ask for confirmation before publishing",
      keywords: ["deploy", "release", "publish"],
      shortcut: "P",
      run: () => {
        const confirmed = window.confirm("Publish the current post?");
        if (confirmed) navigate("/publish/current");
      },
    },
  ];
}

CSS for focus and active state

.cp-backdrop {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding: 14vh 16px 16px;
  background: rgb(15 23 42 / 0.52);
}

.cp-dialog {
  width: min(680px, 100%);
  overflow: hidden;
  border: 1px solid rgb(226 232 240);
  border-radius: 8px;
  background: white;
  box-shadow: 0 24px 80px rgb(15 23 42 / 0.24);
}

.cp-header,
.cp-footer {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 12px;
  color: rgb(71 85 105);
}

.cp-header {
  justify-content: space-between;
  border-bottom: 1px solid rgb(226 232 240);
}

.cp-header h2 {
  margin: 0;
  font-size: 14px;
  font-weight: 700;
}

.cp-close {
  border: 1px solid rgb(203 213 225);
  border-radius: 6px;
  background: rgb(248 250 252);
  color: rgb(51 65 85);
  cursor: pointer;
  font-size: 12px;
  padding: 4px 8px;
}

.cp-search-label {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
}

.cp-input {
  width: 100%;
  border: 0;
  border-bottom: 1px solid rgb(226 232 240);
  font-size: 16px;
  outline: none;
  padding: 14px 16px;
}

.cp-input:focus {
  box-shadow: inset 0 0 0 2px rgb(37 99 235);
}

.cp-list {
  max-height: min(420px, 52vh);
  margin: 0;
  overflow-y: auto;
  padding: 8px;
  list-style: none;
}

.cp-option {
  display: flex;
  min-height: 58px;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  border-radius: 6px;
  cursor: pointer;
  padding: 10px;
}

.cp-option[data-active="true"] {
  background: rgb(239 246 255);
}

.cp-option-main {
  display: grid;
  gap: 3px;
  min-width: 0;
}

.cp-option-label {
  color: rgb(15 23 42);
  font-weight: 650;
}

.cp-option-description {
  color: rgb(100 116 139);
  font-size: 13px;
}

.cp-option-meta {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
}

.cp-category,
.cp-shortcut,
.cp-footer kbd {
  border: 1px solid rgb(203 213 225);
  border-radius: 6px;
  background: rgb(248 250 252);
  color: rgb(71 85 105);
  font-size: 12px;
  padding: 3px 6px;
}

.cp-empty {
  padding: 32px 12px;
  text-align: center;
  color: rgb(100 116 139);
}

@media (max-width: 640px) {
  .cp-backdrop {
    padding-top: 8vh;
  }

  .cp-option {
    align-items: flex-start;
    flex-direction: column;
  }
}

Wire it into your app

import { useMemo, useState } from "react";
import { CommandPalette } from "./CommandPalette";
import { createCommandActions } from "./useCommandActions";

export function AppShell() {
  const [paletteOpen, setPaletteOpen] = useState(false);
  const commands = useMemo(
    () =>
      createCommandActions((href) => {
        window.location.href = href;
      }),
    [],
  );

  return (
    <>
      <header className="app-header">
        <button type="button" onClick={() => setPaletteOpen(true)}>
          Open commands <kbd>Ctrl K</kbd>
        </button>
      </header>

      <CommandPalette
        commands={commands}
        open={paletteOpen}
        onOpenChange={setPaletteOpen}
      />
    </>
  );
}

Practical use cases

For SaaS dashboards, command palettes make deep tasks reachable: user lookup, billing, audit logs, invitations, and feature flags. Keep destructive actions as review screens rather than instant commands.

For CMS and media sites, commands can open a new draft, the asset library, category settings, pre-publish checks, and search index jobs. This is where the pattern saved Masa the most time: repeated editorial actions moved from navigation clicks to Ctrl+K, a few letters, and Enter.

For documentation and developer tools, a palette can cross-link API references, examples, CLI commands, and troubleshooting pages. Unlike plain search, it can also run actions such as copying the current page URL or opening a starter template.

For internal operations tools, commands can create tickets, open customer records, export CSV files, or send notifications. Do not rely on hiding commands in the client; enforce permissions again on the server when an action runs.

Concrete pitfalls

Do not move DOM focus into each list item. Keep focus on the input and use aria-activedescendant to announce the active option. That preserves typing and screen reader context.

Check IME composition before running Enter. Without event.nativeEvent.isComposing, a user confirming Japanese, Chinese, or Korean text can accidentally run the selected command.

Do not put heavy filtering directly in every render. useDeferredValue and useMemo help separate responsive typing from result calculation, but large remote datasets still need server-side search and pagination.

Avoid shortcut collisions. Cmd+K/Ctrl+K is common, but rich text editors, browser search, and embedded terminals may need special handling.

What happened in practice

After adding this structure to a small editorial admin screen, Masa reduced the common path to new drafts, media, and pre-publish checks from several clicks to one shortcut plus a short query. The important change was not only speed; the palette made the next action obvious. The initial idea of running publish directly was removed because it was too easy to trigger. Fast UI still needs deliberate friction at risky moments.

Next steps

Treat the palette as a product workflow layer, not a decoration. Start with 10 to 20 commands, log usage, then promote the actions people actually use. For deeper React patterns, continue with React development with Claude Code and accessibility implementation.

Grab the free cheat sheet if you want Claude Code prompts, review checklists, and setup snippets in one place. For team adoption or UI architecture review, use the consulting page.

#Claude Code #command palette #UI #keyboard #React
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.