Base UI command palette
components/command-palette.tsx
"use client";
import { useEffect, useState } from "react";
import { Dialog } from "@base-ui/react/dialog";
import { Autocomplete } from "@base-ui/react/autocomplete";
export type PaletteCommand = { id: string; label: string; group: string; shortcut?: string };
type Group = { value: string; items: PaletteCommand[] };
function toGroups(commands: PaletteCommand[]): Group[] {
const groups = new Map<string, PaletteCommand[]>();
for (const c of commands) groups.set(c.group, [...(groups.get(c.group) ?? []), c]);
return [...groups].map(([value, items]) => ({ value, items }));
}
// Base UI has no Command component. Dialog gives the modal, Autocomplete (inline + open) gives the
// filtered list with arrow keys; Enter or a click on a row runs onRun.
export function CommandPalette({
commands,
onRun,
}: {
commands: PaletteCommand[];
onRun: (id: string) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((o) => !o);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
function run(id: string) {
setOpen(false);
setQuery("");
onRun(id);
}
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Portal>
<Dialog.Backdrop className="fixed inset-0 bg-black/40" />
<Dialog.Popup className="fixed left-1/2 top-[20vh] w-[min(560px,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border bg-white text-neutral-950 shadow-2xl dark:bg-neutral-900 dark:text-white">
<Dialog.Title className="sr-only">Command palette</Dialog.Title>
<Autocomplete.Root
inline
open
items={toGroups(commands)}
value={query}
onValueChange={setQuery}
itemToStringValue={(c: PaletteCommand) => c.label}
autoHighlight
>
<Autocomplete.Input
placeholder="Type a command or search..."
className="h-12 w-full border-b bg-transparent px-4 text-base outline-none"
/>
<Autocomplete.Empty className="px-4 py-6 text-sm text-neutral-500 empty:hidden">
No results.
</Autocomplete.Empty>
<Autocomplete.List className="max-h-80 overflow-y-auto p-2">
{(group: Group) => (
<Autocomplete.Group key={group.value} items={group.items} className="pb-1">
<Autocomplete.GroupLabel className="px-2 py-1.5 text-xs font-medium text-neutral-500">
{group.value}
</Autocomplete.GroupLabel>
<Autocomplete.Collection>
{(c: PaletteCommand) => (
<Autocomplete.Item
key={c.id}
value={c}
onClick={() => run(c.id)} // Enter on the highlighted row clicks it too
className="flex cursor-default items-center justify-between rounded-md px-2 py-2 text-sm data-highlighted:bg-neutral-100 dark:data-highlighted:bg-neutral-800"
>
{c.label}
{c.shortcut && <kbd className="text-xs text-neutral-500">{c.shortcut}</kbd>}
</Autocomplete.Item>
)}
</Autocomplete.Collection>
</Autocomplete.Group>
)}
</Autocomplete.List>
</Autocomplete.Root>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}
components/app-commands.tsx (your commands, Next.js routing)
"use client";
import { useRouter } from "next/navigation";
import { CommandPalette, type PaletteCommand } from "@/components/command-palette";
const COMMANDS: PaletteCommand[] = [
{ id: "/dashboard", label: "Dashboard", group: "Go to", shortcut: "G D" },
{ id: "/invoices", label: "Invoices", group: "Go to", shortcut: "G I" },
{ id: "/settings", label: "Settings", group: "Go to" },
{ id: "new-invoice", label: "New invoice", group: "Actions", shortcut: "N" },
{ id: "invite", label: "Invite a teammate", group: "Actions" },
];
export function AppCommands() {
const router = useRouter();
return (
<CommandPalette
commands={COMMANDS}
onRun={(id) => (id.startsWith("/") ? router.push(id) : console.log("run", id))}
/>
);
}