CommandPalette
Generic ⌘K overlay: filter items by title/subtitle/keywords and act on selection.
import { CommandPalette, useCommandPalette, CommandItem }from "@/components/ui"
⌘K launcher
…or press ⌘K / Ctrl+K
Full source
The complete component, read from src/components/ui/command-palette.tsx.
command-palette.tsx
"use client";
import { useState, useEffect, useCallback, useRef, useMemo, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useMounted } from "@/hooks";
export interface CommandItem {
id: string;
title: string;
subtitle?: string;
tag?: string;
icon?: ReactNode;
keywords?: string;
}
interface CommandPaletteProps {
open: boolean;
onClose: () => void;
items: CommandItem[];
onSelect: (id: string) => void;
placeholder?: string;
emptyHeading?: string;
}
export function CommandPalette({
open,
onClose,
items,
onSelect,
placeholder = "Search, or type a command…",
emptyHeading = "Suggestions",
}: CommandPaletteProps) {
const [query, setQuery] = useState("");
const [selected, setSelected] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const mounted = useMounted();
const results = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter((it) =>
[it.title, it.subtitle, it.tag, it.keywords]
.filter(Boolean)
.some((s) => (s as string).toLowerCase().includes(q))
);
}, [items, query]);
useEffect(() => {
if (open) {
setQuery("");
setSelected(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
useEffect(() => setSelected(0), [query]);
const choose = useCallback(
(item?: CommandItem) => {
if (!item) return;
onSelect(item.id);
onClose();
},
[onSelect, onClose]
);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") { e.preventDefault(); setSelected((i) => Math.min(i + 1, results.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setSelected((i) => Math.max(i - 1, 0)); }
else if (e.key === "Enter") { e.preventDefault(); choose(results[selected]); }
else if (e.key === "Escape") { e.preventDefault(); onClose(); }
};
if (!open || !mounted) return null;
return createPortal(
<>
<div className="fixed inset-0 z-[1000] bg-overlay" onClick={onClose} aria-hidden />
<div
role="dialog"
aria-modal="true"
aria-label="Command palette"
className="fixed left-1/2 top-[15%] z-[1001] flex w-[calc(100%-2rem)] max-w-xl -translate-x-1/2 flex-col overflow-hidden rounded-2xl border border-border bg-bg shadow-overlay"
>
{}
<div className="flex items-center gap-3 border-b border-border p-4">
<Search size={20} className="shrink-0 text-text-tertiary" aria-hidden />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
style={{ outline: "none" }}
className="flex-1 border-0 bg-transparent text-base text-text placeholder:text-text-tertiary focus:outline-none focus:ring-0"
/>
<button
onClick={onClose}
aria-label="Close"
className="flex size-7 items-center justify-center rounded-md bg-bg-secondary text-text-tertiary hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<X size={16} aria-hidden />
</button>
</div>
{}
<div className="max-h-[50vh] min-h-0 flex-1 overflow-y-auto p-2">
{!query.trim() && (
<p className="px-3 py-2 text-xs font-medium uppercase tracking-wider text-text-tertiary">
{emptyHeading}
</p>
)}
{results.length === 0 ? (
<div className="px-8 py-10 text-center text-text-tertiary">
<Search size={32} className="mx-auto mb-2 opacity-50" aria-hidden />
<p className="text-sm">No results for “{query}”</p>
</div>
) : (
results.map((it, i) => (
<button
key={it.id}
onMouseEnter={() => setSelected(i)}
onClick={() => choose(it)}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors",
i === selected ? "bg-bg-secondary" : "hover:bg-bg-hover"
)}
>
{it.icon && (
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent-light text-accent">
{it.icon}
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-text">{it.title}</span>
{it.subtitle && (
<span className="block truncate text-xs text-text-tertiary">{it.subtitle}</span>
)}
</span>
{it.tag && <span className="shrink-0 text-xs capitalize text-text-tertiary">{it.tag}</span>}
</button>
))
)}
</div>
{}
<div className="flex items-center justify-center gap-4 border-t border-border p-3 text-xs text-text-tertiary">
<span><kbd className="rounded bg-bg-secondary px-1.5 py-0.5">↑↓</kbd> Navigate</span>
<span><kbd className="rounded bg-bg-secondary px-1.5 py-0.5">↵</kbd> Open</span>
<span><kbd className="rounded bg-bg-secondary px-1.5 py-0.5">esc</kbd> Close</span>
</div>
</div>
</>,
document.body
);
}
export function useCommandPalette() {
const [open, setOpen] = useState(false);
const openPalette = useCallback(() => setOpen(true), []);
const closePalette = useCallback(() => setOpen(false), []);
const toggle = useCallback(() => setOpen((o) => !o), []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
toggle();
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [toggle]);
return { open, openPalette, closePalette, toggle, isOpen: open };
}Dependencies
To use CommandPalette you also need the following. Copy these into your project alongside it.
Recommended folder structure
project structure
src/components/ui/ └─ command-palette.tsx src/hooks/ └─ use-mounted.ts src/lib/ └─ utils.ts