The problem
A command palette that arrives as a modal over the page throws away the one piece of context people had: where the search box was, and what it belonged to. The overlay appears from nowhere, dims everything, and has to be dismissed before the page can be seen again.
Growing the field itself into the panel keeps the origin. The list reads as the field having more to say rather than as a new surface arriving on top of the old one — and closing puts you back exactly where you started.
Use cases
- App-wide search and actions behind ⌘K
- A dashboard's "what can I do here" affordance, discoverable without a shortcut
- Any searchable action list of roughly ten to fifty items
Not for: navigation with a handful of destinations — a menu is cheaper. Not for search that returns rich results with previews and pagination; this list is one line per item. Not as a modal replacement: it deliberately does not trap focus or dim the page, which is what makes it feel inline.
Installation
npm install motion lucide-reactThen copy CommandPalette.tsx. It depends only on @/lib/cn.
Usage
import CommandPalette from "@/patterns/command-palette/CommandPalette";
import { Calendar, UserPlus } from "lucide-react";
<CommandPalette
commands={[
{
id: "schedule",
label: "Create new schedule",
icon: <Calendar />,
shortcut: ["⌘", "S"],
onSelect: () => createSchedule(),
},
{
id: "customer",
label: "Add new customer",
icon: <UserPlus />,
onSelect: addCustomer,
},
]}
/>;API
| Prop | Type | Default | Description |
|---|---|---|---|
commands | readonly Command[] | — | The full list, filtered as you type. |
collapsedLabel | string | "Search for anything" | Resting placeholder and the trigger's name. |
placeholder | string | "Search commands…" | Placeholder once open. |
groupLabel | string | "Suggestions" | Heading above the results. |
hotkey | boolean | true | Bind ⌘K / Ctrl+K on the document. |
className | string | — | Merged onto the root. |
Command: { id: string; label: string; icon: ReactNode; shortcut?: readonly string[]; onSelect?: () => void }.
Turn hotkey off when more than one palette can exist on a page — otherwise
every instance answers the same chord at once.
Keyboard
| Key | Action |
|---|---|
⌘K / Ctrl+K | Open from anywhere |
↑ / ↓ | Move the highlight, wrapping |
Home / End | First or last result |
Enter | Run the highlighted command |
Escape | Close, clear the query, focus back on the trigger |
The field keeps focus the whole time and the highlight moves through
aria-activedescendant. Moving real DOM focus into the list would take it off
the input, and in a palette you have to be able to keep typing.
Accessibility
None of the commands did anything. Every row was a button with no click
handler and no onSelect — the list filtered, highlighted and responded to
hover, and selecting an item was impossible for anyone. They run now, by click
or by Enter.
There was no way to move through the list. A command palette whose arrow
keys do nothing is the pattern's central interaction missing: you could type to
filter but not reach what you filtered to. Arrows, Home, End and Enter all work,
against an APG combobox contract — role="combobox" on the field with
aria-expanded, aria-controls and aria-activedescendant, and a role="listbox"
of role="option" rows carrying aria-selected.
The close button was empty. It rendered as <button></button> — no glyph,
no label, no content at all — so it was an invisible control sitting in the tab
order that announced as "button". It has an icon and a name now.
The trigger declares its shortcut with aria-keyshortcuts, so the ⌘K binding
is discoverable rather than folklore. Focus follows the surface: into the field
on open, back to the trigger on close.
The highlight is clamped rather than reset in an effect — filtering can shorten the list underneath a highlight that has already moved past the new end.
Under prefers-reduced-motion the shared-layout growth still runs (it is
position and size, not travel) while the row fades and the dismiss button's
entrance delay are dropped.
Known limitation: this does not trap focus or dim the page, so it is not a
modal. That is deliberate — it is what makes the palette read as the field
expanding rather than as an overlay — but it means Tab can walk out of the
open panel into the page behind it. If you need containment, wrap it in a real
dialog and lose the inline feel.
Performance
The two states share a layoutId, so collapsed-to-expanded is one shared-layout
projection rather than a crossfade between two boxes. The ids are scoped with
useId, so several palettes on a page do not animate into each other.
Filtering is useMemo over the source list — derived state, not state
synchronised in an effect, which would cost a render per keystroke and show a
stale list for one frame.
The bottom fade is sticky, not absolute. Positioned absolutely inside the
scroll container it scrolls away with the content it is supposed to be fading.
Source
The whole component. In a copy-paste library the source is the delivery mechanism, not an appendix — so it is read straight off disk at build time and cannot drift from what ships.
"use client";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Search, X } from "lucide-react";
import { cn } from "@/lib/cn";
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export interface Command {
id: string;
label: string;
icon: React.ReactNode;
/** Rendered as `<kbd>` chips, e.g. `["⌘", "S"]`. */
shortcut?: readonly string[];
onSelect?: () => void;
}
export interface CommandPaletteProps extends Omit<
React.ComponentPropsWithoutRef<"div">,
"onSelect"
> {
commands: readonly Command[];
/** Resting placeholder. @default "Search for anything" */
collapsedLabel?: string;
/** Placeholder once open. @default "Search commands…" */
placeholder?: string;
/** Heading above the results. @default "Suggestions" */
groupLabel?: string;
/** Bind ⌘K / Ctrl+K on the document. @default true */
hotkey?: boolean;
}
/* -------------------------------------------------------------------------- */
/* Motion — kept verbatim from the original */
/* -------------------------------------------------------------------------- */
const RADIUS = 30;
/** The dismiss button waits for the container to finish widening. */
const CLOSE_DELAY = 0.19;
const ITEM = { duration: 0.15 } as const;
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
/**
* A search field that becomes a command list.
*
* A command palette that arrives as a modal over the page throws away the one
* piece of context people had — where the search box was. Growing the field
* itself into the panel keeps the origin, so the list reads as the field
* having more to say rather than as a new surface.
*/
export function CommandPalette({
commands,
collapsedLabel = "Search for anything",
placeholder = "Search commands…",
groupLabel = "Suggestions",
hotkey = true,
className,
...rest
}: CommandPaletteProps) {
const reduce = useReducedMotion();
const uid = useId();
const listId = `${uid}-list`;
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const interacted = useRef(false);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
// Derived, not synchronised. Mirroring this into state via an effect costs a
// render per keystroke and shows a stale list for one frame.
const results = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return commands;
return commands.filter((c) => c.label.toLowerCase().includes(q));
}, [commands, query]);
// Clamped rather than reset in an effect: filtering can shorten the list
// under a highlight that has already moved past the new end.
const active = Math.min(activeIndex, Math.max(0, results.length - 1));
/*
* `AnimatePresence mode="wait"` holds the incoming element back until the
* outgoing one has finished exiting, so at the moment `open` flips the
* element we want to focus does not exist yet. Focusing from an effect keyed
* on `open` therefore focuses `null` and drops focus to the body. These
* flags let the ref callbacks below claim focus as each element mounts.
*/
const focusTriggerOnMount = useRef(false);
const focusFieldOnMount = useRef(false);
const close = useCallback(() => {
interacted.current = true;
focusTriggerOnMount.current = true;
setOpen(false);
setQuery("");
setActiveIndex(0);
}, []);
const openPalette = useCallback(() => {
interacted.current = true;
focusFieldOnMount.current = true;
setOpen(true);
}, []);
const run = useCallback(
(command: Command) => {
command.onSelect?.();
close();
},
[close],
);
useEffect(() => {
if (!hotkey) return;
const onKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
openPalette();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [hotkey, openPalette]);
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) close();
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open, close]);
/**
* APG combobox: the field keeps focus throughout and the highlight moves via
* `aria-activedescendant`. Moving real DOM focus into the list instead would
* take it off the input, and you have to keep typing.
*/
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setActiveIndex((i) =>
results.length ? (i + 1) % results.length : 0,
);
return;
case "ArrowUp":
event.preventDefault();
setActiveIndex((i) =>
results.length ? (i - 1 + results.length) % results.length : 0,
);
return;
case "Home":
event.preventDefault();
setActiveIndex(0);
return;
case "End":
event.preventDefault();
setActiveIndex(Math.max(0, results.length - 1));
return;
case "Enter": {
event.preventDefault();
const command = results[active];
if (command) run(command);
return;
}
case "Escape":
event.preventDefault();
close();
return;
default:
return;
}
},
[active, close, results, run],
);
return (
<div
ref={rootRef}
className={cn("flex w-full min-w-0 justify-center", className)}
{...rest}
>
<AnimatePresence mode="wait">
{!open ? (
<motion.div
key="collapsed"
layoutId={`${uid}-shell`}
style={{ borderRadius: RADIUS }}
className="w-full max-w-[350px] bg-surface"
>
<button
ref={(node) => {
triggerRef.current = node;
// Focus as it mounts, which is the first moment it exists.
if (node && focusTriggerOnMount.current) {
focusTriggerOnMount.current = false;
node.focus();
}
}}
type="button"
onClick={openPalette}
aria-label={collapsedLabel}
aria-keyshortcuts="Meta+K Control+K"
className={cn(
"focus-ring flex w-full cursor-text items-center gap-2 px-4 py-3",
"rounded-[30px] border border-border bg-surface shadow-resting",
"transition-shadow duration-200 hover:shadow-floating",
)}
>
<motion.span
layoutId={`${uid}-field`}
className="inline-flex items-center gap-2"
>
<Search
className="size-4 shrink-0 text-content-subtle"
aria-hidden="true"
/>
<span className="truncate text-sm text-content-subtle">
{collapsedLabel}
</span>
</motion.span>
</button>
</motion.div>
) : (
<motion.div
key="expanded"
layoutId={`${uid}-shell`}
style={{ borderRadius: RADIUS }}
className="w-full max-w-[640px] overflow-hidden bg-surface shadow-overlay"
>
<motion.div
layout
className="mx-3 flex items-center gap-3 border-b border-border px-4 py-3"
>
<motion.span layoutId={`${uid}-field`} className="inline-flex">
<Search
className="size-4 shrink-0 text-content-subtle"
aria-hidden="true"
/>
</motion.span>
<input
ref={(node) => {
inputRef.current = node;
if (node && focusFieldOnMount.current) {
focusFieldOnMount.current = false;
node.focus();
}
}}
type="text"
role="combobox"
aria-expanded
aria-controls={listId}
aria-label={placeholder}
aria-autocomplete="list"
aria-activedescendant={
results[active]
? `${uid}-opt-${results[active].id}`
: undefined
}
value={query}
onChange={(event) => {
setQuery(event.target.value);
setActiveIndex(0);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="min-w-0 flex-1 bg-transparent text-sm text-content placeholder-content-subtle outline-none"
/>
{/*
The original rendered this button with no children and no
label — an invisible, unnameable control sitting in the tab
order. It has a glyph and a name now.
*/}
<motion.button
type="button"
onClick={close}
aria-label="Close command palette"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={
reduce ? { duration: 0.01 } : { delay: CLOSE_DELAY }
}
className="focus-ring shrink-0 rounded p-1 text-content-subtle transition-colors hover:bg-surface-subtle hover:text-content"
>
<X className="size-4" aria-hidden="true" />
</motion.button>
</motion.div>
<div className="relative max-h-96 overflow-y-auto">
<div className="px-3 py-2">
<motion.div
layout="position"
id={`${uid}-group`}
className="px-3 py-1.5 text-xs font-medium tracking-wider text-content-subtle uppercase"
>
{groupLabel}
</motion.div>
<div
role="listbox"
id={listId}
aria-labelledby={`${uid}-group`}
>
<AnimatePresence mode="popLayout">
{results.length === 0 ? (
<motion.p
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="px-3 py-8 text-center text-sm text-content-subtle"
>
No commands found
</motion.p>
) : (
<motion.div
layout="preserve-aspect"
className="min-h-10 space-y-0.5"
>
{results.map((command, index) => (
<motion.button
key={command.id}
id={`${uid}-opt-${command.id}`}
type="button"
role="option"
aria-selected={index === active}
tabIndex={-1}
onClick={() => run(command)}
onPointerMove={() => setActiveIndex(index)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={reduce ? { duration: 0.01 } : ITEM}
className={cn(
"flex w-full cursor-pointer items-center justify-between gap-3",
"rounded-md px-3 py-2 transition-colors duration-200",
index === active && "bg-surface-subtle",
)}
>
<span className="flex flex-1 items-center gap-3">
<span
aria-hidden="true"
className="shrink-0 text-content-subtle [&>svg]:size-4"
>
{command.icon}
</span>
<span className="text-sm font-medium text-content">
{command.label}
</span>
</span>
{command.shortcut?.length ? (
<span className="flex shrink-0 items-center gap-1">
{command.shortcut.map((key) => (
<kbd
key={key}
className={cn(
"inline-flex size-4 items-center justify-center rounded",
"border border-border bg-surface-subtle text-xs",
"font-medium text-content-muted",
)}
>
{key}
</kbd>
))}
</span>
) : null}
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/*
Sticky, not absolute. Absolutely positioned against the
scrolling box it would scroll away with the content it is
meant to be fading.
*/}
<div
aria-hidden="true"
className="pointer-events-none sticky bottom-0 h-8 bg-gradient-to-t from-surface to-transparent"
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default CommandPalette;
Technologies
| Framework | React 19 |
| Motion | Motion 12 (motion/react), layoutId |
| Styling | Tailwind CSS v4 |
| Icons | lucide-react |
| Types | TypeScript 5.9, strict |
Credits
Original design by ydwndr. Rebuilt with a props API, working commands, arrow-key navigation, a combobox contract, a dismiss button that exists, and reduced-motion handling. The shared-layout growth, the 30px radius, the 190ms delay before the dismiss button appears and the 150ms row fade are unchanged.