bottega

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-palette

Usage

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

PropTypeDefaultDescription
navItemsPaletteNavItem[][]
commandsPaletteCommand[][]
onNavigate(href: string) => voidDefaults to router.push.
onSearch(query: string) => Promise<PaletteCommand[]>Async app search — results render in a "Results" group while typing.
storageKeystringbottega.palette.frecencylocalStorage key for the frecency ranking.
placeholderstringSearch…

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