"use client";
import { useEffect, useRef, useState, type ComponentType } from "react";
import { Command } from "cmdk";
import { AnimatePresence, motion } from "motion/react";
import AutoMaskVertical from "@/components/ui/auto-mask-vertical";
export type CommandDef = {
id: string;
label: string;
icon?: ComponentType<{ className?: string }>;
keywords?: string[];
hint?: string;
active?: boolean;
onRun?: () => void;
};
export interface CommandGroupDef {
id: string;
heading: string;
commands: CommandDef[];
pinned?: boolean;
}
export type Hotkey = "cmd+k" | "ctrl+k" | "/";
export interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
groups: CommandGroupDef[];
onSelect?: (command: CommandDef) => void;
onValueChange?: (search: string) => void;
onItemHover?: (command: CommandDef) => void;
hotkey?: Hotkey | Hotkey[];
loop?: boolean;
glass?: boolean;
placeholder?: string;
fixed?: boolean;
initialValue?: string | null;
}
const NO_SELECTION = "__none__";
export function isMacPlatform(): boolean {
return /mac/i.test(navigator.platform || navigator.userAgent);
}
export function isEditableTarget(): boolean {
const el = document.activeElement as HTMLElement | null;
if (!el) return false;
const tag = el.tagName;
return (
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
el.isContentEditable
);
}
function matchesHotkey(event: KeyboardEvent, hotkey: Hotkey): boolean {
if (hotkey === "/") {
if (isEditableTarget()) return false;
return event.key === "/";
}
const mod =
hotkey === "cmd+k"
? event.metaKey || (!isMacPlatform() && event.ctrlKey)
: event.ctrlKey;
return mod && event.key.toLowerCase() === "k";
}
export default function CommandPalette({
open,
onOpenChange,
groups,
onSelect,
onValueChange,
onItemHover,
hotkey = "cmd+k",
loop = true,
glass = false,
placeholder = "Type a command or search…",
fixed = false,
initialValue,
}: CommandPaletteProps) {
const [value, setValue] = useState("");
const [wasOpen, setWasOpen] = useState(open);
if (open !== wasOpen) {
setWasOpen(open);
if (open) {
setValue(initialValue === undefined ? "" : (initialValue ?? NO_SELECTION));
}
}
const hotkeyKey = Array.isArray(hotkey) ? hotkey.join(",") : hotkey;
useEffect(() => {
const hotkeys = hotkeyKey ? (hotkeyKey.split(",") as Hotkey[]) : [];
const onKey = (event: KeyboardEvent) => {
if (hotkeys.length && hotkeys.some((h) => matchesHotkey(event, h))) {
event.preventDefault();
onOpenChange(!open);
} else if (event.key === "Escape" && open) {
onOpenChange(false);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, hotkeyKey, onOpenChange]);
useEffect(() => {
if (!fixed || !open) return;
const { body, documentElement } = document;
const scrollbarWidth = window.innerWidth - documentElement.clientWidth;
const prevOverflow = body.style.overflow;
const prevPaddingRight = body.style.paddingRight;
body.style.overflow = "hidden";
if (scrollbarWidth > 0) body.style.paddingRight = `${scrollbarWidth}px`;
return () => {
body.style.overflow = prevOverflow;
body.style.paddingRight = prevPaddingRight;
};
}, [fixed, open]);
const runCommand = (command: CommandDef) => {
onSelect?.(command);
command.onRun?.();
onOpenChange(false);
};
const renderItem = (
command: CommandDef,
pinned = false,
lastPinned = false,
) => {
const Icon = command.icon;
const tone = command.active
? "text-accent before:absolute before:left-0 before:top-1/2 before:h-4 before:w-0.5 before:-translate-y-1/2 before:rounded-full before:bg-accent"
: "text-ink-dim data-[selected=true]:text-ink";
return (
<Command.Item
key={command.id}
value={command.label}
keywords={command.keywords}
onSelect={() => runCommand(command)}
onMouseEnter={() => onItemHover?.(command)}
forceMount={pinned || undefined}
className={
pinned
?
`relative flex grow cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 font-sans text-xs data-[selected=true]:bg-accent/15 ${tone} ${lastPinned ? "min-w-0" : "shrink-0"}`
: `relative flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 font-sans text-sm data-[selected=true]:bg-accent/15 ${tone}`
}
>
{Icon && (
<Icon
className={
pinned
? `h-3.5 w-3.5 shrink-0 ${command.active ? "text-accent" : "text-ink-mute"}`
: `h-4 w-4 ${command.active ? "text-accent" : "text-ink-mute"}`
}
/>
)}
<span
className={
pinned
? lastPinned
? "min-w-0 truncate"
: "whitespace-nowrap"
: "flex-1"
}
>
{command.label}
</span>
{command.hint && (
<span className="flex shrink-0 items-center gap-1">
{command.hint.split(/\s+/).map((key, index) => (
<kbd
key={`${key}-${index}`}
className={`inline-flex h-5 min-w-5 items-center justify-center rounded border border-hairline px-1 font-mono text-[10px] leading-none ${pinned ? "text-current" : "text-ink-mute"}`}
>
{key}
</kbd>
))}
</span>
)}
</Command.Item>
);
};
const scrollGroups = groups.filter((group) => !group.pinned);
const pinnedGroups = groups.filter((group) => group.pinned);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open || !initialValue) return;
const timer = setTimeout(() => {
listRef.current
?.querySelector('[aria-selected="true"]')
?.scrollIntoView({ block: "center" });
}, 0);
return () => clearTimeout(timer);
}, [open, initialValue]);
const surface = glass
? "border-white/15 bg-white/10 backdrop-blur-xl"
: "border-hairline bg-panel";
return (
<AnimatePresence>
{open && (
<motion.div
className={`${fixed ? "fixed" : "absolute"} inset-0 z-50 flex items-start justify-center pt-[12%]`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<button
aria-label="Close command palette"
onClick={() => onOpenChange(false)}
className="absolute inset-0 bg-black/50 backdrop-blur-[2px]"
/>
<motion.div
initial={{ opacity: 0, scale: 0.97, y: -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: -8 }}
transition={{ duration: 0.16, ease: "easeOut" }}
className={`relative z-10 w-full max-w-xl overflow-hidden rounded-xl border shadow-2xl ${surface}`}
>
{glass && (
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-white/12 to-transparent" />
)}
<Command
value={value}
onValueChange={setValue}
onMouseLeave={() => setValue(NO_SELECTION)}
loop={loop}
className="relative"
>
<div className="border-b border-hairline/60 px-3">
<Command.Input
autoFocus
placeholder={placeholder}
onValueChange={(value) => {
onValueChange?.(value);
listRef.current?.scrollTo({ top: 0 });
}}
className="h-12 w-full bg-transparent font-sans text-sm text-ink outline-none placeholder:text-ink-mute"
/>
</div>
<AutoMaskVertical ref={listRef} className="max-h-80">
<Command.List className="overflow-y-visible p-2">
<Command.Empty className="px-3 py-6 text-center font-mono text-xs text-ink-mute">
No results.
</Command.Empty>
{scrollGroups.map((group) => (
<Command.Group
key={group.id}
heading={group.heading}
className="**:[[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:font-display [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-widest [&_[cmdk-group-heading]]:text-ink-mute"
>
{group.commands.map((command) => renderItem(command))}
</Command.Group>
))}
</Command.List>
</AutoMaskVertical>
{pinnedGroups.length > 0 && (
<div className="flex w-full items-center gap-1 overflow-hidden border-t border-hairline/60 p-2">
{pinnedGroups.map((group, groupIndex) => (
<Command.Group
key={group.id}
forceMount
className="contents [&_[cmdk-group-items]]:contents"
>
{group.commands.map((command, commandIndex) =>
renderItem(
command,
true,
groupIndex === pinnedGroups.length - 1 &&
commandIndex === group.commands.length - 1,
),
)}
</Command.Group>
))}
</div>
)}
</Command>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}