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 {
  BookOpen01Icon as BookOpen,
  PuzzleIcon as Component,
  Home01Icon as Home,
  RocketIcon as Rocket,
  Mouse01Icon as MousePointer,
  MagicWand01Icon as Wand2,
  PaintBrush01Icon as Paintbrush,
  Github01Icon as Github,
} from "hugeicons-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 { ComputerTerminal01Icon as Terminal } from "hugeicons-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

groups?
CommandMenuGroupDef[]
[]

Array of CommandGroup definitions rendered inside the dialog.

showThemeGroup?
boolean
true

Whether to append the built-in Light / Dark / System theme group.

placeholder?
string
"Search…"

Placeholder text inside the search input.

shortcutKey?
string
"k"

Key paired with ⌘/Ctrl to toggle the dialog.

contentDelay?
number
150

Milliseconds to wait after the dialog opens before revealing content.

trigger?
ReactNode

Custom trigger element. Replaces the default search button when provided.

triggerProps?
CommandMenuTriggerProps

Props forwarded to the default trigger button.

className?
string

Extra className applied to the CommandDialog.

CommandMenuGroupDef

heading
string

Section heading displayed above the group.

items
CommandMenuItemDef[]

Array of command items inside this group.

CommandMenuItemDef

label
string

Display label for the command item.

icon?
ComponentType<{ className?: string }>

Icon component rendered to the left of the label.

href?
string

Route to navigate to via next/navigation on select.

action?
() => void

Custom callback invoked on select. Takes precedence over href.

keywords?
string[]

Extra words used for fuzzy matching.

CommandMenuTriggerProps

Extends native <button> props.

label?
string
"Search…"

Text shown in the trigger button.

shortcut?
string
"K"

Key letter shown in the keyboard shortcut badge.

showShortcut?
boolean
true

Whether to render the ⌘K badge.

Built by Léo from Unlumen :3

Last updated: 4/29/2026