litenBeta

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.

Terminal
bun add motion
components/ui/command-menu.tsx
'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 &ldquo;{query}&rdquo;                        </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.

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

Example.tsx
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

PropTypeDefaultDescription
groupsCommandGroup[]-Sections of rows: person, project, or action items.
openboolean-Controlled open state. Omit for uncontrolled.
onOpenChange(open: boolean) => void-Fired whenever the menu opens or closes.
hotkeystring | null"k"Key paired with meta/ctrl to toggle it; null disables.
placeholderstring"Search people, projects, actions"Search input placeholder.
accentstring"#f0883e"Caret color and focus outline.
onRun(row: CommandRow) => void-Called when a row is chosen, before the receipt shows.
classNamestring-Forwarded to the overlay root.
On this page0%