Command menu
Keycaps press, a command palette opens, a query filters the list and a toast confirms the result.
Animated stillsAnimated
invite
Invite teammateMembers
Invite to projectProjects
New issueC
Search docs/
Toggle themeT
Open settings,
Invite sent to Maya
She can join Gallery launch from her email.
command-menu.tsx
220 lines"use client";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import {
CircleCheck,
FileText,
FolderPlus,
Moon,
Search,
Settings,
SquarePlus,
UserPlus,
} from "lucide-react";
const REDUCED = "(prefers-reduced-motion: reduce)";
function useReducedMotion() {
return useSyncExternalStore(
(cb) => {
const mq = window.matchMedia(REDUCED);
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
},
() => window.matchMedia(REDUCED).matches,
() => false,
);
}
function useInView<T extends Element>() {
const ref = useRef<T>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), {
threshold: 0.3,
});
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, inView] as const;
}
function useLoop(holds: readonly number[], playing: boolean) {
const [step, setStep] = useState(0);
useEffect(() => {
if (!playing) return;
let i = 0;
let t = 0;
const next = () => {
t = window.setTimeout(() => {
i = (i + 1) % holds.length;
setStep(i);
next();
}, holds[i]);
};
next();
return () => {
window.clearTimeout(t);
setStep(0);
};
}, [playing, holds]);
return playing ? step : 0;
}
const QUERY = "invite";
const COMMANDS = [
{ label: "Invite teammate", icon: UserPlus, hint: "Members" },
{ label: "Invite to project", icon: FolderPlus, hint: "Projects" },
{ label: "New issue", icon: SquarePlus, hint: "C" },
{ label: "Search docs", icon: FileText, hint: "/" },
{ label: "Toggle theme", icon: Moon, hint: "T" },
{ label: "Open settings", icon: Settings, hint: "," },
];
const ROW_H = 40;
// poster, enter, toast, toast out, ⌘K, open, typing
const HOLDS = [1800, 260, 2400, 700, 450, 900, QUERY.length * 120 + 500] as const;
function Key({ children, pressed }: { children: React.ReactNode; pressed: boolean }) {
return (
<kbd
className={`grid h-11 min-w-11 place-items-center rounded-lg bg-gradient-to-b from-white to-zinc-100 px-2.5 font-sans text-[17px] font-medium text-zinc-700 ring-1 ring-zinc-300 transition-[translate,box-shadow] duration-100 dark:from-zinc-700 dark:to-zinc-800 dark:text-zinc-100 dark:ring-zinc-600 ${
pressed
? "translate-y-0.5 shadow-[inset_0_-1px_0_rgba(0,0,0,.08)]"
: "shadow-[inset_0_-2px_0_rgba(0,0,0,.08),0_2px_4px_rgba(0,0,0,.12)]"
}`}
>
{children}
</kbd>
);
}
export default function CommandMenu() {
const reduced = useReducedMotion();
const [ref, inView] = useInView<HTMLDivElement>();
const step = useLoop(HOLDS, inView && !reduced);
const [typed, setTyped] = useState(0);
useEffect(() => {
if (step !== 6) return;
let n = 0;
const id = window.setInterval(() => {
n += 1;
setTyped(n);
if (n >= QUERY.length) window.clearInterval(id);
}, 120);
return () => {
window.clearInterval(id);
setTyped(0);
};
}, [step]);
const open = step === 0 || step === 1 || step >= 5;
const query = step === 0 || step === 1 ? QUERY : step === 6 ? QUERY.slice(0, typed) : "";
const toast = step === 2;
const keysShown = step === 1 || step === 4 || step === 5;
const keys = step >= 1 && step <= 3 ? "enter" : "cmdk";
const pressed = step === 1 || step === 4;
const matches = COMMANDS.map((c) => c.label.toLowerCase().includes(query.toLowerCase()));
return (
<div
ref={ref}
className="@container relative h-full w-full overflow-hidden bg-zinc-200/70 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100"
>
{/* The app underneath: quiet filler */}
<div className="absolute top-10 right-10 -bottom-12 left-10 flex overflow-hidden @max-[480px]:inset-x-3 @max-[480px]:top-3 rounded-2xl bg-white shadow-[0_30px_60px_-24px_rgba(0,0,0,.35)] ring-1 ring-black/5 dark:bg-zinc-900 dark:ring-white/10">
<aside className="w-48 flex-none @max-[480px]:hidden space-y-3 border-r border-black/5 bg-zinc-50 p-5 dark:border-white/10 dark:bg-zinc-900">
{[60, 72, 48, 66, 54].map((w, i) => (
<div key={i} className="flex items-center gap-2.5">
<span className="size-3.5 rounded bg-zinc-200 dark:bg-zinc-700" />
<span className="h-1.5 rounded-full bg-zinc-200 dark:bg-zinc-700" style={{ width: `${w}%` }} />
</div>
))}
</aside>
<div className="flex-1 space-y-4 p-7 @max-[480px]:p-5">
<span className="block h-2.5 w-40 rounded-full bg-zinc-200 dark:bg-zinc-700" />
{[80, 64, 72, 58, 76, 50].map((w, i) => (
<div key={i} className="flex items-center gap-3 border-b border-black/[.04] pb-4 dark:border-white/5">
<span className="size-7 rounded-full bg-zinc-100 dark:bg-zinc-800" />
<span className="h-1.5 rounded-full bg-zinc-100 dark:bg-zinc-800" style={{ width: `${w * 0.6}%` }} />
</div>
))}
</div>
</div>
{/* Palette, pinned by its top edge */}
<div
className={`absolute top-[72px] left-1/2 w-[460px] @max-[480px]:top-8 @max-[480px]:w-[calc(100%-32px)] -translate-x-1/2 origin-top rounded-2xl bg-white/95 shadow-[0_0_0_1px_rgba(0,0,0,.06),0_24px_64px_-12px_rgba(0,0,0,.35)] backdrop-blur transition-[opacity,scale] dark:bg-zinc-800/95 dark:shadow-[0_0_0_1px_rgba(255,255,255,.08),0_24px_64px_-12px_rgba(0,0,0,.7)] ${
open ? "scale-100 opacity-100 duration-200 ease-out" : "scale-[.97] opacity-0 duration-150 ease-in"
}`}
>
<div className="flex h-14 items-center gap-3 border-b border-black/5 px-4 dark:border-white/10">
<Search className="size-[18px] text-zinc-400" aria-hidden />
<span className="relative text-[16px]">
{query || <span className="text-zinc-400">Type a command or search…</span>}
{step === 6 && (
<span className="absolute top-1/2 -right-1 h-5 w-px -translate-y-1/2 bg-sky-500" />
)}
</span>
</div>
<div className="relative p-2">
<span
className="absolute inset-x-2 top-2 rounded-lg bg-zinc-100 transition-transform duration-200 dark:bg-zinc-700/60"
style={{ height: ROW_H }}
/>
{COMMANDS.map((c, i) => {
const Icon = c.icon;
const shown = matches[i];
return (
<div
key={c.label}
className={`relative grid items-center overflow-hidden transition-[height,opacity] duration-200 ${shown ? "opacity-100" : "opacity-0"}`}
style={{ height: shown ? ROW_H : 0 }}
>
<div className="flex items-center gap-3 px-3 text-[14px]">
<Icon className="size-4 text-zinc-500 dark:text-zinc-400" aria-hidden />
<span>{c.label}</span>
<span className="ml-auto text-[12px] text-zinc-400">{c.hint}</span>
</div>
</div>
);
})}
</div>
</div>
{/* Toast */}
<div
className={`absolute right-16 bottom-10 flex w-[300px] @max-[480px]:inset-x-4 @max-[480px]:bottom-4 @max-[480px]:w-auto items-start gap-3 rounded-xl bg-zinc-900 p-4 text-white shadow-[0_16px_40px_-12px_rgba(0,0,0,.45)] transition-[opacity,translate] ease-[cubic-bezier(.25,.8,.25,1)] dark:bg-zinc-100 dark:text-zinc-900 ${
toast ? "translate-y-0 opacity-100 duration-500" : "translate-y-2 opacity-0 duration-300"
}`}
>
<CircleCheck className="mt-0.5 size-[18px] flex-none text-emerald-400 dark:text-emerald-600" aria-hidden />
<div>
<div className="text-[14px] font-medium">Invite sent to Maya</div>
<div className="mt-0.5 text-[13px] text-zinc-400 dark:text-zinc-500">
She can join Gallery launch from her email.
</div>
</div>
</div>
{/* Keys being pressed */}
<div
className={`absolute bottom-10 left-16 @max-[480px]:bottom-4 @max-[480px]:left-4 flex gap-1.5 transition-opacity duration-200 ${keysShown ? "opacity-100" : "opacity-0"}`}
aria-hidden
>
{keys === "enter" ? (
<Key pressed={pressed}>↵</Key>
) : (
<>
<Key pressed={pressed}>⌘</Key>
<Key pressed={pressed}>K</Key>
</>
)}
</div>
</div>
);
}