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.
↓Scroll to step through it
Backdrop, panel, rows
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.
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.
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 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.
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
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.
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.
The result
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.
- 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);}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.
- 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);}Backdrop, panel, rows
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.
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.
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
- hold
- exit
- spring
- —
- spring
- scale .96 → 1, blur 8 → 0
- settled, opacity 1
- scale .97, blur 6px
<motion.divinitial={{ 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 }}/>Enter and exit 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.
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).
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}One highlight
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.
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.
Open the palette and run a command
The result
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.
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.
Motion Score
opacityFade / cross-fadefilterFilter (blur / brightness)