A command palette with pages: a search-field style trigger, grouped commands, drill-in pages with a breadcrumb, Backspace to go back and a footer of key hints.
Last command: Nothing yet
"use client";
import * as React from "react";
import { Bell, FileText, Folder, Home, Moon, Palette, Settings, Sun, Monitor, UserPlus } from "lucide-react";
import { CommandBar, type CommandBarGroup } from "@/components/ballmac/command-bar";
export default function CommandBarDemo() {
const [last, setLast] = React.useState("Nothing yet");
const run = (name: string) => () => setLast(name);
const groups: CommandBarGroup[] = [
{
heading: "Go to",
items: [
{ id: "home", label: "Home", icon: <Home />, shortcut: ["G", "H"], onSelect: run("Opened Home") },
{ id: "projects", label: "Projects", icon: <Folder />, shortcut: ["G", "P"], onSelect: run("Opened Projects") },
{ id: "docs", label: "Documents", icon: <FileText />, keywords: ["files", "notes"], onSelect: run("Opened Documents") },
],
},
{
heading: "Actions",
items: [
{
id: "theme",
label: "Change theme…",
icon: <Palette />,
keywords: ["dark", "light", "appearance"],
pages: [
{
heading: "Theme",
items: [
{ id: "light", label: "Light", icon: <Sun />, onSelect: run("Theme: light") },
{ id: "dark", label: "Dark", icon: <Moon />, onSelect: run("Theme: dark") },
{ id: "system", label: "System", icon: <Monitor />, onSelect: run("Theme: system") },
],
},
],
},
{ id: "invite", label: "Invite teammate", icon: <UserPlus />, description: "Send an invitation by email", onSelect: run("Invite sent") },
{ id: "notifications", label: "Notification settings", icon: <Bell />, onSelect: run("Opened notifications") },
{ id: "settings", label: "Settings", icon: <Settings />, shortcut: ["⌘", ","], onSelect: run("Opened Settings") },
],
},
];
return (
<div className="grid w-full max-w-sm gap-3 rounded-xl border bg-card p-4 shadow-sm">
<CommandBar groups={groups} />
<p className="text-xs text-muted-foreground" aria-live="polite">
Last command: <span className="font-medium text-foreground">{last}</span>
</p>
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/command-barInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/command @ballmac/kbd @ballmac/i18nCopy the source into your project.
components/ballmac/command-bar.tsx// Ballmac UI: Command Bar. https://ui.ballmac.com/components/command-bar "use client"; import * as React from "react"; import { ChevronRight, CornerDownLeft, Search } from "lucide-react"; import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandShortcut, } from "@/components/ballmac/command"; import { Kbd } from "@/components/ballmac/kbd"; import { cn } from "@/lib/utils"; import { useMessages } from "@/lib/ballmac/i18n"; type CommandBarItem = { /** Unique id. */ id: string; /** Visible name. */ label: string; /** Muted second line. */ description?: string; /** Leading icon. */ icon?: React.ReactNode; /** Keys shown at the end, for example `["⌘", "N"]`. Display only. */ shortcut?: string[]; /** Extra words the search should match. */ keywords?: string[]; /** Runs when chosen. The bar closes afterwards unless `keepOpen` is set. */ onSelect?: () => void; /** Keep the bar open after running `onSelect`. */ keepOpen?: boolean; /** Choosing this item opens a second page with these groups (for example "Change theme…"). */ pages?: CommandBarGroup[]; }; type CommandBarGroup = { /** Group heading. */ heading: string; items: CommandBarItem[]; }; type CommandBarProps = { /** Top-level groups. */ groups: CommandBarGroup[]; /** Controlled open state. */ open?: boolean; /** Initial open state when uncontrolled. */ defaultOpen?: boolean; /** Called when the bar opens or closes. */ onOpenChange?: (open: boolean) => void; /** Placeholder of the search field on the first page. */ placeholder?: string; /** Text shown when nothing matches. */ emptyText?: string; /** Key that toggles the bar together with ⌘ (Ctrl on Windows and Linux). `false` turns the shortcut off. */ hotkey?: string | false; /** Text on the trigger button. `false` hides the trigger so you can open the bar yourself. */ trigger?: string | false; /** Classes for the trigger button. */ triggerClassName?: string; /** Accessible name of the dialog. */ title?: string; }; /** * A command palette with pages. Type to search; choose an item that has `pages` to drill in (Backspace on an empty * field goes back); every other item runs and closes. The trigger is a search-field-style button with the hotkey. */ function CommandBar({ groups, open: openProp, defaultOpen = false, onOpenChange, placeholder, emptyText, hotkey = "k", trigger = "Search or jump to…", triggerClassName, title, }: CommandBarProps) { const msg = useMessages() placeholder ??= msg("command-bar.placeholder", "Search commands…") emptyText ??= msg("command-bar.emptyText", "No results found.") title ??= msg("command-bar.title", "Command bar") const [inner, setInner] = React.useState(defaultOpen); const open = openProp ?? inner; const [stack, setStack] = React.useState<CommandBarItem[]>([]); const [search, setSearch] = React.useState(""); const setOpen = React.useCallback( (next: boolean) => { if (openProp === undefined) setInner(next); onOpenChange?.(next); if (!next) { setStack([]); setSearch(""); } }, [openProp, onOpenChange], ); const inputRef = React.useRef<HTMLInputElement>(null); const current = stack.at(-1); const visibleGroups = current?.pages ?? groups; function choose(item: CommandBarItem) { if (item.pages) { setStack((s) => [...s, item]); setSearch(""); // Choosing with the pointer moves focus off the field; bring it back so typing and Backspace keep working. requestAnimationFrame(() => inputRef.current?.focus()); return; } item.onSelect?.(); if (!item.keepOpen) setOpen(false); } return ( <> {trigger !== false && ( <button type="button" data-slot="command-bar-trigger" onClick={() => setOpen(true)} aria-haspopup="dialog" aria-keyshortcuts={hotkey ? `Meta+${hotkey.toUpperCase()} Control+${hotkey.toUpperCase()}` : undefined} className={cn( "inline-flex h-10 w-full max-w-sm items-center gap-2.5 rounded-xl border bg-background px-3 text-start text-sm text-muted-foreground shadow-xs outline-none transition-[border-color,box-shadow,background-color] hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", triggerClassName, )} > <Search aria-hidden="true" className="size-4 shrink-0" /> <span className="flex-1 truncate">{trigger}</span> {hotkey && ( <span aria-hidden="true" className="flex items-center gap-1"> <Kbd size="sm">⌘</Kbd> <Kbd size="sm">{hotkey.toUpperCase()}</Kbd> </span> )} </button> )} <CommandDialog open={open} onOpenChange={setOpen} hotkey={hotkey} title={title} label={title} className="[&_[cmdk-list]]:max-h-[min(22rem,50dvh)]" > {stack.length > 0 && ( <div className="flex items-center gap-1 border-b px-3 pt-2.5 pb-2 text-xs" aria-label={msg("command-bar.currentPage", "Current page")}> <button type="button" onClick={() => { setStack([]); setSearch(""); }} className="rounded px-1.5 py-0.5 text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50" > {msg("command-bar.all", "All")} </button> {stack.map((page, index) => ( <React.Fragment key={page.id}> <ChevronRight aria-hidden="true" className="size-3 text-muted-foreground rtl:rotate-180" /> <span aria-current={index === stack.length - 1 ? "page" : undefined} className="rounded bg-accent px-1.5 py-0.5 font-medium"> {page.label} </span> </React.Fragment> ))} </div> )} <CommandInput ref={inputRef} value={search} onValueChange={setSearch} placeholder={current ? `Search ${current.label.toLowerCase()}…` : placeholder} onKeyDown={(event) => { if (event.key === "Backspace" && !search && stack.length) { event.preventDefault(); setStack((s) => s.slice(0, -1)); } }} /> <CommandList> <CommandEmpty>{emptyText}</CommandEmpty> {visibleGroups.map((group) => ( <CommandGroup key={group.heading} heading={group.heading}> {group.items.map((item) => ( <CommandItem key={item.id} value={item.id} keywords={[item.label, ...(item.keywords ?? [])]} icon={item.icon} description={item.description} onSelect={() => choose(item)} > {item.label} {item.pages ? ( <ChevronRight aria-hidden="true" className="ms-auto size-4 rtl:rotate-180" /> ) : ( item.shortcut && <CommandShortcut>{item.shortcut.join(" ")}</CommandShortcut> )} </CommandItem> ))} </CommandGroup> ))} </CommandList> <div aria-hidden="true" className="flex items-center gap-4 border-t bg-muted/40 px-3.5 py-2 text-xs text-muted-foreground" > <span className="flex items-center gap-1.5"> <Kbd size="sm">↑</Kbd> <Kbd size="sm">↓</Kbd> {msg("command-bar.navigate", "Navigate")} </span> <span className="flex items-center gap-1.5"> <Kbd size="sm"> <CornerDownLeft className="size-3 rtl:-scale-x-100" /> </Kbd>{" "} {msg("command-bar.select", "Select")} </span> {stack.length > 0 && ( <span className="flex items-center gap-1.5"> <Kbd size="sm">⌫</Kbd> {msg("command-bar.back", "Back")} </span> )} <span className="ms-auto flex items-center gap-1.5"> <Kbd size="sm">{msg("command-bar.esc", "Esc")}</Kbd> {msg("command-bar.close", "Close")} </span> </div> </CommandDialog> </> ); } export { CommandBar, type CommandBarProps, type CommandBarGroup, type CommandBarItem };Update the import paths to match your project setup.
Usage
import { CommandBar } from "@/components/ballmac/command-bar"The full example is in the Code tab above.
Examples
Own trigger
"use client";
import * as React from "react";
import { Search } from "lucide-react";
import { buttonVariants } from "@/components/ballmac/button";
import { CommandBar } from "@/components/ballmac/command-bar";
export default function CommandBarStates() {
const [open, setOpen] = React.useState(false);
return (
<div className="flex items-center gap-3">
<button type="button" className={buttonVariants({ variant: "outline" })} onClick={() => setOpen(true)}>
<Search aria-hidden="true" /> Open from your own button
</button>
<CommandBar
trigger={false}
hotkey="j"
title="Jump to"
placeholder="Jump to…"
open={open}
onOpenChange={setOpen}
groups={[{ heading: "Recent", items: [{ id: "a", label: "Launch plan" }, { id: "b", label: "Pricing model" }, { id: "c", label: "Brand guidelines" }] }]}
/>
</div>
);
}API reference
| Prop | Type | Default |
|---|---|---|
groups*Top-level groups. | CommandBarGroup[] | — |
openControlled open state. | boolean | — |
defaultOpenInitial open state when uncontrolled. | boolean | false |
onOpenChangeCalled when the bar opens or closes. | (open: boolean) => void | — |
placeholderPlaceholder of the search field on the first page. | string | — |
emptyTextText shown when nothing matches. | string | — |
hotkeyKey that toggles the bar together with ⌘ (Ctrl on Windows and Linux). `false` turns the shortcut off. | string | false | "k" |
triggerText on the trigger button. `false` hides the trigger so you can open the bar yourself. | string | false | "Search or jump to…" |
triggerClassNameClasses for the trigger button. | string | — |
titleAccessible name of the dialog. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Cmd/Ctrl+K | Toggles the bar |
| Type | Filters commands |
| ArrowUp / ArrowDown / Enter | Moves and runs |
| Backspace on an empty field | Goes back one page |
| Escape | Closes |
Use with AI
groups: {heading, items:[{id,label,icon,shortcut,keywords,onSelect,pages}]}. An item with pages opens a second page. hotkey toggles with Cmd/Ctrl; trigger={false} hides the button. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Command Bar (@ballmac/command-bar) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Global quick actions and navigation
- Apps with many commands and sub-choices
Not for
- A macOS-style launcher; use spotlight-search
- Choosing one value in a form; use combobox
Registry JSON: https://ui.ballmac.com/r/command-bar.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
Command
A filterable command list on cmdk, inline or in a Cmd/Ctrl+K dialog, with groups, shortcuts, descriptions, loading state and page-safe scrolling.
Kbd
Keyboard key and shortcut display built on the semantic <kbd> element, in three sizes, with KbdGroup for combinations like ⌘ ⇧ K. Adapts inside tooltips.
Dialog
A Radix modal dialog with a blurred overlay, centered card surface that fits 360 px screens, header and footer parts, optional close button and animations.
Animated Tabs
Radix Tabs with a pill or underline indicator that slides between triggers on a spring, and panels that fade in. Keeps full keyboard support and ARIA; controlled or uncontrolled.