CommandPalette
LG, under 3300B gzippedA keyboard-first fuzzy launcher following the ARIA combobox pattern.
- Category
- Navigation
- Budget
- lg, composes several components
- Runtime deps
- None
Preview
This is the exact fixture the conformance suite renders in CI. Switch the language to see the component mirror and reformat.
Install
npx shadcn@latest add https://kata-ui-rho.vercel.app/r/command-palette.jsonCopies the source into your project. Pulls in 4 primitives: use-focus-trap, cn, portal, use-hydrated.
Source
"use client";
import { useEffect, useId, useMemo, useState } from "react";
import { useFocusTrap } from "../lib/use-focus-trap";
import { cn } from "../lib/cn";
import { Portal } from "./portal";
export interface Command {
id: string;
label: string;
onRun: () => void;
}
export interface CommandPaletteProps {
open: boolean;
onClose: () => void;
commands: Command[];
label?: string;
placeholder?: string;
}
/**
* A keyboard-first fuzzy launcher following the ARIA combobox pattern: the
* text input owns focus throughout, `aria-activedescendant` tracks the
* highlighted result without moving real focus off the field, and the
* listbox is announced by a live region as results change count.
*/
export function CommandPalette({ open, onClose, commands, label = "Command palette", placeholder = "Type a command…" }: CommandPaletteProps) {
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [queryAtLastReset, setQueryAtLastReset] = useState(query);
const listId = useId();
const trapRef = useFocusTrap<HTMLDivElement>(open);
const results = useMemo(
() => commands.filter((c) => c.label.toLowerCase().includes(query.toLowerCase())),
[commands, query],
);
// Reset the highlight when the query changes — computed during render
// (React's own pattern for "adjust state when a value changes") rather
// than in an effect, since the query and the palette are both this
// component's own state, not an external system to synchronise with.
if (query !== queryAtLastReset) {
setQueryAtLastReset(query);
setActiveIndex(0);
}
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open, onClose]);
if (!open) return null;
const run = (command: Command) => {
command.onRun();
onClose();
};
return (
<Portal>
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24">
<div aria-hidden="true" className="absolute inset-0 bg-black/50" onClick={onClose} />
<div ref={trapRef} tabIndex={-1} className="relative z-10 w-full max-w-lg overflow-hidden rounded-xl bg-white shadow-xl dark:bg-neutral-900">
<input
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-activedescendant={results[activeIndex] ? `${listId}-${results[activeIndex].id}` : undefined}
aria-label={label}
autoFocus
value={query}
placeholder={placeholder}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((i) => Math.min(results.length - 1, i + 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((i) => Math.max(0, i - 1));
} else if (event.key === "Enter" && results[activeIndex]) {
run(results[activeIndex]);
}
}}
className="w-full border-b border-neutral-200 px-4 py-3 text-base outline-none dark:border-neutral-800 dark:bg-neutral-900"
/>
<ul id={listId} role="listbox" aria-label={label} className="max-h-72 overflow-y-auto p-1">
{results.map((command, index) => (
<li
key={command.id}
id={`${listId}-${command.id}`}
role="option"
aria-selected={index === activeIndex}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => run(command)}
className={cn(
"cursor-pointer rounded-md px-3 py-2 text-sm",
index === activeIndex ? "bg-blue-600 text-on-accent" : "hover:bg-neutral-100 dark:hover:bg-neutral-800",
)}
>
{command.label}
</li>
))}
{results.length === 0 && <li className="px-3 py-2 text-sm text-neutral-500">No results</li>}
</ul>
</div>
</div>
</Portal>
);
}
What CI checks
- Bundled, minified and gzipped against its tier budget
- Audited by axe in the state previewed above
- Rendered through react-dom/server with no browser globals
- Scanned for network calls, dangerous sinks, and unguarded animation