Skip to content
goodtabs UI
BrowseBrowse components

Command palette

A Cmd K dialog with search, grouped items with icons and kbd hints, and an empty state.

npx shadcn@latest add https://ui.goodtabs.co/r/command-palette.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/command-palette

A button and mod+k open the dialog. The same list is shown inline below so the look is visible.

With recent items

A Recent group first, then pages and actions with hints and shortcuts.

Single group

One group, no icons, a custom placeholder and empty text.

Usage

import { CommandPalette, useCommandPalette } from "@/components/ui/command-palette"

const { open, setOpen } = useCommandPalette()

<CommandPalette
  open={open}
  onOpenChange={setOpen}
  groups={[{ heading: "Pages", items: [{ id: "docs", label: "Docs", shortcut: "g d" }] }]}
  onSelect={(id) => router.push(`/${id}`)}
/>

Props

PropTypeDefaultDescription
groups{ heading: string; items: { id: string; label: string; icon?: ComponentType; shortcut?: string; hint?: string }[] }[]The groups, in order. shortcut is a combination like "mod+k"; hint is short muted text.
openbooleanWhether the dialog is shown. Controlled, pair with onOpenChange.
onOpenChange(open: boolean) => voidCalled when the dialog asks to open or close, including Escape and a click outside.
onSelect(id: string) => voidCalled with the id of the chosen item after the dialog closes.
placeholderstring"Type a command or search"Placeholder of the search input.
emptyTextstring"No results."Shown when the search matches nothing.
titlestring"Command palette"Accessible name of the dialog.
autoScrollbooleantrueOn CommandPaletteMenu. Off stops cmdk scrolling the selected row into view on mount, for inline menus on long pages.

Source

command-palette/command-palette.tsx
"use client";

import * as React from "react";
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandShortcut,
} from "@/components/ui/command";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Kbd } from "@/components/ui/kbd";
import { cn } from "@/lib/utils";

export type CommandPaletteItem = {
  /** Reported to onSelect. Unique across every group. */
  id: string;
  /** Item text, what the search matches. */
  label: string;
  /** An icon component, for example a lucide icon. */
  icon?: React.ComponentType<{ className?: string }>;
  /** Key combination shown as kbd on the right, for example "mod+k" or "c". */
  shortcut?: string;
  /** Short muted text on the right, for example "Page". */
  hint?: string;
};

export type CommandPaletteGroup = {
  /** Heading above the group. */
  heading: string;
  items: CommandPaletteItem[];
};

const noop = () => () => {};
const isMac = () => /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent);

/** Reads the platform once after mount. The server renders the mac form, so nothing flashes on a mac. */
function useIsMac() {
  return React.useSyncExternalStore(noop, isMac, () => true);
}

const macGlyphs: Record<string, string> = { mod: "⌘", meta: "⌘", ctrl: "⌃", alt: "⌥", shift: "⇧" };
const pcGlyphs: Record<string, string> = { mod: "Ctrl", meta: "Win", ctrl: "Ctrl", alt: "Alt", shift: "Shift" };

function shortcutParts(shortcut: string, mac: boolean): string[] {
  const glyphs = mac ? macGlyphs : pcGlyphs;
  return shortcut
    .toLowerCase()
    .split("+")
    .map((p) => p.trim())
    .filter(Boolean)
    .map(
      (part) => glyphs[part] ?? (part.length === 1 ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1)),
    );
}

/** A key combination as a row of kbd, with mod shown as the platform's modifier. */
export function ShortcutKeys({ shortcut, className, ...props }: React.ComponentProps<"span"> & { shortcut: string }) {
  const mac = useIsMac();
  return (
    <span aria-hidden="true" className={cn("inline-flex items-center gap-0.5", className)} {...props}>
      {shortcutParts(shortcut, mac).map((part, i) => (
        <Kbd key={i}>{part}</Kbd>
      ))}
    </span>
  );
}

type MenuProps = Omit<React.ComponentProps<typeof Command>, "onSelect"> & {
  /** The groups, in order. */
  groups: CommandPaletteGroup[];
  /** Placeholder of the search input. */
  placeholder?: string;
  /** Shown when the search matches nothing. */
  emptyText?: string;
  /** Called with the id of the chosen item. */
  onSelect?: (id: string) => void;
  /** cmdk scrolls the selected row into view on mount. Turn this off for an inline menu on a long page, or the page jumps to it. */
  autoScroll?: boolean;
};

function useNoAutoScroll(enabled: boolean) {
  const ref = React.useRef<HTMLDivElement>(null);
  React.useLayoutEffect(() => {
    const root = ref.current;
    if (enabled || !root) return;
    const patch = () =>
      root.querySelectorAll<HTMLElement>("[cmdk-item], [cmdk-group-heading]").forEach((el) => {
        el.scrollIntoView = () => {};
      });
    patch();
    const observer = new MutationObserver(patch);
    observer.observe(root, { childList: true, subtree: true });
    return () => observer.disconnect();
  }, [enabled]);
  return ref;
}

/**
 * The searchable list on its own: input, groups, empty state.
 * CommandPalette puts this in a dialog; use it directly for an inline or always-open menu.
 */
export function CommandPaletteMenu({
  groups,
  placeholder = "Type a command or search",
  emptyText = "No results.",
  onSelect,
  label = "Command palette",
  autoScroll = true,
  className,
  ...props
}: MenuProps) {
  const frame = useNoAutoScroll(autoScroll);
  return (
    <Command ref={frame} label={label} className={cn("rounded-md!", className)} {...props}>
      <CommandInput placeholder={placeholder} />
      <CommandList>
        <CommandEmpty className="text-muted-foreground">{emptyText}</CommandEmpty>
        {groups.map((group) => (
          <CommandGroup key={group.heading} heading={group.heading}>
            {group.items.map(({ id, label: itemLabel, icon: Icon, shortcut, hint }) => (
              <CommandItem
                key={id}
                value={id}
                keywords={[itemLabel]}
                onSelect={() => onSelect?.(id)}
                className="h-8 rounded-sm in-data-[slot=dialog-content]:rounded-sm!"
              >
                {Icon && <Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />}
                <span className="truncate">{itemLabel}</span>
                {(hint || shortcut) && (
                  <CommandShortcut className="flex items-center gap-1.5 tracking-normal">
                    {hint && <span>{hint}</span>}
                    {shortcut && <ShortcutKeys shortcut={shortcut} />}
                  </CommandShortcut>
                )}
              </CommandItem>
            ))}
          </CommandGroup>
        ))}
      </CommandList>
    </Command>
  );
}

type CommandPaletteProps = Omit<MenuProps, "className"> & {
  /** Whether the dialog is shown. Controlled: pair with onOpenChange. */
  open: boolean;
  /** Called when the dialog asks to open or close, including Escape and a click outside. */
  onOpenChange: (open: boolean) => void;
  /** Accessible name of the dialog. */
  title?: string;
};

/**
 * A command palette in a dialog: search at the top, grouped items with icons and kbd hints.
 * Choosing an item closes the dialog, then calls onSelect with its id. Escape closes.
 */
export function CommandPalette({
  open,
  onOpenChange,
  onSelect,
  title = "Command palette",
  placeholder,
  ...props
}: CommandPaletteProps) {
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent
        showCloseButton={false}
        className="top-1/4 translate-y-0 gap-0 overflow-hidden rounded-md p-0 shadow-lg sm:max-w-lg"
      >
        <DialogTitle className="sr-only">{title}</DialogTitle>
        <DialogDescription className="sr-only">{placeholder ?? "Type a command or search"}</DialogDescription>
        <CommandPaletteMenu
          label={title}
          placeholder={placeholder}
          onSelect={(id) => {
            onOpenChange(false);
            onSelect?.(id);
          }}
          {...props}
        />
      </DialogContent>
    </Dialog>
  );
}

/** Open state for a CommandPalette, toggled by mod+k anywhere on the page. */
export function useCommandPalette(initialOpen = false) {
  const [open, setOpen] = React.useState(initialOpen);

  React.useEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.defaultPrevented || event.repeat) return;
      if (event.key.toLowerCase() !== "k" || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey)
        return;
      event.preventDefault();
      setOpen((value) => !value);
    };
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, []);

  return { open, setOpen };
}

For AI agents

Hand your agent this page, the markdown at /components/command-palette/prompt.md, or the Copy AI prompt button above.

When to use

  • As the one place to jump to any page or run any action with the keyboard
  • In apps and docs sites where people already reach for Cmd K
  • When navigation has outgrown a menu but a full search page is too much

When not to use

  • For a short list of actions on one element, use a dropdown menu
  • For picking a value in a form, use a select or a combobox
  • On marketing pages with a handful of links

Agent notes

  • useCommandPalette() holds open state and binds mod+k on the document; pass its open and setOpen to CommandPalette
  • Ids must be unique across all groups: cmdk matches the search against the id and the label
  • CommandPaletteMenu is the list without the dialog, for an inline or always-open menu; ShortcutKeys renders any combination as kbd
  • The parent must be a client component because onSelect and onOpenChange are functions
  • An inline CommandPaletteMenu far down a page makes the page jump on load unless autoScroll is false; the dialog form never needs it