Anatomy of the Command Palette
How a flat groups array becomes a spring-loaded ⌘K menu, with a single highlight that springs between rows instead of restyling them.
A command palette is three unglamorous jobs wearing one glossy interaction: mount an overlay, filter a list as you type, and keep a keyboard cursor in sync with the mouse. None of that is hard on its own — what makes it feel fast is that the filtering, the keyboard, and the ⌘K shortcut all share one piece of state, and the "active" row is one animated element, not a re-render of every row.
Backdrop, panel, rows
CommandPalette portals a fixed-position layer into document.body and
renders it only while open is true, via AnimatePresence. Behind the
panel sits a click-to-close backdrop; inside it, a search input drives a
useMemo'd filter over groups, and the surviving items render flat, one
<button role="menuitem"> per row:
- backdrop
- click-through scrim, closes on click
- panel
- role=dialog, max-h-[60vh]
- search
- autofocused, filters as you type
- item
- icon · label · shortcut
- active
- highlight fill behind the hot row
const filteredGroups = groups
.map((g) => ({ ...g, items: g.items.filter((i) => matches(i, query)) }))
.filter((g) => g.items.length > 0);
function matches(item: CommandItem, query: string) {
if (!query) return true;
const q = query.toLowerCase();
return (
item.label.toLowerCase().includes(q) ||
item.keywords?.some((k) => k.toLowerCase().includes(q)) === true
);
}matches checks the label and an optional keywords array, so an item
titled "Toggle Theme" can still surface on "dark" or "light" — the palette
searches intent, not just the string on screen.
Enter and exit share one spring
The panel doesn't fade in from nowhere — it resolves out of a blur, like a
lens racking into focus. initial/animate/exit move four properties at
once (opacity, scale, y, filter: blur()), all on the same spring:
- enter
- hold
- exit
- spring
- —
- spring
- scale .96 → 1, blur 8 → 0
- settled, opacity 1
- scale .97, blur 6px
<motion.div
initial={{ opacity: 0, scale: 0.96, y: -8, filter: "blur(8px)" }}
animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.97, y: -6, filter: "blur(6px)" }}
transition={{ type: "spring", stiffness: 320, damping: 32, mass: 0.9 }}
/>The exit isn't a mirror of the entrance — scale 0.97 and y -6 are a
shorter throw than 0.96/-8, and the blur is 6px instead of 8. Dismissing
reads as a quick settle rather than the entrance played backward, which
matters because exits happen far more often than opens (every Escape,
every selection, every backdrop click).
One highlight, not one per row
The active-row indicator isn't a class toggled on whichever row is hot —
it's a single motion.span that only ever exists inside one row at a time,
bound by a shared layoutId:
layoutId="command-active" · spring 500/35 · re-targets on ArrowUp/Down or mouse move
{isActive ? (
<motion.span
layoutId="command-active"
transition={{ type: "spring", stiffness: 500, damping: 35 }}
className="absolute inset-0 rounded-lg bg-accent"
/>
) : null}When activeIndex changes — ArrowDown/ArrowUp, or onMouseMove
re-targeting the row under the cursor — the span it was rendered inside
unmounts and a new one mounts in the new row. Framer notices they share
layoutId, diffs the old box against the new one, and springs that
transform instead of cross-fading two static rects. The spring is stiffer
than the panel's own enter (500/35 vs 320/32) because it has to keep up
with a held-down arrow key without lagging behind it.
Keyboard, ⌘K, and cleanup
ArrowDown/ArrowUp wrap with a modulo so the cursor never runs off the
list, Enter selects whatever flat[activeIndex] currently is, and
Escape closes:
if (e.key === "ArrowDown") {
setActiveIndex((i) => (i + 1) % Math.max(flat.length, 1));
} else if (e.key === "ArrowUp") {
setActiveIndex((i) => (i - 1 + Math.max(flat.length, 1)) % Math.max(flat.length, 1));
} else if (e.key === "Enter") {
const item = flat[activeIndex];
if (item) select(item);
}With enableShortcut (the default), a document-level keydown listener
toggles onOpenChange(!open) on ⌘K / Ctrl+K — bound and torn down in one
effect keyed on [enableShortcut, open, onOpenChange], so it's live even
before the palette has ever opened. A separate effect keyed on open
autofocuses the input via requestAnimationFrame (so it fires after the
portal paints) and locks document.body.style.overflow, restoring the
previous value on close — a background page can't scroll under an open
palette, and it never leaks the lock if the component unmounts mid-session.
The result
Open the palette and run a command
One groups array, filtered in place, walked flat, with a single animated
highlight riding on top of it. The panel is a spring; the rows are just
data.