Command Menu
A layered command palette summoned by a hotkey, with people, projects, and actions, matched search, and a paper-stack that fans out as it lands.
A bezel, panel, and recessed well stacked like paper, summoned with ⌘K and
settling in with a soft blur while two backing layers fan out behind it. People
carry a presence arc around their avatar, projects a gradient tile and progress
ring, actions a glyph chip, and the query is highlighted inline as you type.
Built with Motion.
Installation
Complete the shared Setup first, then copy the component into
components/ui/command-menu.tsx.
bun add motion'use client';import * as React from 'react';import { motion, AnimatePresence, useReducedMotion } from 'motion/react';import { cn } from '@/lib/cn';const EASE = [0.23, 1, 0.32, 1] as const;const FOCUS = 'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:[outline-color:var(--cmk-accent)]';export type CommandPresence = 'online' | 'busy' | 'away';export type CommandPerson = { kind: 'person'; id: string; name: string; role: string; avatar?: string; presence: CommandPresence;};export type CommandProject = { kind: 'project'; id: string; name: string; meta: string; gradient: [string, string]; progress: number; shortcut: string;};export type CommandAction = { kind: 'action'; id: string; label: string; glyph: string; shortcut: string; receipt: string;};export type CommandRow = CommandPerson | CommandProject | CommandAction;export type CommandGroup = { title: string; rows: CommandRow[] };export type CommandMenuProps = { groups: CommandGroup[]; open?: boolean; onOpenChange?: (open: boolean) => void; hotkey?: string | null; placeholder?: string; accent?: string; onRun?: (row: CommandRow) => void; className?: string;};const PRESENCE: Record< CommandPresence, { color: string; arc: number; label: string }> = { online: { color: '#4FAE7E', arc: 1, label: 'online' }, busy: { color: '#D96A5F', arc: 0.82, label: 'busy' }, away: { color: '#D9A13F', arc: 0.45, label: 'away' },};function initials(name: string) { return name .split(' ') .map((w) => w[0]) .slice(0, 2) .join('') .toUpperCase();}function hashName(name: string) { let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0; return Math.abs(h);}const AVATAR_TINT = ['#f0883e', '#3ecf8e', '#22d3ee', '#b06bff', '#f0463a'];function Match({ text, q }: { text: string; q: string }) { if (!q) return <>{text}</>; const i = text.toLowerCase().indexOf(q.toLowerCase()); if (i === -1) return <>{text}</>; return ( <> {text.slice(0, i)} <span className="rounded-[3px] px-px" style={{ background: 'var(--cmk-hl-bg)', color: 'var(--cmk-hl-text)' }} > {text.slice(i, i + q.length)} </span> {text.slice(i + q.length)} </> );}function Key({ children }: { children: React.ReactNode }) { return ( <kbd className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[5px] px-1 text-[9.5px] font-medium" style={{ color: 'var(--cmk-mute)', background: 'var(--cmk-key-bg)', border: '1px solid var(--cmk-key-border)', boxShadow: '0 1.5px 0 var(--cmk-key-shadow)', }} > {children} </kbd> );}function Ring({ value, from, to, id,}: { value: number; from: string; to: string; id: string;}) { const R = 5.5; const C = 2 * Math.PI * R; return ( <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden> <defs> <linearGradient id={`cmk-ring-${id}`} x1="0" y1="0" x2="1" y2="1"> <stop offset="0%" stopColor={from} /> <stop offset="100%" stopColor={to} /> </linearGradient> </defs> <circle cx="8" cy="8" r={R} fill="none" stroke="var(--cmk-ring-track)" strokeWidth="2" /> <circle cx="8" cy="8" r={R} fill="none" stroke={`url(#cmk-ring-${id})`} strokeWidth="2" strokeLinecap="round" strokeDasharray={`${C * value} ${C}`} transform="rotate(-90 8 8)" /> </svg> );}function Lead({ row }: { row: CommandRow }) { if (row.kind === 'person') { const p = PRESENCE[row.presence]; const C = 2 * Math.PI * 14; return ( <span className="relative inline-block size-[26px] shrink-0"> {row.avatar ? ( <img src={row.avatar} alt="" width={26} height={26} draggable={false} className="size-[26px] rounded-full object-cover" /> ) : ( <span className="grid size-[26px] place-items-center rounded-full text-[10px] font-semibold" style={{ background: AVATAR_TINT[hashName(row.name) % AVATAR_TINT.length], color: '#141612', }} > {initials(row.name)} </span> )} <svg aria-hidden viewBox="0 0 34 34" className="absolute -inset-[4px] size-[34px]" > <circle cx="17" cy="17" r="14" fill="none" stroke={p.color} strokeWidth="1.75" strokeLinecap="round" strokeDasharray={`${C * p.arc} ${C}`} transform="rotate(-90 17 17)" opacity="0.9" /> </svg> </span> ); } if (row.kind === 'project') { return ( <span aria-hidden className="block size-[26px] shrink-0 rounded-[8px]" style={{ background: `linear-gradient(135deg, var(--cmk-tile-lit) 28%, ${row.gradient[0]} 90%, ${row.gradient[1]} 140%)`, boxShadow: 'var(--cmk-tile-shadow)', }} /> ); } return ( <span aria-hidden className="flex size-[26px] shrink-0 items-center justify-center rounded-[8px] text-[12px]" style={{ color: 'var(--cmk-mute)', background: 'var(--cmk-chip-bg)', border: '1px solid var(--cmk-chip-border)', boxShadow: 'var(--cmk-chip-shadow)', }} > {row.glyph} </span> );}export function CommandMenu({ groups, open: controlledOpen, onOpenChange, hotkey = 'k', placeholder = 'Search people, projects, actions', accent = '#f0883e', onRun, className,}: CommandMenuProps) { const [internalOpen, setInternalOpen] = React.useState(false); const [query, setQuery] = React.useState(''); const [sel, setSel] = React.useState(0); const [receipt, setReceipt] = React.useState<string | null>(null); const listRef = React.useRef<HTMLDivElement>(null); const inputRef = React.useRef<HTMLInputElement>(null); const reduce = useReducedMotion(); const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : internalOpen; const openRef = React.useRef(open); openRef.current = open; const setOpen = React.useCallback( (v: boolean) => { if (!isControlled) setInternalOpen(v); onOpenChange?.(v); }, [isControlled, onOpenChange], ); const sections: CommandGroup[] = React.useMemo(() => { const q = query.trim().toLowerCase(); const match = (row: CommandRow) => { if (!q) return true; const hay = row.kind === 'person' ? `${row.name} ${row.role}` : row.kind === 'project' ? `${row.name} ${row.meta}` : row.label; return hay.toLowerCase().includes(q); }; return groups .map((g) => ({ title: g.title, rows: g.rows.filter(match) })) .filter((g) => g.rows.length > 0); }, [groups, query]); const flat = React.useMemo( () => sections.flatMap((s) => s.rows), [sections], ); const current = flat[sel]; const onlinePeople = React.useMemo( () => groups .flatMap((g) => g.rows) .filter( (r): r is CommandPerson => r.kind === 'person' && r.presence === 'online', ), [groups], ); React.useEffect(() => setSel(0), [query]); React.useEffect(() => { listRef.current?.querySelector('[data-sel="true"]')?.scrollIntoView({ block: 'nearest', behavior: reduce ? 'auto' : 'smooth', }); }, [sel, reduce]); const close = React.useCallback(() => { setOpen(false); setQuery(''); setSel(0); setReceipt(null); }, [setOpen]); React.useEffect(() => { if (!hotkey) return; const onKey = (e: KeyboardEvent) => { if (e.key.toLowerCase() === hotkey && (e.metaKey || e.ctrlKey)) { e.preventDefault(); if (openRef.current) { close(); } else { setQuery(''); setSel(0); setReceipt(null); setOpen(true); } } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [hotkey, close, setOpen]); React.useEffect(() => { if (!open) return; const prev = document.documentElement.style.overflow; document.documentElement.style.overflow = 'hidden'; return () => { document.documentElement.style.overflow = prev; }; }, [open]); const run = React.useCallback( (row: CommandRow) => { onRun?.(row); const text = row.kind === 'person' ? `opening chat with ${row.name.split(' ')[0]}` : row.kind === 'project' ? `jumping to ${row.name}` : row.receipt; setReceipt(text); window.setTimeout(close, 700); }, [onRun, close], ); const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(flat.length - 1, s + 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(0, s - 1)); } else if (e.key === 'Enter' && current && !receipt) { e.preventDefault(); run(current); } else if (e.key === 'Escape') { e.preventDefault(); close(); } }; const vars = { '--cmk-accent': accent } as React.CSSProperties; return ( <AnimatePresence> {open && ( <div style={vars} className={cn( 'cmk-root fixed inset-0 z-[100] flex items-center justify-center px-4', className, )} role="dialog" aria-modal="true" aria-label="Command menu" > <motion.button aria-label="Close" onClick={close} className="absolute inset-0 cursor-default" style={{ background: 'var(--cmk-scrim)' }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.18, ease: 'easeOut' }} /> <motion.div className="relative w-full max-w-[600px]" style={{ transformOrigin: '50% 0%' }} initial={ reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: 10, filter: 'blur(6px)' } } animate={{ opacity: 1, scale: 1, y: 0, filter: 'blur(0px)' }} exit={ reduce ? { opacity: 0 } : { opacity: 0, scale: 0.98, y: 6, filter: 'blur(3px)' } } transition={{ duration: 0.22, ease: EASE }} > <motion.div aria-hidden className="absolute -top-[14px] left-1/2 h-6 w-[86%] -translate-x-1/2 rounded-t-[18px]" style={{ background: 'var(--cmk-layer-2)' }} initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }} animate={{ opacity: 0.55, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.24, delay: 0.1, ease: EASE }} /> <motion.div aria-hidden className="absolute -top-[7px] left-1/2 h-6 w-[94%] -translate-x-1/2 rounded-t-[20px]" style={{ background: 'var(--cmk-layer-1)' }} initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6 }} animate={{ opacity: 0.9, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.22, delay: 0.06, ease: EASE }} /> <div className="relative rounded-[24px] p-[6px]" style={{ background: 'var(--cmk-bezel)', boxShadow: 'var(--cmk-bezel-shadow)', }} > <div className="overflow-hidden rounded-[19px]" style={{ background: 'var(--cmk-panel)', boxShadow: 'var(--cmk-panel-shadow)', }} onKeyDown={onKeyDown} > <div className="p-3 pb-0"> <div className="flex items-center gap-3 rounded-[12px] px-3.5 py-2.5" style={{ background: 'var(--cmk-well)', boxShadow: 'var(--cmk-well-shadow)', }} > <svg aria-hidden width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0" > <circle cx="7" cy="7" r="4.5" stroke={query ? 'var(--cmk-text)' : 'var(--cmk-faint)'} strokeWidth="1.6" /> <path d="M10.5 10.5 L13.5 13.5" stroke={query ? 'var(--cmk-text)' : 'var(--cmk-faint)'} strokeWidth="1.6" strokeLinecap="round" /> </svg> <input ref={inputRef} autoFocus value={query} onChange={(e) => setQuery(e.target.value)} placeholder={placeholder} aria-label="Search" className="flex-1 bg-transparent text-[13.5px] font-medium outline-none" style={{ color: 'var(--cmk-text)', caretColor: 'var(--cmk-accent)', }} /> <span className="text-[10px] tabular-nums" style={{ color: 'var(--cmk-faint)' }} > {query ? `${flat.length} results` : ''} </span> <Key>esc</Key> </div> </div> <div ref={listRef} role="listbox" aria-label="Results" className="max-h-[356px] overflow-y-auto px-3 pb-4 pt-1" style={{ scrollPadding: '18px 0 26px', maskImage: 'linear-gradient(to bottom, transparent, black 8px, black calc(100% - 20px), transparent)', }} > <div className="relative"> {flat.length === 0 && ( <div className="px-2 py-8 text-center"> <p className="text-[12.5px] font-medium" style={{ color: 'var(--cmk-mute)' }} > nothing for “{query}” </p> <p className="mt-1 text-[11px]" style={{ color: 'var(--cmk-faint)' }} > try a person, a project, or an action </p> </div> )} {sections.map((section) => ( <div key={section.title} className="mt-2.5"> <div className="flex items-center justify-between px-2 pb-1"> <p className="text-[10px] font-semibold uppercase tracking-[0.08em]" style={{ color: 'var(--cmk-faint)' }} > {section.title} </p> <p className="text-[9.5px] tabular-nums" style={{ color: 'var(--cmk-faint)' }} > {section.rows.length} </p> </div> {section.rows.map((row) => { const i = flat.indexOf(row); const isSel = i === sel; return ( <button key={row.id} role="option" aria-selected={isSel} data-sel={isSel} onMouseMove={() => setSel(i)} onClick={() => run(row)} className={cn( 'block w-full rounded-[11px] px-2.5 py-[8px] text-left outline-none', 'transition-[background-color,box-shadow] duration-150', FOCUS, )} style={{ background: isSel ? 'var(--cmk-well)' : 'transparent', boxShadow: isSel ? 'var(--cmk-sel-shadow)' : 'none', }} > <span className="flex items-center gap-3.5 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" style={{ transform: isSel && !reduce ? 'translateX(3px)' : 'translateX(0px)', }} > <span className="relative"> <Lead row={row} /> </span> <span className="relative min-w-0 flex-1"> <span className="block truncate text-[13px] font-medium leading-tight" style={{ color: 'var(--cmk-text)' }} > <Match text={ row.kind === 'action' ? row.label : row.name } q={query.trim()} /> </span> <span className="block truncate text-[10.5px] leading-tight transition-colors duration-200" style={{ color: isSel ? 'var(--cmk-mute)' : 'var(--cmk-faint)', }} > {row.kind === 'person' ? ( <> {row.role} ·{' '} <span style={{ color: PRESENCE[row.presence].color, }} > {PRESENCE[row.presence].label} </span> </> ) : row.kind === 'project' ? ( row.meta ) : ( 'runs instantly' )} </span> </span> <span className="relative flex items-center gap-2"> {row.kind === 'project' && ( <> <Ring value={row.progress} from={row.gradient[0]} to={row.gradient[1]} id={row.id} /> <span className="w-7 text-right text-[9.5px] tabular-nums" style={{ color: 'var(--cmk-faint)' }} > {Math.round(row.progress * 100)}% </span> </> )} <kbd className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[5px] px-1 text-[9.5px] font-medium transition-colors duration-100" style={{ color: isSel ? 'var(--cmk-text)' : 'var(--cmk-mute)', background: 'var(--cmk-key-bg)', border: `1px solid ${isSel ? 'var(--cmk-key-border-strong)' : 'var(--cmk-key-border)'}`, boxShadow: '0 1.5px 0 var(--cmk-key-shadow)', }} > {row.kind === 'person' ? '@' : row.shortcut} </kbd> </span> </span> </button> ); })} </div> ))} </div> </div> <div className="flex h-11 items-center justify-between px-4" style={{ background: 'var(--cmk-footer)', borderTop: '1px solid var(--cmk-hairline)', }} > <AnimatePresence mode="wait" initial={false}> {receipt ? ( <motion.p key="receipt" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.1, ease: 'easeOut' }} className="text-[11.5px] font-medium" style={{ color: 'var(--cmk-text)' }} > <span style={{ color: '#3FB77A' }}>✓</span> {receipt} </motion.p> ) : ( <motion.div key="hints" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.1, ease: 'easeOut' }} className="flex items-center gap-3 text-[10.5px]" style={{ color: 'var(--cmk-mute)' }} > <span className="flex items-center gap-1"> <Key>↑</Key> <Key>↓</Key> move </span> <span className="flex items-center gap-1"> <Key>↵</Key> open </span> </motion.div> )} </AnimatePresence> {onlinePeople.length > 0 && ( <div className="flex items-center gap-2"> <span className="flex -space-x-1"> {onlinePeople.map((p) => p.avatar ? ( <img key={p.id} src={p.avatar} alt="" width={14} height={14} draggable={false} className="size-3.5 rounded-full object-cover" style={{ boxShadow: '0 0 0 1.5px var(--cmk-footer)' }} /> ) : ( <span key={p.id} className="grid size-3.5 place-items-center rounded-full text-[7px] font-semibold" style={{ background: AVATAR_TINT[ hashName(p.name) % AVATAR_TINT.length ], color: '#141612', boxShadow: '0 0 0 1.5px var(--cmk-footer)', }} > {initials(p.name)} </span> ), )} </span> <span className="text-[10px]" style={{ color: 'var(--cmk-faint)' }} > {onlinePeople.length} online </span> </div> )} </div> </div> </div> </motion.div> </div> )} </AnimatePresence> );}Add the theme tokens to your global.css.
.cmk-root {
--cmk-text: #1b1b19;
--cmk-mute: #73736d;
--cmk-faint: #a6a6a0;
--cmk-panel: #ffffff;
--cmk-bezel: #f0f0ee;
--cmk-well: #f6f6f4;
--cmk-footer: #fbfbfa;
--cmk-hairline: rgba(27, 27, 25, 0.07);
--cmk-scrim: rgba(27, 27, 25, 0.16);
--cmk-layer-1: #e9e9e6;
--cmk-layer-2: #ddddda;
--cmk-key-bg: #ffffff;
--cmk-key-border: rgba(27, 27, 25, 0.1);
--cmk-key-border-strong: rgba(27, 27, 25, 0.18);
--cmk-key-shadow: rgba(27, 27, 25, 0.08);
--cmk-hl-bg: #ffe9a8;
--cmk-hl-text: #1b1b19;
--cmk-ring-track: rgba(27, 27, 25, 0.08);
--cmk-tile-lit: #ffffff;
--cmk-tile-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5),
0 1px 2px rgba(27, 27, 25, 0.1);
--cmk-chip-bg: #ffffff;
--cmk-chip-border: rgba(27, 27, 25, 0.1);
--cmk-chip-shadow: 0 1px 1.5px rgba(27, 27, 25, 0.06);
--cmk-bezel-shadow: 0 32px 64px -24px rgba(27, 27, 25, 0.28),
0 0 0 1px rgba(27, 27, 25, 0.05);
--cmk-panel-shadow: 0 1px 2px rgba(27, 27, 25, 0.05);
--cmk-well-shadow: inset 0 1px 2px rgba(27, 27, 25, 0.05);
--cmk-sel-shadow: inset 0 0 0 1px rgba(27, 27, 25, 0.06),
0 1px 2px rgba(27, 27, 25, 0.04);
}
.dark .cmk-root {
--cmk-text: #f3f3ef;
--cmk-mute: #a2a29a;
--cmk-faint: #6e6e67;
--cmk-panel: #1e1e1b;
--cmk-bezel: #161513;
--cmk-well: #262622;
--cmk-footer: #1a1a17;
--cmk-hairline: rgba(255, 255, 255, 0.07);
--cmk-scrim: rgba(0, 0, 0, 0.5);
--cmk-layer-1: #232320;
--cmk-layer-2: #2a2a27;
--cmk-key-bg: #262622;
--cmk-key-border: rgba(255, 255, 255, 0.1);
--cmk-key-border-strong: rgba(255, 255, 255, 0.2);
--cmk-key-shadow: rgba(0, 0, 0, 0.4);
--cmk-hl-bg: rgba(255, 213, 120, 0.22);
--cmk-hl-text: #ffe9a8;
--cmk-ring-track: rgba(255, 255, 255, 0.1);
--cmk-tile-lit: #f3f3ef;
--cmk-tile-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14),
0 1px 2px rgba(0, 0, 0, 0.5);
--cmk-chip-bg: #262622;
--cmk-chip-border: rgba(255, 255, 255, 0.1);
--cmk-chip-shadow: 0 1px 1.5px rgba(0, 0, 0, 0.4);
--cmk-bezel-shadow: 0 32px 64px -24px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(255, 255, 255, 0.05);
--cmk-panel-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
--cmk-well-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.4);
--cmk-sel-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06),
0 1px 2px rgba(0, 0, 0, 0.35);
}Usage
import { CommandMenu, type CommandGroup } from '@/components/ui/command-menu';
const groups: CommandGroup[] = [
{
title: 'Projects',
rows: [
{
kind: 'project',
id: 'atlas',
name: 'Atlas redesign',
meta: '12 tasks · due Fri',
gradient: ['#A9C6F8', '#6E9BEF'],
progress: 0.72,
shortcut: '⌘1',
},
],
},
{
title: 'Quick actions',
rows: [
{
kind: 'action',
id: 'task',
label: 'New task',
glyph: '+',
shortcut: '⌘T',
receipt: 'task created in Atlas',
},
],
},
];
export default function Example() {
return <CommandMenu groups={groups} />;
}Left uncontrolled, the menu opens on ⌘K (or Ctrl+K) and closes on Esc.
Pass open and onOpenChange to drive it yourself, and set hotkey={null} to
turn the global shortcut off.
Examples
An actions-only palette, opened from a button with the hotkey disabled and a cyan accent.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
groups | CommandGroup[] | - | Sections of rows: person, project, or action items. |
open | boolean | - | Controlled open state. Omit for uncontrolled. |
onOpenChange | (open: boolean) => void | - | Fired whenever the menu opens or closes. |
hotkey | string | null | "k" | Key paired with meta/ctrl to toggle it; null disables. |
placeholder | string | "Search people, projects, actions" | Search input placeholder. |
accent | string | "#f0883e" | Caret color and focus outline. |
onRun | (row: CommandRow) => void | - | Called when a row is chosen, before the receipt shows. |
className | string | - | Forwarded to the overlay root. |