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:
- The Trigger / Global Listener: Listens for
Cmd+K(macOS) orCtrl+K(Windows/Linux) to open the modal. - The Dialog / Backdrop: Traps focus inside the command palette when open and blocks interaction with the rest of the page.
- The Input (
role="combobox"): A text field where the user types their query. It must manage states likearia-expanded,aria-controls, andaria-activedescendant. - The Listbox (
role="listbox"): A container holding the filtered options (role="option"). - 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.
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 “{query}”
</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:
- 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.
- 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. - 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. - Screen Reader Live Regions: The hidden
aria-liveregion 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.