A cmdk palette in a dialog: ⌘K to open, 100ms fade only, one sliding highlight across rows, kbd-hint footer.
npx shadcn@latest add @paragon/command-menu"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
/*
* Command palette. Frequency rule: this surface is hit dozens of times a
* day, so the only open/close animation is a 100ms fade — snappy beats
* pretty. Row selection is a single sliding highlight (translateY on a
* dedicated layer, height set instantly, never animated) instead of each
* row repainting its own background.
*/
const commandMenuStyles = `
@keyframes pg-cmdk-in { from { opacity: 0; } }
@keyframes pg-cmdk-out { to { opacity: 0; } }
`;
export interface CommandMenuProps
extends React.ComponentProps<typeof DialogPrimitive.Root> {
/**
* Key that toggles the palette with ⌘/Ctrl held. Defaults to "k".
* Pass `null` to disable the global shortcut.
*/
hotkey?: string | null;
/** Accessible name for the dialog. */
label?: string;
/** Optional trigger element, rendered with asChild. */
trigger?: React.ReactNode;
/** Class for the palette panel. */
className?: string;
}
function CommandMenu({
hotkey = "k",
label = "Command menu",
trigger,
className,
open: openProp,
defaultOpen = false,
onOpenChange,
children,
...props
}: CommandMenuProps) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
const open = openProp ?? uncontrolledOpen;
const setOpen = React.useCallback(
(next: boolean) => {
setUncontrolledOpen(next);
onOpenChange?.(next);
},
[onOpenChange],
);
React.useEffect(() => {
if (!hotkey) return;
const onKeyDown = (event: KeyboardEvent) => {
if (
event.key.toLowerCase() === hotkey.toLowerCase() &&
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey
) {
event.preventDefault();
setOpen(!open);
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [hotkey, open, setOpen]);
return (
<DialogPrimitive.Root
data-slot="command-menu"
open={open}
onOpenChange={setOpen}
{...props}
>
{trigger && (
<DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>
)}
<DialogPrimitive.Portal>
<style href="paragon-command-menu" precedence="paragon">{commandMenuStyles}</style>
<DialogPrimitive.Overlay
data-slot="command-menu-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/40 dark:bg-black/60",
"data-[state=open]:animate-[pg-cmdk-in_100ms_var(--ease-out)]",
"data-[state=closed]:animate-[pg-cmdk-out_100ms_var(--ease-exit)_forwards]",
)}
/>
<DialogPrimitive.Content
data-slot="command-menu-content"
className={cn(
"fixed top-[18%] left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 sm:max-w-xl",
"data-[state=open]:animate-[pg-cmdk-in_100ms_var(--ease-out)]",
"data-[state=closed]:animate-[pg-cmdk-out_100ms_var(--ease-exit)_forwards]",
)}
>
<DialogPrimitive.Title className="sr-only">
{label}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Type a command or search
</DialogPrimitive.Description>
<CommandPrimitive
label={label}
className={cn(
"flex w-full flex-col overflow-hidden rounded-xl bg-popover text-popover-foreground shadow-overlay outline-none",
className,
)}
>
{children}
</CommandPrimitive>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
function CommandMenuInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div className="flex items-center gap-2 border-b border-border px-3">
<Search aria-hidden className="size-4 shrink-0 text-muted-foreground" />
<CommandPrimitive.Input
data-slot="command-menu-input"
className={cn(
"h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
);
}
function CommandMenuList({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
const listRef = React.useRef<HTMLDivElement>(null);
const highlightRef = React.useRef<HTMLDivElement>(null);
// Slide one highlight layer to the selected row. Height is set
// instantly (never animated); only transform and opacity transition.
React.useEffect(() => {
const list = listRef.current;
const highlight = highlightRef.current;
if (!list || !highlight) return;
let raf = 0;
const sync = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const selected = list.querySelector<HTMLElement>(
'[cmdk-item][data-selected="true"], [cmdk-item][aria-selected="true"]',
);
if (!selected) {
highlight.style.opacity = "0";
return;
}
const appearing = highlight.style.opacity !== "1";
if (appearing) {
// Snap into place when (re)appearing — no slide from a stale row.
highlight.style.transitionDuration = "0ms";
requestAnimationFrame(() => {
highlight.style.transitionDuration = "";
});
}
highlight.style.opacity = "1";
highlight.style.height = `${selected.offsetHeight}px`;
highlight.style.transform = `translateY(${selected.offsetTop}px)`;
});
};
sync();
const observer = new MutationObserver(sync);
observer.observe(list, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["data-selected", "aria-selected"],
});
const resizeObserver = new ResizeObserver(sync);
resizeObserver.observe(list);
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
resizeObserver.disconnect();
};
}, []);
return (
<CommandPrimitive.List
ref={listRef}
data-slot="command-menu-list"
className={cn(
"relative max-h-80 scroll-py-2 overflow-x-hidden overflow-y-auto p-2",
className,
)}
{...props}
>
<div
ref={highlightRef}
aria-hidden
data-slot="command-menu-highlight"
className="pointer-events-none absolute top-0 right-2 left-2 z-0 rounded-md bg-accent opacity-0 transition-[transform,opacity] duration-150 ease-(--ease-out) motion-reduce:transition-none"
style={{ height: 0 }}
/>
{children}
</CommandPrimitive.List>
);
}
function CommandMenuEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-menu-empty"
className={cn(
"py-6 text-center text-sm text-muted-foreground",
className,
)}
{...props}
/>
);
}
function CommandMenuGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-menu-group"
className={cn(
"overflow-hidden [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
);
}
function CommandMenuItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-menu-item"
className={cn(
// The sliding highlight layer supplies the background; rows stay above it.
"relative z-[1] flex cursor-default items-center gap-2 rounded-md px-2 py-2 text-sm outline-none select-none",
"data-[selected=true]:text-accent-foreground",
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground",
className,
)}
{...props}
/>
);
}
function CommandMenuSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-menu-separator"
className={cn("my-1 h-px bg-border", className)}
{...props}
/>
);
}
function CommandMenuShortcut({
className,
...props
}: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="command-menu-shortcut"
className={cn(
"ml-auto font-sans text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
);
}
function CommandMenuKbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="command-menu-kbd"
className={cn(
"rounded border border-border bg-muted px-1 font-sans text-[10px] leading-4 text-muted-foreground",
className,
)}
{...props}
/>
);
}
function CommandMenuFooter({
className,
children,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="command-menu-footer"
className={cn(
"flex items-center gap-4 border-t border-border px-3 py-2 text-xs text-muted-foreground",
className,
)}
{...props}
>
{children ?? (
<>
<span className="flex items-center gap-1">
<CommandMenuKbd>↑</CommandMenuKbd>
<CommandMenuKbd>↓</CommandMenuKbd>
Navigate
</span>
<span className="flex items-center gap-1">
<CommandMenuKbd>↵</CommandMenuKbd>
Select
</span>
<span className="flex items-center gap-1">
<CommandMenuKbd>esc</CommandMenuKbd>
Close
</span>
</>
)}
</div>
);
}
export {
CommandMenu,
CommandMenuInput,
CommandMenuList,
CommandMenuEmpty,
CommandMenuGroup,
CommandMenuItem,
CommandMenuSeparator,
CommandMenuShortcut,
CommandMenuKbd,
CommandMenuFooter,
};