All posts
5 Oct 2026

Command and Conquer: Building a Fully Accessible Command Palette in React

{

{ “title”: “Command and Conquer: Building a Fully Accessible Command Palette in React”, “summary”: “Learn how to build a production-ready, fully accessible ⌘K command palette in React using ARIA combobox patterns, keyboard navigation, and robust focus management.”, “tags”: [“Accessibility”, “React”, “TypeScript”, “UI Components”], “body”: “# Command and Conquer: Building a Fully Accessible Command Palette in React\n\nCommand palettes (often triggered by ⌘K or Ctrl+K) have become a staple of modern web applications. They provide a lightning-fast, keyboard-driven interface for power users to navigate pages, execute actions, and search data.\n\nHowever, because they float above the rest of the application and heavily rely on custom keyboard interactions, they are notoriously difficult to get right from an accessibility standpoint. Screen reader users and keyboard-only users are often left stranded by custom widgets that fail to communicate state changes, trap focus incorrectly, or ignore standard combobox semantics.\n\nIn this guide, we will build a production-ready, fully accessible command palette in React and TypeScript. We will focus heavily on:\n\n1. Implementing the ARIA Combobox and Listbox patterns.\n2. Writing robust keyboard navigation handlers.\n3. Optimizing typeahead filtering.\n4. Gracefully managing focus return when the palette closes.\n\n—, \n\n## The Anatomy of an Accessible Command Palette\n\nAt its core, a command palette is a composite widget. It consists of:\n\n* A backdrop/dialog wrapper: To isolate the component visually and functionally from the rest of the DOM.\n* A text input (role=\"combobox\"): Where the user types their query.\n* A results container (role=\"listbox\"): Which houses the filterable options (role=\"option\").\n\nTo make this accessible, we must hook up a web of ARIA attributes (aria-expanded, aria-controls, aria-activedescendant, aria-autocomplete) so that assistive technologies understand the relationship between the input and the list of suggestions.\n\n—\n\n## Setting Up the State and Types\n\nLet’s start by defining our TypeScript types and setting up the core state machine for our palette.\n\ntsx\nimport React, { useState, useEffect, useRef, useId } from 'react';\n\nexport interface CommandItem {\n id: string;\n label: string;\n category: string;\n icon?: React.ReactNode;\n onSelect: () => void;\n}\n\ninterface CommandPaletteProps {\n isOpen: boolean;\n onClose: () => void;\n items: CommandItem[];\n}\n\n\nWe need state for the search query, the index of the currently active (highlighted) item, and a reference to the trigger element so we can return focus later.\n\ntsx\nexport const CommandPalette: React.FC<CommandPaletteProps> = ({\n isOpen,\n onClose,\n items,\n}) => {\n const [query, setQuery] = useState('');\n const [activeIndex, setActiveIndex] = useState<number>(0);\n \n // Generate stable IDs for ARIA relationships\n const inputId = useId();\n const listboxId = useId();\n \n const inputRef = useRef<HTMLInputElement>(null);\n const triggerRef = useRef<HTMLElement | null>(null);\n\n // Filtering logic\n const filteredItems = items.filter((item) =>\n item.label.toLowerCase().includes(query.toLowerCase())\n );\n\n // Reset active index when query changes\n useEffect(() => {\n setActiveIndex(0);\n }, [query]);\n\n if (!isOpen) return null;\n\n return (\n // JSX structure goes here\n );\n};\n\n\n—\n\n## Managing Focus and the Escape Hatch\n\nWhen the palette opens, two things must happen immediately:\n1. We must store a reference to whatever element currently has focus (usually the button that opened the palette).\n2. We must shift focus directly to the search input.\n\nWhen the palette closes, focus must return to that stored reference. If focus is simply dropped to document.body, screen reader users will lose their place on the page.\n\ntsx\nuseEffect(() => {\n if (isOpen) {\n // Store current active element before opening\n triggerRef.current = document.getElementById(document.activeElement?.id || '') || document.activeElement as HTMLElement;\n \n // Focus input on next tick\n requestAnimationFrame(() => {\n inputRef.current?.focus();\n });\n } else {\n // Return focus when closed\n triggerRef.current?.focus();\n }\n}, [isOpen]);\n\n\nWe also need a global event listener to catch ⌘K or Ctrl+K to toggle the palette open from anywhere in the app, and Escape to close it.\n\ntsx\nuseEffect(() => {\n const handleKeyDown = (e: KeyboardEvent) => {\n if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {\n e.preventDefault();\n if (isOpen) onClose();\n else { /* trigger open */ }\n }\n if (e.key === 'Escape' && isOpen) {\n e.preventDefault();\n onClose();\n }\n };\n\n window.addEventListener('keydown', handleKeyDown);\n return () => window.removeEventListener('keydown', handleKeyDown);\n}, [isOpen, onClose]);\n\n\n—\n\n## Implementing the ARIA Combobox Pattern\n\nThe WAI-ARIA Combobox pattern is notoriously tricky. Instead of moving physical browser focus to each option in the listbox (which breaks typing in the input), we keep focus on the text input and use the aria-activedescendant attribute to point to the ID of the currently highlighted option.\n\nHere is how the markup comes together with all necessary ARIA attributes:\n\ntsx\nreturn (\n <div className=\"fixed inset-0 z-50 flex items-start justify-center pt-20 bg-black/50 backdrop-blur-sm\">\n <div \n role=\"dialog\" \n aria-modal=\"true\" \n aria-label=\"Command Palette\"\n className=\"w-full max-w-lg bg-white rounded-xl shadow-2xl overflow-hidden border border-gray-200\"\n >\n {/* Combobox Input Container */}\n <div className=\"relative border-b border-gray-100 flex items-center px-4\">\n <input\n ref={inputRef}\n id={inputId}\n role=\"combobox\"\n aria-expanded={filteredItems.length > 0}\n aria-autocomplete=\"list\"\n aria-controls={listboxId}\n aria-activedescendant={\n filteredItems.length > 0 ? `${listboxId}-item-${activeIndex}` : undefined\n }\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n onKeyDown={handleKeyDown}\n placeholder=\"Type a command or search...\"\n className=\"w-full py-4 text-gray-900 bg-transparent outline-none text-lg\"\n />\n </div>\n\n {/* Listbox Container */}\n {filteredItems.length > 0 ? (\n <ul\n id={listboxId}\n role=\"listbox\"\n className=\"max-h-96 overflow-y-auto p-2 space-y-1\"\n >\n {filteredItems.map((item, index) => {\n const isActive = index === activeIndex;\n const itemId = `${listboxId}-item-${index}`;\n\n return (\n <li\n id={itemId}\n key={item.id}\n role=\"option\"\n aria-selected={isActive}\n onClick={item.onSelect}\n className={`flex items-center px-3 py-2.5 rounded-lg cursor-pointer text-sm ${\n isActive ? 'bg-blue-600 text-white' : 'text-gray-700 hover:bg-gray-100'\n }`}\n >\n <span className=\"flex-1\">{item.label}</span>\n {isActive && <span className=\"text-xs opacity-75\">↵ Enter</span>}\n </li>\n );\n })}\n </ul>\n ) : (\n <div className=\"py-12 text-center text-sm text-gray-500\">\n No results found for &ldquo;{query}&rdquo;\n </div>\n )}\n </div>\n </div>\n);\n\n\n—\n\n## Writing Robust Keyboard Navigation Handlers\n\nNow we must handle keyboard events inside the input field. The user should be able to press ArrowDown, ArrowUp, and Enter to navigate and execute options without ever leaving the input.\n\ntsx\nconst handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n if (filteredItems.length === 0) return;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n setActiveIndex((prev) => \n prev < filteredItems.length - 1 ? prev + 1 : 0\n );\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n setActiveIndex((prev) => \n prev > 0 ? prev - 1 : filteredItems.length - 1\n );\n break;\n\n case 'Enter':\n e.preventDefault();\n if (filteredItems[activeIndex]) {\n filteredItems[activeIndex].onSelect();\n onClose();\n }\n break;\n\n default:\n break;\n }\n};\n\n> Pro-Tip: Notice how we wrap navigation updates with e.preventDefault(). Without this, pressing the Up and Down arrow keys will move the native text cursor to the beginning or end of your input string instead of traversing your list items.\n\n—\n\n## Auto-Scrolling Active Items into View\n\nIf your command palette contains dozens of items, the active item might fall outside the visible scroll container. We can fix this by attaching a useEffect that scrolls the active item into view whenever activeIndex changes.\n\ntsx\nuseEffect(() => {\n const activeElement = document.getElementById(`${listboxId}-item-${activeIndex}`);\n if (activeElement) {\n activeElement.scrollIntoView({\n block: 'nearest',\n behavior: 'smooth',\n });\n }\n}, [activeIndex, listboxId]);\n\n\n—\n\n## Wrapping Up\n\nBuilding an accessible command palette requires paying close attention to the small details: keeping focus trapped correctly, updating aria-activedescendant dynamically, preventing default arrow key behavior in text inputs, and gracefully returning focus upon closure.\n\nBy following the WAI-ARIA Combobox pattern outlined here, you ensure that screen reader users get an experience just as smooth and informative as sighted keyboard users. Happy coding, and go conquer your application’s navigation!”}

More posts