Command Menu

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

Made by Léo
Open in

Command Palette

Search for a command to run...

demo-command-menu.tsx
"use client";

import {
  BookOpen,
  Component,
  Home,
  Rocket,
  MousePointer,
  Wand2,
  Paintbrush,
  Github,
} from "lucide-react";

import {
  CommandMenu,
  type CommandMenuGroupDef,
} from "@/components/unlumen-ui/command-menu";

const GROUPS: CommandMenuGroupDef[] = [
  {
    heading: "Navigation",
    items: [
      {
        label: "Home",
        icon: Home,
        href: "/",
        keywords: ["home", "main", "accueil"],
      },
      {
        label: "Documentation",
        icon: BookOpen,
        href: "/docs/installation",
        keywords: ["docs", "documentation", "guide", "getting started"],
      },
      {
        label: "Components",
        icon: Component,
        href: "/docs/ui",
        keywords: ["components", "ui", "library", "browse"],
      },
    ],
  },
  {
    heading: "Components",
    items: [
      {
        label: "Animated Components",
        icon: Wand2,
        href: "/docs/ui/animate",
        keywords: ["animated", "motion", "framer"],
      },
      {
        label: "Buttons",
        icon: MousePointer,
        href: "/docs/ui/buttons",
        keywords: ["buttons", "cta", "click"],
      },
      {
        label: "Backgrounds",
        icon: Paintbrush,
        href: "/docs/ui/backgrounds",
        keywords: ["backgrounds", "bg", "gradient", "pattern"],
      },
    ],
  },
  {
    heading: "Links",
    items: [
      {
        label: "GitHub",
        icon: Github,
        action: () =>
          window.open(
            "https://github.com/wicki-leonard-emf/unlumen-ui-pv",
            "_blank",
          ),
        keywords: ["github", "source", "code", "repo"],
      },
      {
        label: "Get Started",
        icon: Rocket,
        href: "/docs/installation",
        keywords: ["get started", "start", "begin", "install"],
      },
    ],
  },
];

export const CommandMenuDemo = () => {
  return (
    <div className="flex items-center justify-center p-8 w-full">
      <CommandMenu
        groups={GROUPS}
        showThemeGroup
        placeholder="Search components, pages, actions…"
        triggerProps={{ label: "Search components…" }}
      />
    </div>
  );
};

Installation

Install the following dependencies:

Copy and paste the following code into your project:

components/unlumen-ui/command-menu.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
          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 };

Update the import paths to match your project setup.

Usage

import {
  CommandMenu,
  type CommandMenuGroupDef,
} from "@/components/unlumen-ui/command-menu";

const groups: CommandMenuGroupDef[] = [
  {
    heading: "Navigation",
    items: [
      { label: "Home", href: "/" },
      { label: "Docs", href: "/docs" },
    ],
  },
];

<CommandMenu groups={groups} />;

Custom trigger

Pass any element as trigger to replace the default search button entirely.

import { CommandMenu } from "@/components/unlumen-ui/command-menu";
import { Button } from "@/components/ui/button";
import { Terminal } from "lucide-react";

<CommandMenu
  trigger={
    <Button variant="outline" size="sm">
      <Terminal className="size-4 mr-2" />
      Command
    </Button>
  }
/>;

Keyboard shortcut

The dialog opens and closes with ⌘ K (macOS) / Ctrl K (Windows/Linux) by default. Use the shortcutKey prop to change the key portion.

<CommandMenu shortcutKey="j" /> // ⌘J / Ctrl J

API Reference

CommandMenu

PropTypeDefault
groups?
CommandMenuGroupDef[]
[]
showThemeGroup?
boolean
true
placeholder?
string
"Search…"
shortcutKey?
string
"k"
contentDelay?
number
150
trigger?
ReactNode
-
triggerProps?
CommandMenuTriggerProps
-
className?
string
-

CommandMenuGroupDef

PropTypeDefault
heading
string
-
items
CommandMenuItemDef[]
-

CommandMenuItemDef

PropTypeDefault
label
string
-
icon?
ComponentType<{ className?: string }>
-
href?
string
-
action?
() => void
-
keywords?
string[]
-

CommandMenuTriggerProps

Extends native <button> props.

PropTypeDefault
label?
string
"Search…"
shortcut?
string
"K"
showShortcut?
boolean
true

Built by Léo from Unlumen :3

Last updated: 3/15/2026