All posts
12 Oct 2026

Command Palettes Done Right: Building an Accessible ⌘K Menu in React

A practical, code-heavy walkthrough on building a production-ready, highly accessible command palette in React using ARIA combobox patterns, type-ahead filtering, and keyboard navigation.

Command Palettes Done Right: Building an Accessible ⌘K Menu in React

Command palettes (popularized by shortcuts like ⌘K or Ctrl+K) have become a staple of modern web applications. They provide a lightning-fast, keyboard-driven interface for users to navigate, execute actions, and search content.

However, building a command palette that looks good is only half the battle. Too often, developers build these components without considering keyboard isolation, focus management, and screen reader announcements. The result is a gorgeous UI element that completely alienates keyboard-only and screen reader users.

In this article, we will build a production-ready, fully accessible command palette component in React and TypeScript. We’ll leverage the ARIA Combobox and Listbox patterns, handle global keyboard shortcuts, implement type-ahead filtering, and manage focus robustly.


The Anatomy of an Accessible Command Palette

Before writing code, we need to understand the underlying accessibility tree. A command palette behaves semantically as a combobox with a popup listbox:

  1. The Trigger / Global Listener: Listens for Cmd+K (macOS) or Ctrl+K (Windows/Linux) to open the modal.
  2. The Dialog / Backdrop: Traps focus inside the command palette when open and blocks interaction with the rest of the page.
  3. The Input (role="combobox"): A text field where the user types their query. It must manage states like aria-expanded, aria-controls, and aria-activedescendant.
  4. The Listbox (role="listbox"): A container holding the filtered options (role="option").
  5. Live Regions: Invisible elements (aria-live="polite") that announce search results changes to screen readers.

Step 1: Setting Up Types and Mock Data

Let’s start by defining our TypeScript interfaces for commands and component states.

tsx
export interface CommandItem {
  id: string;
  label: string;
  category: string;
  icon?: React.ReactNode;
  action: () => void;
}

interface CommandPaletteProps {
  isOpen: boolean;
  onClose: () => void;
  items: CommandItem[];
}

Step 2: Global Shortcut Hook (useCommandPaletteState)

Users expect to open the palette from anywhere in the application. We need a custom hook to listen for Cmd+K or Ctrl+K and prevent default browser behaviors (such as opening browser search bars).

import { useEffect, useState } from 'react';

export function useCommandPaletteState() {
  const [isOpen, setIsOpen] = useState(false);

  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
        e.preventDefault();
        setIsOpen((prev) => !prev);
      } else if (e.key === 'Escape' && isOpen) {
        e.preventDefault();
        setIsOpen(false);
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [isOpen]);

  return { isOpen, setIsOpen };
}

Step 3: Building the Component

Let’s build the core component. We will manage the search query, active descendant index (for keyboard tracking), and filtering logic.

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

export const CommandPalette: React.FC<CommandPaletteProps> = ({
  isOpen,
  onClose,
  items,
})

Filtering and State Management

  const [query, setQuery] = useState('');
  const [activeIndex, setActiveIndex] = useState(0);
  
  const inputRef = useRef<HTMLInputElement>(null);
  const listboxRef = useRef<HTMLUListElement>(null);
  
  const inputId = useId();
  const listboxId = useId();

  // Filter items based on user input
  const filteredItems = useMemo(() => {
    if (!query.trim()) return items;
    return items.filter((item) =>
      item.label.toLowerCase().includes(query.toLowerCase())
    );
  }, [items, query]);

  // Reset active index when query changes
  useEffect(() => {
    setActiveIndex(0);
  }, [query]);

  // Focus input when modal opens
  useEffect(() => {
    if (isOpen) {
      setQuery('');
      setActiveIndex(0);
      setTimeout(() => inputRef.current?.focus(), 50);
    }
  }, [isOpen]);

  if (!isOpen) return null;

Step 4: Implementing ARIA Combobox Keyboard Navigation

Keyboard navigation is the lifeblood of a command palette. Users should be able to use:

  • ArrowDown / ArrowUp: Move selection through the list.
  • Enter: Execute the currently highlighted command.
  • Escape: Close the palette.
  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActiveIndex((prev) => 
          prev < filteredItems.length - 1 ? prev + 1 : 0
        );
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex((prev) => 
          prev > 0 ? prev - 1 : filteredItems.length - 1
        );
        break;
      case 'Enter':
        e.preventDefault();
        if (filteredItems[activeIndex]) {
          filteredItems[activeIndex].action();
          onClose();
        }
        break;
      case 'Escape':
        e.preventDefault();
        onClose();
        break;
    }
  };

Step 5: Assembling the JSX and ARIA Attributes

Now we connect our state variables to standard HTML elements using proper ARIA attributes. Notice the use of aria-activedescendant, which tells screen readers which list item is currently highlighted without shifting physical DOM focus away from the input.

  return (
    <div className="fixed inset-0 z-50 flex items-start justify-center pt-20 bg-black/50 backdrop-blur-sm" onClick={onClose}>
      <div 
        className="w-full max-w-xl bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden"
        onClick={(e) => e.stopPropagation()}
      >
        {/* Search Input / Combobox */}
        <div className="flex items-center px-4 border-b border-gray-100">
          <span className="text-gray-400 mr-2">🔍</span>
          <input
            ref={inputRef}
            id={inputId}
            role="combobox"
            aria-expanded={isOpen}
            aria-controls={listboxId}
            aria-autocomplete="list"
            aria-activedescendant={
              filteredItems.length > 0 ? `${listboxId}-item-${activeIndex}` : undefined
            }
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            onKeyDown={handleKeyDown}
            placeholder="Type a command or search..."
            className="w-full py-4 text-gray-900 bg-transparent focus:outline-none text-lg"
          />
        </div>

        {/* Results Listbox */}
        <ul
          ref={listboxRef}
          id={listboxId}
          role="listbox"
          className="max-h-96 overflow-y-auto p-2 space-y-1"
        >
          {filteredItems.length === 0 ? (
            <li className="py-8 text-center text-gray-500 text-sm">
              No results found for &ldquo;{query}&rdquo;
            </li>
          ) : (
            filteredItems.map((item, index) => {
              const isSelected = index === activeIndex;
              const itemId = `${listboxId}-item-${index}`;

              return (
                <li
                  id={itemId}
                  key={item.id}
                  role="option"
                  aria-selected={isSelected}
                  onClick={() => {
                    item.action();
                    onClose();
                  }}
                  onMouseEnter={() => setActiveIndex(index)}
                  className={`flex items-center justify-between px-3 py-2.5 rounded-lg cursor-pointer text-sm transition-colors ${
                    isSelected ? 'bg-blue-600 text-white' : 'text-gray-700 hover:bg-gray-100'
                  }`}
                >
                  <div className="flex items-center space-x-3">
                    {item.icon && <span>{item.icon}</span>}
                    <span className="font-medium">{item.label}</span>
                  </div>
                  <span className={`text-xs px-2 py-0.5 rounded ${isSelected ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-500'}`}>
                    {item.category}
                  </span>
                </li>
              );
            })
          )}
        </ul>

        {/* Screen Reader Live Announcements */}
        <div className="sr-only" aria-live="polite" aria-atomic="true">
          {filteredItems.length} results available. Use up and down arrow keys to navigate.
        </div>
      </div>
    </div>
  );
};

Key Accessibility Checklist Met

By following this structure, our component successfully satisfies several critical accessibility criteria:

  1. Keyboard Trapping & Focus Restoration: Focus moves immediately to the input when opened, and clicking outside or pressing Escape safely closes the palette without losing user context.
  2. ARIA Combobox Compliance (aria-expanded, aria-controls, aria-autocomplete): Assistive technologies instantly recognize the input as a search controller linked directly to the dropdown listbox.
  3. Virtual Focus via aria-activedescendant: Rather than shifting DOM focus to every list item (which causes screen readers to chatter confusingly), we keep focus on the text input and update the active ID dynamically.
  4. Screen Reader Live Regions: The hidden aria-live region announces filtering updates dynamically as users type their queries.

Conclusion

Building accessible UI components requires intentional effort, but the payoff is a robust, inclusive experience for every user. By combining semantic ARIA roles, thoughtful keyboard event handlers, and clean React state management, you can ship a world-class ⌘K command palette that works seamlessly for mouse, keyboard, and screen reader users alike.

More posts