Command Palette
A ⌘K command palette with live fuzzy search, grouped commands, and keyboard navigation, its highlight row sliding via a shared layout.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add command-palette.enCommand Palette is a React ⌘K command menu built with Tailwind CSS and Motion. Press ⌘K or Ctrl+K to open it, type to filter commands instantly, and run one with the arrow keys and Enter, without an extra combobox library.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<CommandPalette/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- Global quick navigation in a SaaS dashboard, such as "Go to billing" or "Open settings".
- A fast search entry point for a docs site or knowledge base that lists common pages.
- Collecting actions scattered across menus in an editor or dashboard into one keyboard entry point.
- Frequent switches in an admin UI, like changing workspace, project or theme.
Installation
npx webberui@latest add https://webberui.com/r/command-palette.en.jsonOr, once registries are configured in components.json, install it as @webberui/command-palette.
Usage
import * as React from "react";
import { Home, Plus } from "lucide-react";
import { CommandPalette } from "@/components/ui/command-palette";
export function Example() {
const [open, setOpen] = React.useState(false);
const groups = [
{
heading: "Navigation",
items: [
{
id: "home",
label: "Go to home",
icon: <Home className="size-4" />,
shortcut: "G H",
onSelect: () => console.log("home"),
},
],
},
{
heading: "Actions",
items: [
{
id: "new",
label: "New document",
icon: <Plus className="size-4" />,
shortcut: "⌘ N",
onSelect: () => console.log("new"),
},
],
},
];
return <CommandPalette open={open} onOpenChange={setOpen} groups={groups} />;
}Press ⌘K (macOS) or Ctrl+K (Windows/Linux) to summon the palette at any time; you can also drive it from an external button through open / onOpenChange.
Examples
Navigate with the Next.js router
Call router.push inside onSelect; the palette closes first and then runs the action. A button covers users who do not know the shortcut.
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { CreditCard, LayoutDashboard, Settings } from "lucide-react";
import { CommandPalette } from "@/components/ui/command-palette";
export function AppCommandMenu() {
const router = useRouter();
const [open, setOpen] = React.useState(false);
const groups = [
{
heading: "Pages",
items: [
{ id: "dashboard", label: "Dashboard", icon: <LayoutDashboard className="size-4" />, onSelect: () => router.push("/dashboard") },
{ id: "billing", label: "Billing and payments", icon: <CreditCard className="size-4" />, onSelect: () => router.push("/settings/billing") },
{ id: "settings", label: "Account settings", icon: <Settings className="size-4" />, shortcut: "⌘ ,", onSelect: () => router.push("/settings") },
],
},
];
return (
<>
<button onClick={() => setOpen(true)}>Search... ⌘K</button>
<CommandPalette open={open} onOpenChange={setOpen} groups={groups} placeholder="Search pages or commands..." />
</>
);
}Uncontrolled mode
Without open, the component manages its own state and opens only with ⌘K / Ctrl+K, which suits dropping it into the root layout.
<CommandPalette
groups={[
{
heading: "Appearance",
items: [
{ id: "light", label: "Switch to light theme", onSelect: () => setTheme("light") },
{ id: "dark", label: "Switch to dark theme", onSelect: () => setTheme("dark") },
],
},
]}
/>Build groups from data
groups is a plain array, so you can build it from an API response or props, for example recently opened projects.
const groups = [
{
heading: "Recent projects",
items: recentProjects.map((project) => ({
id: `project-${project.id}`,
label: project.name,
onSelect: () => router.push(`/projects/${project.id}`),
})),
},
];Props
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Open state in controlled mode; when omitted, the component manages it internally |
onOpenChange | (open: boolean) => void | — | Callback fired when the open state changes (in both controlled and uncontrolled modes) |
groups | CommandPaletteGroup[] | — | Command group data; each group's heading and items are rendered in order |
placeholder | string | "Type a command or search…" | Placeholder text for the search field |
className | string | — | Appended to the palette container's className |
CommandPaletteGroup
| Field | Type | Description |
|---|---|---|
heading | string | Group heading |
items | CommandPaletteItem[] | The command items in that group |
CommandPaletteItem
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier, used as the option's DOM id and the React key |
label | string | Display text, which is also what search filtering matches against |
icon | React.ReactNode | Optional leading icon |
shortcut | string | Optional keyboard shortcut hint, shown on the right of the item |
onSelect | () => void | The action to run when this item is selected (by click or Enter) |
How it works
- A global listener toggles on
⌘K/Ctrl+K; the palette is mounted ontodocument.bodywithcreatePortal. - The overlay fades in and the palette scales and fades in, centered slightly above the middle; the search field focuses automatically and typing applies live subsequence fuzzy filtering.
- The up and down keys cycle the highlight through the filtered items,
Enterruns the item,Esccloses, and clicking the overlay closes. - The highlight row's background slides smoothly between items via a shared
layoutId.
Accessibility
- The palette is
role="dialog"witharia-modal="true"; it locks body scrolling while open and returns focus to the previously focused element after closing. - The search field is
role="combobox"and points at the currently highlighted item witharia-activedescendant; the results arerole="listbox"and the items arerole="option"witharia-selected. - When the user has "reduce motion" enabled at the system level, the palette only fades in and out by opacity and the highlight row snaps into place, with no scaling or sliding.
FAQ
Does it work with the Next.js App Router?
Yes. The source starts with "use client". The panel renders through createPortal into document.body only after mount, so the server output is empty and there is no hydration mismatch.
How does the search match?
It matches only each item's label, using a case-insensitive subsequence match: the query characters must appear in order, not necessarily next to each other. Groups with no matches are hidden, and an empty-state message shows when nothing matches.
Does the palette close after selecting an item?
Yes. On click or Enter, the component calls onOpenChange(false) first and then runs the item's onSelect.
How well does it support keyboards and screen readers?
Up and Down move the highlight with wraparound, Enter runs, Esc closes. The panel is a role="dialog" that locks body scroll, the input is a role="combobox" pointing at the highlighted item with aria-activedescendant, and focus returns to the previously focused element on close.
Do I need cmdk or a similar package?
No. Dependencies are only motion, lucide-react, clsx and tailwind-merge; filtering and keyboard navigation live in the component source, which you can copy from the Manual tab.