The Cmd+K palette is one of those features that users notice immediately when it is broken and take for granted when it works. We spent three iterations getting ours right: the first version had noticeable input lag, the second had a scroll lock bug on mobile, and the third is the one that shipped. Here is what we learned.
The Requirement
Avo's platform covers 56,000+ symbols across equities, crypto, forex, and commodities. Users needed a way to navigate to any symbol from any page, instantly, without mouse interaction. The palette also needed to handle navigation commands (go to Markets, go to Screener) and eventually would need to support action commands (add to watchlist, set alert).
The specific performance target: input appears in the search field within 16ms of keypress, first results render within 50ms of the first character typed, and the modal opens without any layout shift or scroll jump. These targets are achievable but require care in the implementation.
Why cmdk
We evaluated three options: building from scratch with a Radix Dialog + custom search input, using Radix's Combobox primitive directly, and using the cmdk library.
Building from scratch is tempting because you control everything. It is also two to three days of work to get keyboard navigation (up/down arrows, enter to select, escape to close, tab handling) correct across browsers and screen reader semantics (ARIA roles for combobox, listbox, option). The accessibility requirements alone make from-scratch a significant investment.
cmdk ships with correct ARIA semantics, robust keyboard handling, and the filtering/ scoring logic built in. The library is maintained by Pacocoursey and has seen serious production use across Linear, Vercel, and similar developer-facing products. We used it.
Basic Structure
"use client";
import { Command } from "cmdk";
import * as Dialog from "@radix-ui/react-dialog";
import { useState, useEffect, useCallback } from "react";
export function CommandPalette() {
const [open, setOpen] = useState(false);
// Global Cmd+K / Ctrl+K listener
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen((prev) => !prev);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, []);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-bg/50 backdrop-blur-sm z-50" />
<Dialog.Content className="fixed top-[20%] left-1/2 -translate-x-1/2 z-50 w-full max-w-xl">
<Command className="bg-white border border-zinc-200 rounded-xl shadow-2xl overflow-hidden">
<Command.Input
placeholder="Search symbols, markets, or navigate..."
className="w-full px-4 py-3 text-sm outline-none border-b border-zinc-100"
/>
<Command.List className="max-h-80 overflow-y-auto py-2">
<Command.Empty className="py-8 text-center text-sm text-muted">
No results found.
</Command.Empty>
{/* items rendered here */}
</Command.List>
</Command>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}The Search Backend Problem
cmdk's built-in filtering works well for small lists (hundreds of items). For 56,000+ symbols, you cannot send the full list to the client on mount. The naive approach is to fetch from an API on every keystroke, which introduces network latency and produces visible result flicker on fast typists.
Our approach: a debounced API call with an optimistic local first pass. We maintain a small in-memory cache of the 500 most recently viewed symbols in localStorage. On first keypress, cmdk filters this local cache instantly (0ms network). Simultaneously, we fire a debounced API request. When the API response arrives, it replaces the local results if the query string has not changed.
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
// Debounced remote search
useEffect(() => {
if (query.length < 1) {
setResults([]);
return;
}
const timeout = setTimeout(async () => {
setLoading(true);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=12`);
const data = await res.json();
setResults(data.results ?? []);
} finally {
setLoading(false);
}
}, 120); // 120ms debounce
return () => clearTimeout(timeout);
}, [query]);The 120ms debounce is a deliberate choice. Below 100ms, the API fires on nearly every keystroke for fast typists, producing visible flicker and unnecessary server load. Above 200ms, the delay is perceptible as a lag. 120ms sits in the sweet spot where the API fires once the user has paused momentarily.
Get weekly intelligence delivered to your inbox
Curated signals, regime shifts, and anomaly highlights from Avo Intelligence. Every Monday. Free.
The Scroll Lock Bug
Our second implementation had a scroll lock bug on iOS Safari. When the palette opened, the background page stopped scrolling. When it closed, the scroll position jumped by the height of the soft keyboard. This is a known interaction between fixed-position overlays, iOS Safari's address bar, and the overflow: hidden body lock.
The fix required two things: storing the current scroll position before applying body lock, and restoring it on close. We handled this in the Dialog open/close lifecycle:
const handleOpenChange = useCallback((next: boolean) => {
if (next) {
// Store scroll position before lock
const scrollY = window.scrollY;
document.body.style.position = "fixed";
document.body.style.top = `-${scrollY}px`;
document.body.style.width = "100%";
} else {
// Restore scroll position on close
const scrollY = document.body.style.top;
document.body.style.position = "";
document.body.style.top = "";
document.body.style.width = "";
if (scrollY) {
window.scrollTo(0, parseInt(scrollY || "0") * -1);
}
}
setOpen(next);
}, []);Radix Dialog handles this for you if you use its built-in scroll lock behavior, but our initial implementation bypassed Radix's scroll lock in favor of a CSS-only approach that did not account for iOS Safari's scroll position restoration.
Keyboard Navigation Edge Cases
cmdk handles the common cases correctly: arrow keys move selection, Enter activates the selected item, Escape closes. The edge cases we hit:
- →Tab inside the palette should not move browser focus to the address bar. We preventDefault on Tab to keep focus inside the Command component.
- →On mobile, the virtual keyboard pushes the viewport up. The palette needs to reposition to stay visible above the keyboard, not behind it. We used a CSS env(keyboard-inset-height) variable for this.
- →When results update while the user has a keyboard selection active, cmdk resets selection to the first item. This is correct behavior but feels jarring if the selected result disappears. We animate result transitions to make the reset less abrupt.
- →Paste events into the search input should trigger immediate search (bypass debounce). We added a separate onPaste handler that fires the API call immediately on paste.
Performance Tuning
The palette opens on Cmd+K from any page. We do not want the Dialog, Command, and search infrastructure to be in the initial bundle for every page. We lazy-load the entire palette component:
import dynamic from "next/dynamic";
const CommandPalette = dynamic(
() => import("@/components/command-palette").then((m) => m.CommandPalette),
{ ssr: false }
);The ssr: false is required because the palette uses window event listeners and localStorage on mount. Without it, Next.js attempts to server-render the component, hits the browser API references, and throws a hydration mismatch.
After lazy loading, the cmdk + Radix Dialog chunk is about 18 KB gzipped. It loads on first Cmd+K press, which means the first open has a 50-100ms loading pause on slow connections. We prefetch the chunk on first mousemove over the search trigger button to eliminate this in most cases.
Results
- →Input-to-first-character latency: under 16ms (no perceptible lag)
- →First results rendered: under 50ms via local cache, under 250ms via API
- →Zero layout shift on open/close
- →Zero scroll position jump on mobile Safari
- →Search covers 56,000+ symbols with fuzzy matching and type-ahead
What We Would Do Differently
Start with cmdk wrapped in Radix Dialog on day one, do not attempt a hybrid approach. The scroll lock, ARIA semantics, and focus trap behavior that Radix Dialog provides are non-trivial to re-implement correctly. The two bugs we spent time fixing were both caused by not fully using Radix's built-in behaviors.
Also: build the local cache layer before the API layer, not after. Users typing quickly see local results before the API responds. Having that local cache populated from day one means the palette feels fast from the first use, even before backend search is fully optimized.
Need similar work shipped?
We build production Next.js applications with real performance constraints. If you need a polished product shipped fast, we can help.
Start a Project →