sections
Command Palette
Global ⌘K / Ctrl+K command palette built on raw cmdk (its Command.Dialog bundles a radix dialog — no shadcn command/dialog needed). Nav items grouped with browseable submenus, frecency-ranked Recent, route-local "On this page" commands, nested action pages, and an async onSearch hook for app-specific results. Next-only (uses next/navigation).
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/command-paletteUsage
Usagetsx
import { CommandPalette, type PaletteNavItem } from "@/components/command-palette";
// Derive nav from your module registry (or write it by hand):
const NAV: PaletteNavItem[] = MODULES.map((m) => ({
id: m.slug, label: m.label, href: m.href, group: m.group, icon: m.icon,
}));
<CommandPalette
navItems={NAV}
commands={[{ id: "help", label: "Open help", run: () => window.open("/help") }]}
onSearch={async (q) => searchThings(q)}
/>
// Pairs with @bottega/app-shell via its palette slot:
// <AppShell palette={<CommandPalette navItems={NAV} />} ...>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| navItems | PaletteNavItem[] | [] | |
| commands | PaletteCommand[] | [] | |
| onNavigate | (href: string) => void | — | Defaults to router.push. |
| onSearch | (query: string) => Promise<PaletteCommand[]> | — | Async app search — results render in a "Results" group while typing. |
| storageKey | string | bottega.palette.frecency | localStorage key for the frecency ranking. |
| placeholder | string | Search… |
Source
command-palette.tsxtsx
"use client";
import { useCallback, useEffect, useState, type ComponentType } from "react";
import { useRouter, usePathname } from "next/navigation";
import { Command } from "cmdk";
import { ChevronLeft, ChevronRight, CornerDownLeft, Folder, Search } from "lucide-react";
import {
DEFAULT_FRECENCY_KEY,
loadFrecency,
rankByFrecency,
recordUse,
saveFrecency,
} from "./frecency";
/** A navigable destination (usually derived from your nav registry). */
export interface PaletteNavItem {
id: string;
label: string;
href: string;
/** Group heading; group order = first appearance in the array. */
group?: string;
keywords?: string[];
icon?: ComponentType<{ className?: string }>;
}
/** A runnable action. `children` makes it a nested page; `routes` surfaces it in "On this page". */
export interface PaletteCommand {
id: string;
label: string;
keywords?: string[];
icon?: ComponentType<{ className?: string }>;
/** Pathnames (exact or prefix) where this command surfaces under "On this page". */
routes?: string[];
children?: PaletteCommand[];
run?: () => void | Promise<void>;
}
export interface CommandPaletteProps {
navItems?: PaletteNavItem[];
commands?: PaletteCommand[];
/** Defaults to router.push. */
onNavigate?: (href: string) => void;
/** Async app search — results render in a "Results" group while typing. */
onSearch?: (query: string) => Promise<PaletteCommand[]>;
/** localStorage key for the frecency ranking. */
storageKey?: string;
placeholder?: string;
}
/** Page id prefix marking a nav-group submenu in the cmdk pages stack. */
const GROUP_PREFIX = "group:";
const DEFAULT_GROUP = "Go to";
/**
* Global ⌘K / Ctrl+K command palette on raw cmdk (no shadcn command/dialog
* needed — cmdk's Command.Dialog bundles a radix dialog with focus trap +
* scroll lock). Renders its own topbar trigger buttons; drop it into the
* AppShell `palette` slot or anywhere in a layout.
*/
export function CommandPalette({
navItems = [],
commands = [],
onNavigate,
onSearch,
storageKey = DEFAULT_FRECENCY_KEY,
placeholder = "Search…",
}: CommandPaletteProps) {
const [open, setOpen] = useState(false);
// cmdk "pages" stack: empty = root; last entry = the open nested page id.
const [pages, setPages] = useState<string[]>([]);
const [search, setSearch] = useState("");
const [searchHits, setSearchHits] = useState<PaletteCommand[]>([]);
const [recentIds, setRecentIds] = useState<string[]>([]);
const router = useRouter();
const pathname = usePathname();
const go = onNavigate ?? ((href: string) => router.push(href));
// Group nav items by heading, ordered by first appearance.
const groupOrder: string[] = [];
const groups: Record<string, PaletteNavItem[]> = {};
for (const item of navItems) {
const g = item.group ?? DEFAULT_GROUP;
if (!groups[g]) {
groups[g] = [];
groupOrder.push(g);
}
groups[g].push(item);
}
const routeLocal = commands.filter(
(c) => c.routes?.some((r) => pathname === r || pathname.startsWith(r + "/")),
);
const generalCommands = commands.filter((c) => !c.routes);
const activePageId = pages[pages.length - 1];
const activeGroup = activePageId?.startsWith(GROUP_PREFIX)
? activePageId.slice(GROUP_PREFIX.length)
: null;
const activePage =
activePageId && !activeGroup ? findCommand(commands, activePageId) : undefined;
const pageHeading = activePage?.label ?? activeGroup ?? null;
// Resolve frecency ids to renderable items. Top 5, leaves only.
const navById = new Map(navItems.map((n) => [n.id, n] as const));
const recentItems = recentIds
.map((id): PaletteNavItem | PaletteCommand | null => {
const nav = navById.get(id);
if (nav) return nav;
const cmd = findCommand(commands, id);
return cmd && !cmd.children?.length ? cmd : null;
})
.filter((x): x is PaletteNavItem | PaletteCommand => x !== null)
.slice(0, 5);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
// Reset page/search state when ⌘K closes so a nested page doesn't
// reappear on the next open (this path bypasses close()).
setOpen((v) => {
if (v) {
setPages([]);
setSearch("");
}
return !v;
});
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// Load the frecency ranking on open so Recent is fresh.
useEffect(() => {
if (open) setRecentIds(rankByFrecency(loadFrecency(storageKey), Date.now()));
}, [open, storageKey]);
// Debounced async search at the palette root. Stale responses are dropped.
useEffect(() => {
const q = search.trim();
if (!open || !onSearch || pages.length > 0 || q.length < 2) {
setSearchHits([]);
return;
}
let active = true;
const t = setTimeout(() => {
onSearch(q)
.then((hits) => active && setSearchHits(hits))
.catch(() => active && setSearchHits([]));
}, 150);
return () => {
active = false;
clearTimeout(t);
};
}, [open, search, pages.length, onSearch]);
const track = useCallback(
(id: string) => {
const next = recordUse(loadFrecency(storageKey), id, Date.now());
saveFrecency(next, storageKey);
setRecentIds(rankByFrecency(next, Date.now()));
},
[storageKey],
);
const close = useCallback(() => {
setOpen(false);
setPages([]);
setSearch("");
setSearchHits([]);
}, []);
const openPage = useCallback((id: string) => {
setPages((p) => [...p, id]);
setSearch("");
}, []);
const popPage = useCallback(() => setPages((p) => p.slice(0, -1)), []);
const selectNav = useCallback(
(item: PaletteNavItem) => {
track(item.id);
close();
go(item.href);
},
[track, close, go],
);
const selectCommand = useCallback(
(cmd: PaletteCommand) => {
if (cmd.children?.length) {
openPage(cmd.id);
return;
}
track(cmd.id);
close();
void cmd.run?.();
},
[openPage, track, close],
);
const selectRecent = useCallback(
(item: PaletteNavItem | PaletteCommand) => {
if ("href" in item) selectNav(item);
else selectCommand(item);
},
[selectNav, selectCommand],
);
const itemClass =
"flex items-center gap-2 px-3 py-2 rounded-md text-[13px] text-foreground cursor-default select-none data-[selected=true]:bg-muted";
const groupClass =
"[&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-[11px] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-[0.4px] [&_[cmdk-group-heading]]:text-[var(--shell-muted-fg)]";
return (
<div
style={{
["--shell-muted-fg" as string]: "color-mix(in oklch, var(--foreground) 62%, var(--background))",
["--shell-surface-1" as string]: "color-mix(in oklch, var(--muted) 55%, var(--background))",
}}
>
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Open command palette"
className="hidden w-[200px] items-center gap-2 rounded-md border border-border bg-[var(--shell-surface-1)] px-3 py-[7px] text-[var(--shell-muted-fg)] transition-colors hover:bg-muted sm:flex"
>
<Search className="size-[13px] shrink-0" aria-hidden="true" />
<span className="flex-1 text-left text-[13px] tracking-[-0.13px]">{placeholder}</span>
<kbd className="rounded bg-accent px-[5px] py-[1px] text-[10px] font-medium text-[var(--shell-muted-fg)]">
⌘K
</kbd>
</button>
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Open command palette"
className="flex h-[36px] w-[36px] items-center justify-center rounded-md text-[var(--shell-muted-fg)] transition-colors hover:bg-[var(--shell-surface-1)] hover:text-foreground sm:hidden"
>
<Search className="size-[15px]" aria-hidden="true" />
</button>
<Command.Dialog
open={open}
onOpenChange={(v) => (v ? setOpen(true) : close())}
label="Command palette"
loop
// cmdk portals Overlay+Content to document.body, outside this component's
// wrapper div — so the --shell-* vars set there never reach the dialog.
// cmdk spreads rest-props (incl. style) from Command.Dialog onto the inner
// Command root div, which IS inside the portaled Content, so re-declaring
// --shell-muted-fg here cascades to Input/List/Item/Group inside the dialog.
style={{
["--shell-muted-fg" as string]: "color-mix(in oklch, var(--foreground) 62%, var(--background))",
}}
// Overlay is a portal *sibling* of Content, not a descendant — no style
// prop reaches it, so --shell-scrim must be inlined here directly.
overlayClassName="fixed inset-0 z-50 bg-[color-mix(in_oklch,var(--foreground)_45%,transparent)] backdrop-blur-[2px]"
contentClassName="fixed left-1/2 top-[20vh] z-50 w-[min(560px,calc(100vw-32px))] -translate-x-1/2 overflow-hidden rounded-[var(--radius)] border border-border bg-background shadow-2xl shadow-[color-mix(in_oklch,var(--foreground)_25%,transparent)]"
>
<Command.Input
value={search}
onValueChange={setSearch}
placeholder={pageHeading ? `${pageHeading}…` : "Search…"}
className="w-full border-b border-border bg-transparent px-4 py-3 text-[14px] text-foreground outline-none placeholder:text-[var(--shell-muted-fg)]"
onKeyDown={(e) => {
// Backspace on an empty query steps back out of a nested page.
if (e.key === "Backspace" && search === "" && pages.length > 0) {
e.preventDefault();
popPage();
}
}}
/>
<Command.List className={`max-h-[min(420px,50vh)] overflow-y-auto p-2 ${groupClass}`}>
<Command.Empty className="px-3 py-6 text-center text-[13px] text-[var(--shell-muted-fg)]">
{search ? `No results for "${search}"` : "No results."}
</Command.Empty>
{activePage ? (
<Command.Group heading={activePage.label}>
<BackRow onBack={popPage} itemClass={itemClass} />
{activePage.children?.map((child) => (
<Row key={child.id} entry={child} onSelect={() => selectCommand(child)} itemClass={itemClass} />
))}
</Command.Group>
) : activeGroup && search === "" ? (
// Nav-group submenu (browse). Typing falls through to flattened search.
<Command.Group heading={activeGroup}>
<BackRow onBack={popPage} itemClass={itemClass} />
{(groups[activeGroup] ?? []).map((item) => (
<Row key={item.id} entry={item} onSelect={() => selectNav(item)} itemClass={itemClass} />
))}
</Command.Group>
) : (
<>
{/* Async results — keep this group MOUNTED for the whole non-empty
search (gate on `search`, NOT hit count): cmdk drops a group
from its internal map on unmount but does not rebuild
filtered.groups, and its sort then derefs an undefined group
Set and throws. Only unmount at search === "". */}
{onSearch && pages.length === 0 && search.trim() !== "" && (
<Command.Group heading="Results">
{searchHits.map((hit) => (
<Command.Item
key={hit.id}
// Carry the raw query so cmdk never re-filters server results out.
value={`result ${hit.label} ${hit.id} ${search}`}
onSelect={() => selectCommand(hit)}
className={itemClass}
>
<RowIcon icon={hit.icon ?? CornerDownLeft} />
<span className="flex-1 truncate">{hit.label}</span>
</Command.Item>
))}
</Command.Group>
)}
{routeLocal.length > 0 && (
<Command.Group heading="On this page">
{routeLocal.map((cmd) => (
<Row key={cmd.id} entry={cmd} onSelect={() => selectCommand(cmd)} itemClass={itemClass} />
))}
</Command.Group>
)}
{/* Recent (frecency) — only on the empty-query root so it doesn't
duplicate items once cmdk filters a typed query. */}
{search === "" && recentItems.length > 0 && (
<Command.Group heading="Recent">
{recentItems.map((it) => (
<Command.Item
key={`recent:${it.id}`}
value={`recent ${it.label} ${it.id}`}
onSelect={() => selectRecent(it)}
className={itemClass}
>
<RowIcon icon={it.icon} />
<span className="flex-1 truncate">{it.label}</span>
</Command.Item>
))}
</Command.Group>
)}
{search === "" ? (
// Browsing: condensed group entries that open submenus.
<Command.Group heading="Go to">
{groupOrder.map((group) => (
<Command.Item
key={group}
value={`${group} section`}
onSelect={() => openPage(`${GROUP_PREFIX}${group}`)}
className={itemClass}
>
<RowIcon icon={Folder} />
<span className="flex-1">{group}</span>
<span className="text-[11px] tabular-nums text-[var(--shell-muted-fg)]">
{groups[group].length}
</span>
<ChevronRight className="size-4 text-[var(--shell-muted-fg)]" aria-hidden="true" />
</Command.Item>
))}
</Command.Group>
) : (
// Searching: flatten all nav so a query finds anything directly.
groupOrder.map((group) => (
<Command.Group key={group} heading={group}>
{groups[group].map((item) => (
<Row key={item.id} entry={item} onSelect={() => selectNav(item)} itemClass={itemClass} />
))}
</Command.Group>
))
)}
{generalCommands.length > 0 && (
<Command.Group heading="Actions">
{generalCommands.map((cmd) => (
<Row key={cmd.id} entry={cmd} onSelect={() => selectCommand(cmd)} itemClass={itemClass} />
))}
</Command.Group>
)}
</>
)}
</Command.List>
</Command.Dialog>
</div>
);
}
/** One nav item or command row. cmdk value = label + keywords + id for search. */
function Row({
entry,
onSelect,
itemClass,
}: {
entry: PaletteNavItem | PaletteCommand;
onSelect: () => void;
itemClass: string;
}) {
const nested = "children" in entry && !!entry.children?.length;
return (
<Command.Item
value={`${entry.label} ${(entry.keywords ?? []).join(" ")} ${entry.id}`}
onSelect={onSelect}
className={itemClass}
>
<RowIcon icon={entry.icon} />
<span className="flex-1 truncate">{entry.label}</span>
{nested && <ChevronRight className="size-4 text-[var(--shell-muted-fg)]" aria-hidden="true" />}
</Command.Item>
);
}
function RowIcon({ icon: Icon }: { icon?: ComponentType<{ className?: string }> }) {
if (!Icon) return <span className="size-4 shrink-0" aria-hidden="true" />;
return <Icon className="size-4 shrink-0 text-[var(--shell-muted-fg)]" aria-hidden="true" />;
}
/** "‹ Back" row that steps out of a nested page (also via Backspace on empty query). */
function BackRow({ onBack, itemClass }: { onBack: () => void; itemClass: string }) {
return (
<Command.Item value="back go back" onSelect={onBack} className={itemClass}>
<ChevronLeft className="size-4 text-[var(--shell-muted-fg)]" aria-hidden="true" />
<span className="flex-1 text-[var(--shell-muted-fg)]">Back</span>
</Command.Item>
);
}
/** Depth-first command lookup (nested pages live in children). */
function findCommand(commands: PaletteCommand[], id: string): PaletteCommand | undefined {
for (const c of commands) {
if (c.id === id) return c;
const hit = c.children && findCommand(c.children, id);
if (hit) return hit;
}
return undefined;
}
Dependencies
- cmdk
- lucide-react
- @bottega/tokens