shadimage

Command Menu

A composable ⌘K command palette with fuzzy search, custom groups, built-in theme switcher, and animated content reveal.

registry/components/unlumen/command-menu/index.tsx

"use client";

import * as React from "react";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { Moon, Sun, Monitor, Search } from "lucide-react";

import {
  CommandDialog,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
} from "@/components/ui/command";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { cn } from "@/lib/utils";

export type CommandMenuItemDef = {
  /** Display label */
  label: string;
  /** Lucide or any icon component */
  icon?: React.ComponentType<{ className?: string }>;
  /** Route to navigate to (uses next/navigation router.push) */
  href?: string;
  /** Custom action — used instead of href when provided */
  action?: () => void;
  /** Extra keywords for matching */
  keywords?: string[];
};

export type CommandMenuGroupDef = {
  /** Heading rendered above the group */
  heading: string;
  items: CommandMenuItemDef[];
};

export interface CommandMenuTriggerProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** Label text inside the trigger button */
  label?: string;
  /** Keyboard shortcut hint shown on the right */
  shortcut?: string;
  /** Whether to show the keyboard shortcut badge */
  showShortcut?: boolean;
}

export interface CommandMenuProps {
  /** CommandGroup definitions rendered in the dialog */
  groups?: CommandMenuGroupDef[];
  /** Whether to include the built-in Theme group */
  showThemeGroup?: boolean;
  /** Placeholder text inside the search input */
  placeholder?: string;
  /** Key portion of the keyboard shortcut (⌘ / Ctrl + key) */
  shortcutKey?: string;
  /** Delay in ms before the dialog content becomes visible (avoids layout pop) */
  contentDelay?: number;
  /** Custom trigger element. When provided the default button is NOT rendered. */
  trigger?: React.ReactNode;
  /** Props forwarded to the default trigger button */
  triggerProps?: CommandMenuTriggerProps;
  /** Extra className on the root CommandDialog */
  className?: string;
}

function CommandMenuTrigger({
  label = "Search…",
  shortcut = "K",
  showShortcut = true,
  className,
  onClick,
  ...props
}: CommandMenuTriggerProps) {
  return (
    <button
      type="button"
      onClick={onClick}
      className={cn(
        "flex items-center gap-2 rounded-lg border bg-background/60 backdrop-blur-sm px-3 py-2 text-sm text-muted-foreground hover:bg-accent/50 transition-colors w-full max-w-sm cursor-pointer",
        className,
      )}
      {...props}
    >
      <Search className="size-4 shrink-0" />
      <span className="flex-1 text-left">{label}</span>
      {showShortcut && (
        <KbdGroup>
          <Kbd>⌘</Kbd>
          <Kbd>{shortcut}</Kbd>
        </KbdGroup>
      )}
    </button>
  );
}

function CommandMenu({
  groups = [],
  showThemeGroup = true,
  placeholder = "Search components, pages, actions…",
  shortcutKey = "k",
  contentDelay = 150,
  trigger,
  triggerProps,
  className,
}: CommandMenuProps) {
  const router = useRouter();
  const { setTheme } = useTheme();

  const [open, setOpen] = React.useState(false);
  const [showContent, setShowContent] = React.useState(false);

  // Reveal content after dialog open transition
  React.useEffect(() => {
    if (open) {
      const id = setTimeout(() => setShowContent(true), contentDelay);
      return () => clearTimeout(id);
    } else {
      setShowContent(false);
    }
  }, [open, contentDelay]);

  React.useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (
        e.key.toLowerCase() === shortcutKey.toLowerCase() &&
        (e.metaKey || e.ctrlKey)
      ) {
        e.preventDefault();
        e.stopPropagation();
        setOpen((prev) => !prev);
      }
    };
    document.addEventListener("keydown", down, { capture: true });
    return () =>
      document.removeEventListener("keydown", down, { capture: true });
  }, [shortcutKey]);

  const run = React.useCallback((fn: () => void) => {
    setOpen(false);
    fn();
  }, []);

  const handleItemSelect = React.useCallback(
    (item: CommandMenuItemDef) => {
      if (item.action) {
        run(item.action);
      } else if (item.href) {
        run(() => router.push(item.href!));
      }
    },
    [run, router],
  );

  return (
    <>
      {trigger ? (
        <span onClick={() => setOpen(true)} className="cursor-pointer">
          {trigger}
        </span>
      ) : (
        <CommandMenuTrigger
          shortcut={shortcutKey.toUpperCase()}
          {...triggerProps}
          onClick={() => setOpen(true)}
        />
      )}

      <CommandDialog open={open} onOpenChange={setOpen} className={className}>
        <CommandInput placeholder={placeholder} />

        <div
          data-lenis-prevent="command-menu"
          className="transition-all duration-300 ease-out overflow-hidden"
          style={{
            maxHeight: showContent ? "400px" : "0px",
            opacity: showContent ? 1 : 0,
          }}
        >
          <CommandList>
            <CommandEmpty>
              <span className="text-sm font-mono text-muted-foreground">
                No results found.
              </span>
            </CommandEmpty>

            {groups.map((group, gi) => (
              <React.Fragment key={`g-${gi}`}>
                {gi > 0 && <CommandSeparator />}
                <CommandGroup heading={group.heading}>
                  {group.items.map((item, ii) => (
                    <CommandItem
                      key={`i-${gi}-${ii}`}
                      keywords={item.keywords}
                      onSelect={() => handleItemSelect(item)}
                    >
                      {item.icon && (
                        <item.icon className="mr-2 size-4 shrink-0" />
                      )}
                      {item.label}
                    </CommandItem>
                  ))}
                </CommandGroup>
              </React.Fragment>
            ))}

            {showThemeGroup && (
              <>
                {groups.length > 0 && <CommandSeparator />}
                <CommandGroup heading="Theme">
                  <CommandItem
                    keywords={["light", "bright", "white", "day"]}
                    onSelect={() => run(() => setTheme("light"))}
                  >
                    <Sun className="mr-2 size-4" />
                    Light Mode
                  </CommandItem>
                  <CommandItem
                    keywords={["dark", "night", "black"]}
                    onSelect={() => run(() => setTheme("dark"))}
                  >
                    <Moon className="mr-2 size-4" />
                    Dark Mode
                  </CommandItem>
                  <CommandItem
                    keywords={["system", "auto", "os", "default"]}
                    onSelect={() => run(() => setTheme("system"))}
                  >
                    <Monitor className="mr-2 size-4" />
                    System Theme
                  </CommandItem>
                </CommandGroup>
              </>
            )}
          </CommandList>
        </div>
      </CommandDialog>
    </>
  );
}

export { CommandMenu, CommandMenuTrigger };
Interaction
scroll-revealdropdown

DependenciesBundle weight · medium

framework
next
icons
lucide-react