Overlays

Command Menu

A cmdk palette in a dialog: ⌘K to open, 100ms fade only, one sliding highlight across rows, kbd-hint footer.

Install

npx shadcn@latest add @paragon/command-menu

command-menu.tsx

"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,
};