Command Palette Open
The palette drops a short distance into place with its results already filtering as it lands.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useId, useRef, useState } from "react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
/**
* Vibary · Command Palette Open
*
* The palette drops a short distance into place, the field takes focus on
* the way down, and the results are already filtering by the time it
* lands. Type to narrow, arrows to move, Enter to run, Escape to leave.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The page behind is mixed from the inherited text color and the palette
* follows the host app's color scheme, so both land correctly on a light
* page and on a dark one.
* Works with zero props; tune via `variant`, `placeholder`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type CommandPaletteOpenProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Field placeholder, also the palette's accessible name. */
placeholder?: string;
/** Notified with the id of the command that was run. */
onRun?: (id: string) => void;
};
type VariantConfig = {
spring: { type: "spring"; stiffness: number; damping: number };
/** How far above its resting place the palette starts, in pixels. */
drop: number;
/** Seconds between one result appearing and the next. */
stagger: number;
scrimFade: number;
};
// Quality rule: a palette is a wall of text that appears under the user's
// hands mid-keystroke, so it has to be still by the time they read it.
// Every spring is well above a 0.8 damping ratio — it arrives and stops —
// and the drop is short enough that the rows never blur. Variants change
// the distance and the gap between rows, never the settle.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely a drop. For people who open this fifty times a day.
subtle: {
spring: { type: "spring", stiffness: 620, damping: 48 },
drop: 6,
stagger: 0.016,
scrimFade: 0.14,
},
// Enough travel to register as arriving. All-purpose.
default: {
spring: { type: "spring", stiffness: 520, damping: 42 },
drop: 12,
stagger: 0.026,
scrimFade: 0.18,
},
// A longer drop and a wider gap between rows, so the list reads as
// assembling itself.
playful: {
spring: { type: "spring", stiffness: 440, damping: 37 },
drop: 18,
stagger: 0.036,
scrimFade: 0.2,
},
};
const ACCENT = "#7C7CF0";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
type CommandIcon = "document" | "person" | "chart" | "export" | "billing" | "ticket";
const COMMANDS: readonly {
id: string;
label: string;
group: string;
icon: CommandIcon;
}[] = [
{ id: "new-doc", label: "New document", group: "Create", icon: "document" },
{ id: "invite", label: "Invite a teammate", group: "Team", icon: "person" },
{ id: "analytics", label: "Open analytics", group: "Jump to", icon: "chart" },
{ id: "export", label: "Export orders", group: "Data", icon: "export" },
{ id: "billing", label: "Billing settings", group: "Jump to", icon: "billing" },
{ id: "tickets", label: "Search support requests", group: "Support", icon: "ticket" },
];
function CommandGlyph({ name }: { name: CommandIcon }) {
const common = {
width: 15,
height: 15,
viewBox: "0 0 20 20",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
if (name === "document") {
return (
<svg {...common}>
<path d="M11.5 2.5H5.5a1.5 1.5 0 0 0-1.5 1.5v12a1.5 1.5 0 0 0 1.5 1.5h9a1.5 1.5 0 0 0 1.5-1.5V7z" />
<path d="M11.5 2.5V7H16" />
</svg>
);
}
if (name === "person") {
return (
<svg {...common}>
<circle cx="10" cy="7" r="3" />
<path d="M4 16.5c0-2.8 2.7-4.5 6-4.5s6 1.7 6 4.5" />
</svg>
);
}
if (name === "chart") {
return (
<svg {...common}>
<path d="M3 16.5h14" />
<path d="M6 16.5V10" />
<path d="M10 16.5V4.5" />
<path d="M14 16.5v-4" />
</svg>
);
}
if (name === "export") {
return (
<svg {...common}>
<path d="M10 3v9" />
<path d="M6.5 8.5 10 12l3.5-3.5" />
<path d="M3.5 14.5v1.5a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-1.5" />
</svg>
);
}
if (name === "billing") {
return (
<svg {...common}>
<rect x="2.6" y="4.4" width="14.8" height="11.2" rx="2" />
<path d="M2.6 8.4h14.8" />
</svg>
);
}
return (
<svg {...common}>
<circle cx="9" cy="9" r="5.4" />
<path d="M13 13l4 4" />
</svg>
);
}
export default function CommandPaletteOpen({
variant = "default",
placeholder = "Search commands",
onRun,
}: CommandPaletteOpenProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [cursor, setCursor] = useState(0);
const [lastRun, setLastRun] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const listId = useId();
const results = COMMANDS.filter((command) =>
`${command.label} ${command.group}`.toLowerCase().includes(query.trim().toLowerCase())
);
// Filtering can shrink the list out from under the cursor.
const active = Math.min(cursor, Math.max(results.length - 1, 0));
const openPalette = () => {
setQuery("");
setCursor(0);
setOpen(true);
};
// The field takes focus as the palette is still travelling, which is why
// the results can already be filtering by the time it lands.
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
useEffect(() => {
// One palette per app is the assumption a global shortcut makes. If two
// of these can be mounted at once, move this listener up to the app
// shell and pass `open` down instead.
const onKey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setQuery("");
setCursor(0);
setOpen((current) => !current);
} else if (event.key === "Escape") {
setOpen(false);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const run = (id: string) => {
setLastRun(COMMANDS.find((command) => command.id === id)?.label ?? null);
setOpen(false);
onRun?.(id);
};
// Reduced motion: the palette still arrives over a dimmed page and the
// rows still appear in order, they just stop travelling to get there.
const paletteVariants: Variants = reduceMotion
? {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { duration: 0.14, ease: "easeOut", staggerChildren: 0 },
},
exit: { opacity: 0, transition: { duration: 0.1 } },
}
: {
hidden: { opacity: 0, y: -cfg.drop, scale: 0.985 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: {
...cfg.spring,
// Rows begin arriving immediately, while the palette is still
// on its way down — that overlap is what makes the list feel
// like it was already there.
delayChildren: 0.02,
staggerChildren: cfg.stagger,
},
},
exit: {
opacity: 0,
y: -cfg.drop * 0.5,
scale: 0.99,
transition: { duration: 0.12, ease: "easeIn" },
},
};
const rowVariants: Variants = reduceMotion
? {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.1 } },
exit: { opacity: 0, transition: { duration: 0.08 } },
}
: {
hidden: { opacity: 0, y: -6 },
visible: { opacity: 1, y: 0, transition: { duration: 0.18, ease: "easeOut" } },
exit: { opacity: 0, y: -4, transition: { duration: 0.1, ease: "easeIn" } },
};
return (
<div
style={{
position: "relative",
width: 344,
height: 386,
display: "flex",
flexDirection: "column",
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 16px 40px rgba(0,0,0,0.18)",
// The palette is positioned against this box rather than the
// viewport, so the pattern drops into a preview or an embedded
// card. For an app-level palette, swap `absolute` for `fixed` on
// the scrim and the palette.
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "0 14px",
height: 50,
borderBottom: `1px solid ${tone(12)}`,
}}
>
<span style={{ flex: 1, fontSize: 13.5, fontWeight: 650 }}>Workspace</span>
<button
type="button"
onClick={openPalette}
aria-haspopup="dialog"
aria-expanded={open}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 8px 6px 10px",
borderRadius: 9,
border: `1px solid ${tone(14)}`,
background: tone(8),
color: "inherit",
fontSize: 12,
fontFamily: "inherit",
opacity: 0.8,
cursor: "pointer",
}}
>
Jump to
{["Cmd", "K"].map((cap) => (
<span
key={cap}
aria-hidden
style={{
padding: "1px 5px",
borderRadius: 5,
background: tone(12),
fontSize: 10.5,
fontWeight: 650,
}}
>
{cap}
</span>
))}
</button>
</div>
<div style={{ flex: 1, padding: "12px 16px" }}>
<div style={{ fontSize: 11.5, opacity: 0.5 }}>Recent</div>
{["Q3 revenue summary", "Vendor agreement v4", "Refund policy"].map(
(item) => (
<div
key={item}
style={{
display: "flex",
alignItems: "center",
gap: 9,
padding: "9px 0",
borderBottom: `1px solid ${tone(10)}`,
fontSize: 12.5,
}}
>
<CommandGlyph name="document" />
{item}
</div>
)
)}
<div style={{ fontSize: 11.5, opacity: 0.5, marginTop: 14 }}>
{lastRun ? `Ran: ${lastRun}` : "Nothing run yet"}
</div>
</div>
{/* The scrim and the palette are siblings so AnimatePresence tracks
both directly — inside a fragment it would see neither and skip
the exit. */}
<AnimatePresence>
{open && (
<motion.div
key="scrim"
aria-hidden
onClick={() => setOpen(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.12 } }}
transition={{ duration: cfg.scrimFade, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
// A scrim darkens in both themes — light or dark, the page
// behind an overlay recedes — so this one stays literal.
background: "rgba(0,0,0,0.44)",
cursor: "pointer",
}}
/>
)}
{open && (
<motion.div
key="palette"
role="dialog"
aria-modal="true"
aria-label={placeholder}
variants={paletteVariants}
initial="hidden"
animate="visible"
exit="exit"
style={{
position: "absolute",
top: 56,
left: 18,
right: 18,
display: "flex",
flexDirection: "column",
borderRadius: 14,
// The one surface here that cannot be translucent: it sits on
// top of the scrim, and a see-through palette would read as
// more scrim. `Canvas`/`CanvasText` are the CSS system colors
// for page background and page text, so it lands light in a
// light app and dark in a dark one. Everything inside then
// mixes from `currentColor`.
background: "Canvas",
color: "CanvasText",
border: `1px solid ${tone(14)}`,
boxShadow: "0 22px 50px rgba(0,0,0,0.36)",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 9,
padding: "11px 13px",
borderBottom: `1px solid ${tone(12)}`,
}}
>
<span aria-hidden style={{ display: "grid", opacity: 0.5 }}>
<CommandGlyph name="ticket" />
</span>
<input
ref={inputRef}
value={query}
onChange={(event) => {
setQuery(event.target.value);
setCursor(0);
}}
placeholder={placeholder}
aria-label={placeholder}
role="combobox"
aria-expanded
aria-autocomplete="list"
aria-controls={listId}
aria-activedescendant={
results[active] ? `${listId}-${results[active].id}` : undefined
}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setCursor(
results.length ? (active + 1) % results.length : 0
);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setCursor(
results.length
? (active - 1 + results.length) % results.length
: 0
);
} else if (event.key === "Enter" && results[active]) {
event.preventDefault();
run(results[active].id);
}
}}
style={{
flex: 1,
minWidth: 0,
border: 0,
outline: "none",
background: "transparent",
color: "inherit",
fontSize: 13,
fontFamily: "inherit",
}}
/>
<span
aria-hidden
style={{
padding: "1px 5px",
borderRadius: 5,
background: tone(10),
fontSize: 10.5,
fontWeight: 650,
opacity: 0.6,
}}
>
Esc
</span>
</div>
<div
id={listId}
role="listbox"
aria-label="Commands"
style={{ padding: 6, minHeight: 168 }}
>
{/* popLayout pulls a filtered-out row from the flow the
instant it starts leaving, so the rows below close the
gap immediately instead of after the fade. */}
<AnimatePresence initial={false} mode="popLayout">
{results.map((command, index) => {
const isActive = index === active;
return (
<motion.div
key={command.id}
id={`${listId}-${command.id}`}
role="option"
aria-selected={isActive}
layout={reduceMotion ? false : "position"}
variants={rowVariants}
onMouseEnter={() => setCursor(index)}
onClick={() => run(command.id)}
transition={reduceMotion ? { duration: 0 } : cfg.spring}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "9px 10px",
borderRadius: 9,
background: isActive ? tone(9) : "transparent",
cursor: "pointer",
}}
>
<span
aria-hidden
style={{
display: "grid",
color: isActive ? ACCENT : "inherit",
opacity: isActive ? 1 : 0.6,
}}
>
<CommandGlyph name={command.icon} />
</span>
<span style={{ flex: 1, fontSize: 12.5, fontWeight: 550 }}>
{command.label}
</span>
<span style={{ fontSize: 10.5, opacity: 0.45 }}>
{command.group}
</span>
</motion.div>
);
})}
</AnimatePresence>
{results.length === 0 && (
<div
style={{
padding: "18px 10px",
fontSize: 12.5,
opacity: 0.5,
textAlign: "center",
}}
>
No command matches “{query}”
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}About this pattern
Power-user navigation, and the moment where motion has to get out of the way fastest: the palette appears under hands that are already typing. So the drop is short, the spring is damped well past the wobble threshold, and the field takes focus while the surface is still travelling — by the time it stops, the first keystroke has already narrowed the list. The rows begin arriving a beat before the palette settles, each a couple of frames after the last, which reads as a list that was there rather than one being built. Filtering afterwards uses AnimatePresence popLayout so a row that stops matching leaves the flow immediately and the rows beneath close the gap in the same frame, instead of stepping down after the fade.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Command palette
The palette drops in over the page with the first result already highlighted.