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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. | |
| open | boolean | Whether the dialog is shown. Controlled, pair with onOpenChange. | |
| onOpenChange | (open: boolean) => void | Called when the dialog asks to open or close, including Escape and a click outside. | |
| onSelect | (id: string) => void | Called with the id of the chosen item after the dialog closes. | |
| placeholder | string | "Type a command or search" | Placeholder of the search input. |
| emptyText | string | "No results." | Shown when the search matches nothing. |
| title | string | "Command palette" | Accessible name of the dialog. |
| autoScroll | boolean | true | On 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