Command Palette Search (⌘K) in React: Spotlight-Style UI
Build a Spotlight-style ⌘K command palette in React with Tailwind. Keyboard-driven search, fuzzy filtering, and smooth animations — no bloated libraries needed.
Why Your App Needs a ⌘K Command Palette
Honestly, the command palette is the most underrated UI pattern in modern web apps. Users who discover it never go back to poking around in navbars. Power users especially — they'll hit ⌘K before they even glance at your sidebar.
The pattern started with Sublime Text, got popularized by Alfred on macOS, and then Vercel shipped their dashboard version around 2021. Since then nearly every serious SaaS tool has one. Linear, Raycast, Figma, GitHub — they all use it. If you're building a developer tool, admin dashboard, or anything with more than a dozen pages, you need this.
The good news is you don't need a massive dependency to pull it off. A focused React component, some Tailwind utility classes, and a solid understanding of KeyboardEvent handling gets you 90% of the way there. This guide shows you exactly how.
The Anatomy of a Spotlight-Style Search Component
A command palette has four moving parts: a trigger (the ⌘K listener), an overlay backdrop, a search input, and a filtered results list. That's it. Don't overcomplicate it.
The overlay uses a fixed-position wrapper with a semi-transparent backdrop — typically rgba(0,0,0,0.6) with a backdrop-filter: blur(4px). The dialog itself sits centered in the viewport, usually constrained to about 560px wide and max-h-[480px] tall with overflow-y scroll on the results list.
Keyboard navigation is where most implementations fall apart. You need to track a selectedIndex in state, update it on ArrowUp / ArrowDown, execute the selected command on Enter, and close on Escape. All without losing focus on the search input. It's fiddlier than it sounds, but we'll handle it properly below.
Setting Up Global Keyboard Shortcut Listeners in React
The global ⌘K listener lives in a useEffect with proper cleanup. Attach it to window or document — window is fine for most cases. The key thing is checking event.metaKey (Mac) AND event.ctrlKey (Windows/Linux) so the shortcut works cross-platform.
import { useEffect, useState } from 'react'
export function useCommandPalette() {
const [open, setOpen] = useState(false)
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setOpen(prev => !prev)
}
if (e.key === 'Escape') {
setOpen(false)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
return { open, setOpen }
}The e.preventDefault() call is critical — without it, browsers intercept ⌘K for their own purposes (in Chrome, it focuses the address bar). Notice we're toggling, not just opening, so a second ⌘K press closes the palette. That matches user expectations from tools like Linear and VS Code.
Building the Fuzzy Filter: No Library Required
You don't need Fuse.js for a command palette. A simple substring match with score ranking handles 95% of real-world use cases. The trick is ranking: exact prefix matches should surface before mid-string matches.
type Command = {
id: string
label: string
shortcut?: string
action: () => void
group?: string
}
function filterCommands(commands: Command[], query: string): Command[] {
if (!query.trim()) return commands
const lower = query.toLowerCase()
return commands
.filter(cmd => cmd.label.toLowerCase().includes(lower))
.sort((a, b) => {
const aStarts = a.label.toLowerCase().startsWith(lower) ? 0 : 1
const bStarts = b.label.toLowerCase().startsWith(lower) ? 0 : 1
return aStarts - bStarts
})
}Group your commands by category — 'Navigation', 'Actions', 'Settings' — and render each group with a heading label. This dramatically improves perceived quality. Users can visually scan groups rather than reading every result linearly. Vercel's palette is a good reference for this grouping pattern.
If you do end up needing true fuzzy matching for a large command set (500+ items), consider the cmdk library by Pacocoursey — it's tiny, headless, and composable with your own Tailwind styles. But honestly, for most apps the filter above is all you need.
Styling the Command Palette with Tailwind v4.0.2
The visual design should feel elevated — glass morphism with a subtle border works really well here. Think bg-zinc-900/90 with border border-white/10 and backdrop-blur-xl. The search input should blend into the header area, not look like a traditional form field. Remove the default ring and use a bottom border instead: border-b border-white/10.
// CommandPalette.tsx (simplified)
export function CommandPalette({ open, onClose, commands }: Props) {
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
onClick={onClose}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
{/* Dialog */}
<div
className="relative w-full max-w-[560px] rounded-xl border border-white/10
bg-zinc-900/95 shadow-2xl backdrop-blur-xl
mx-4 overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* Search input */}
<input
autoFocus
type="text"
placeholder="Search commands..."
className="w-full bg-transparent px-4 py-3.5 text-sm text-white
placeholder-zinc-500 outline-none border-b border-white/10"
/>
{/* Results */}
<ul className="max-h-[360px] overflow-y-auto p-2">
{/* map results here */}
</ul>
{/* Footer hint */}
<div className="border-t border-white/10 px-4 py-2 flex gap-3 text-xs text-zinc-600">
<span><kbd>↑↓</kbd> navigate</span>
<span><kbd>↵</kbd> select</span>
<span><kbd>esc</kbd> close</span>
</div>
</div>
</div>
)
}The pt-[20vh] on the outer wrapper positions the palette in the upper third of the viewport — not perfectly centered. That's intentional. It feels more like Spotlight and less like a modal dialog. Small detail, big difference in feel.
For dark/light theme support, you'll want to wire this up to your app's theme context. Check out how to build a theme toggle in React if you haven't handled that yet — the command palette is a great place to include a 'Toggle dark mode' command too.
Keyboard Navigation: Arrow Keys, Enter, and Focus Management
This is where it gets interesting. How do you handle arrow key navigation while keeping focus on the input? You listen on the input's onKeyDown and update a selectedIndex state. Don't move DOM focus to the list items — that would interrupt typing.
function useListNavigation(length: number) {
const [selectedIndex, setSelectedIndex] = useState(0)
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex(i => (i + 1) % length)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex(i => (i - 1 + length) % length)
}
}
// Reset when results change
useEffect(() => setSelectedIndex(0), [length])
return { selectedIndex, handleKeyDown }
}Apply aria-selected="true" to the highlighted item and use scrollIntoView({ block: 'nearest' }) via a ref to keep the selected item visible when the user arrows past the viewport boundary. Without scrollIntoView, the highlight will move off screen and users will be confused about what they're about to execute.
Also: reset selectedIndex to 0 whenever the query changes. If someone types 'dash' and the first result becomes 'Dashboard', it should be pre-selected. Don't make them press ArrowDown before Enter works.
Integrating with React Router or Next.js App Router
Navigation commands are the killer use-case. Every route in your app should be reachable from the palette. In Next.js with the App Router, use useRouter from next/navigation inside your command actions.
Build your commands array dynamically. Static routes go in a constant, but you can also inject context-aware commands — 'New invoice', 'View customer #1042' — based on what page the user is currently on. This is similar to how animated tabs surface context-specific actions depending on which tab is active.
One thing worth thinking about: should opening the palette cause a re-render of your whole app? It shouldn't. Keep the command palette state at the root layout level in Next.js (in your RootLayout or a dedicated Providers component) and pass the open state down via context. That way switching the palette open/closed doesn't re-render your page content at all.
What about server components? The command palette itself is purely client-side — 'use client' at the top. You can still compose it with server-rendered layouts; just make sure the palette lives in a client component boundary.
Accessibility and ARIA for Screen Readers
A command palette that's keyboard-only but not screen-reader accessible is a missed opportunity. The ARIA pattern here is role="combobox" on the input with aria-controls pointing to the results list, which gets role="listbox". Each result item gets role="option" and aria-selected.
The dialog container should have role="dialog" and aria-modal="true". Add a visually hidden aria-label="Command palette" or link it to a visible heading via aria-labelledby. When the palette opens, focus must move to the search input — autoFocus handles this, but double-check it works when the component mounts dynamically.
Use aria-live="polite" on a status element that announces result counts: 'Showing 5 results for dashboard'. Screen reader users can't see the filtered list update visually. This is the same attention to accessible state you'd bring to components like cards with stack interactions where visual state changes need announcements.
Finally: don't trap focus in ways that conflict with assistive technology. The Escape key should reliably close the palette and return focus to whatever triggered it. Store a ref to the triggering element before opening and call .focus() on it when closing.
FAQ
Depends on your constraints. cmdk is headless and tiny (~4kb), so if you want a fully custom look with Tailwind and don't want to reinvent keyboard navigation logic, it's a solid pick. If you need tight control over every behavior — custom scoring, grouped results, mixed action types — rolling your own with the patterns in this article is totally manageable and avoids the extra dependency.
Call e.preventDefault() inside your keydown handler before doing anything else. On Chrome/Mac, ⌘K focuses the address bar without it. On some Linux browser builds, Ctrl+K also has default behavior. Always prevent default before setting your open state.
On iOS Safari, blur events fire when you programmatically focus an input that wasn't triggered by a direct user tap. The backdrop click handler is often the culprit — your onClick on the overlay fires before the input auto-focuses, which triggers a blur. Add e.stopPropagation() carefully and test on real iOS devices, not just Chrome DevTools mobile emulation.
Put your useCommandPalette() hook and the <CommandPalette /> component inside your root layout's client boundary — typically a Providers component wrapped in 'use client'. This way the component persists across page navigations since App Router's root layout doesn't unmount between routes.
360px to 400px works well for most viewport sizes. This typically shows 6-8 results at a comfortable 48px item height with 8px gap between items. Go higher and the palette starts feeling like a full modal; go lower and users have to scroll too aggressively. Always add overflow-y-auto and implement scrollIntoView for keyboard navigation.
Yes, and it's worth doing — icons dramatically improve scannability. Use inline SVG or a lightweight icon library like Lucide React (tree-shakeable). Avoid loading full icon packs. Since command lists are usually under 200 items, rendering SVG icons in every item doesn't cause performance issues. For very large datasets, virtualize the list with @tanstack/react-virtual.