Command Palette
A command palette with fuzzy subsequence matching — "nse" finds "New Section", where substring matching would find nothing and feel broken. The visible part is easy; what is usually missing is around it. Focus is trapped inside the dialog and returned to wherever it came from on close. Focus stays in the input while aria-activedescendant points at the active option, which is the only arrangement where arrowing through results and continuing to type both keep working. Scrolling follows the selection with block: nearest, so arrowing to an off-screen row nudges the list rather than yanking it.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { CommandPalette } from "@/components/commandpalette"; <CommandPalette />Customise
The component
components/craft/interface/CommandPalette.tsx
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; /** * A command palette — ⌘K, filter, run. * * The visual part is easy; the reason most implementations are quietly broken * is everything around it: * * FOCUS IS TRAPPED and returned. Tab cycles inside the dialog, and on close * focus goes back to whatever was focused before it opened. Without that, a * keyboard user tabs out of an open dialog into the page behind it and is * stranded — which is a strange thing to get wrong in a component whose only * audience is keyboard users. * * THE LIST IS NOT FOCUSED, the input is. Moving DOM focus to each option as * you arrow through it means the input loses focus and typing stops working. * Instead focus stays in the input and `aria-activedescendant` points at the * active option, which is the combobox pattern and the only way both keep * working at once. * * SCROLLING FOLLOWS THE SELECTION with `nearest`, so arrowing to an item just * off-screen nudges the list by one row rather than yanking it to centre. * * Matching is subsequence, not substring: "nse" finds "New Section". Substring * matching feels broken the moment anyone types the initials they think in. */ export interface Command { id: string; label: string; /** Shown right-aligned — a section name, or a keyboard hint. */ hint?: string; keywords?: string; run?: () => void;} /** Subsequence match with a score: earlier and tighter runs rank higher. */function score(query: string, text: string): number | null { if (!query) return 0; const q = query.toLowerCase(); const t = text.toLowerCase(); let ti = 0; let points = 0; let previous = -1; for (const char of q) { const found = t.indexOf(char, ti); if (found === -1) return null; // Adjacent characters are worth more than scattered ones, and a match at // a word boundary is worth more again. if (found === previous + 1) points += 3; if (found === 0 || t[found - 1] === " ") points += 2; points += 1; previous = found; ti = found + 1; } return points;} export interface CommandPaletteProps { commands: Command[]; placeholder?: string; /** Rendered inline instead of as a modal — for previews and docs. */ inline?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; accent?: string; still?: boolean; className?: string;} export function CommandPalette({ commands, placeholder = "Type a command…", inline = false, open: controlledOpen, onOpenChange, accent = "#c3f53c", still = false, className = "",}: CommandPaletteProps) { const [uncontrolledOpen, setUncontrolledOpen] = useState(false); const open = inline ? true : (controlledOpen ?? uncontrolledOpen); const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const listId = useId(); const inputRef = useRef<HTMLInputElement>(null); const listRef = useRef<HTMLUListElement>(null); const dialogRef = useRef<HTMLDivElement>(null); const returnFocusRef = useRef<HTMLElement | null>(null); const setOpen = useCallback( (next: boolean) => { if (onOpenChange) onOpenChange(next); else setUncontrolledOpen(next); }, [onOpenChange], ); const results = useMemo(() => { const scored = commands .map((command) => ({ command, points: score(query, `${command.label} ${command.keywords ?? ""}`), })) .filter((r): r is { command: Command; points: number } => r.points !== null); // Stable when the query is empty: authored order is meaningful, and // re-sorting an unfiltered list shuffles it for no reason. return query ? scored.sort((a, b) => b.points - a.points) : scored; }, [commands, query]); // ⌘K / Ctrl+K to open. useEffect(() => { if (inline) return; const onKey = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); returnFocusRef.current = document.activeElement as HTMLElement | null; setOpen(true); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [inline, setOpen]); /** * Adjusted during render rather than synced from an effect. * * "Reset the query when the palette opens" and "highlight the first result * when the query changes" are both derived state. Doing them in an effect * means React commits the stale value, paints, and then immediately renders * again — a visible flash of the previous search on every open. Comparing * against the previous value during render is the pattern React documents * for exactly this, and it re-renders before anything reaches the screen. */ const [prevOpen, setPrevOpen] = useState(open); if (open !== prevOpen) { setPrevOpen(open); if (open) { setQuery(""); setActive(0); } } const [prevQuery, setPrevQuery] = useState(query); if (query !== prevQuery) { setPrevQuery(query); setActive(0); } useEffect(() => { if (!open) return; // Deferred a frame: focusing during the same commit that mounts the // element can land before it is laid out, and the caret ends up nowhere. const id = requestAnimationFrame(() => inputRef.current?.focus()); return () => cancelAnimationFrame(id); }, [open]); // Restores focus to where it came from, or a keyboard user is dumped at the // top of the document. useEffect(() => { if (open || inline) return; returnFocusRef.current?.focus(); returnFocusRef.current = null; }, [open, inline]); // Keeps the active row in view without yanking the list around. useEffect(() => { listRef.current ?.querySelector<HTMLElement>(`[data-index="${active}"]`) ?.scrollIntoView({ block: "nearest" }); }, [active]); const choose = (command: Command) => { command.run?.(); if (!inline) setOpen(false); }; const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === "ArrowDown") { event.preventDefault(); setActive((i) => (results.length ? (i + 1) % results.length : 0)); } else if (event.key === "ArrowUp") { event.preventDefault(); setActive((i) => (results.length ? (i - 1 + results.length) % results.length : 0)); } else if (event.key === "Home") { event.preventDefault(); setActive(0); } else if (event.key === "End") { event.preventDefault(); setActive(Math.max(0, results.length - 1)); } else if (event.key === "Enter") { event.preventDefault(); const hit = results[active]; if (hit) choose(hit.command); } else if (event.key === "Escape" && !inline) { event.preventDefault(); setOpen(false); } else if (event.key === "Tab" && !inline) { // Focus trap. Only the input and the close control are tabbable, so the // cycle is short — but it has to exist, or Tab escapes into the page // behind an open dialog. const focusable = dialogRef.current?.querySelectorAll<HTMLElement>( 'input, button, [href], [tabindex]:not([tabindex="-1"])', ); if (!focusable?.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } } }; if (!open) return null; const panel = ( <div ref={dialogRef} role={inline ? undefined : "dialog"} aria-modal={inline ? undefined : true} aria-label="Command palette" onKeyDown={onKeyDown} className={`w-full overflow-hidden rounded-xl border border-ink-600 bg-ink-800 shadow-2xl ${className}`} > <div className="border-b border-ink-600 px-4"> <input ref={inputRef} value={query} onChange={(e) => setQuery(e.target.value)} placeholder={placeholder} role="combobox" aria-expanded="true" aria-controls={listId} // Focus stays here; this is what tells assistive tech which option // is current without moving DOM focus off the input. aria-activedescendant={results[active] ? `${listId}-${active}` : undefined} aria-autocomplete="list" autoComplete="off" spellCheck={false} className="w-full bg-transparent py-3.5 text-sm text-bone-100 placeholder:text-bone-500 focus:outline-none" /> </div> <ul ref={listRef} id={listId} role="listbox" aria-label="Commands" data-lenis-prevent className="max-h-72 overflow-auto overscroll-contain p-2" > {results.length === 0 ? ( <li className="px-3 py-6 text-center font-mono text-micro text-bone-500"> Nothing matches “{query}” </li> ) : ( results.map(({ command }, index) => { const isActive = index === active; return ( <li key={command.id} id={`${listId}-${index}`} data-index={index} role="option" aria-selected={isActive} onPointerMove={() => setActive(index)} onClick={() => choose(command)} className="flex cursor-pointer items-center justify-between gap-4 rounded-md px-3 py-2.5 text-sm" style={{ background: isActive ? `${accent}1a` : "transparent", color: isActive ? accent : undefined, transition: still ? "none" : "background-color 120ms linear", }} > <span className={isActive ? "" : "text-bone-200"}>{command.label}</span> {command.hint ? ( <span className="font-mono text-micro text-bone-500">{command.hint}</span> ) : null} </li> ); }) )} </ul> <div className="flex items-center gap-4 border-t border-ink-600 px-4 py-2 font-mono text-micro text-bone-500"> <span>↑↓ navigate</span> <span>↵ run</span> {!inline ? <span>esc close</span> : null} </div> </div> ); if (inline) return panel; return ( <div className="fixed inset-0 z-50 flex items-start justify-center p-4 pt-[12vh]"> <div className="absolute inset-0 bg-ink-900/70 backdrop-blur-sm" onClick={() => setOpen(false)} aria-hidden /> <div className="relative w-full max-w-lg">{panel}</div> </div> );} One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.
What it costs
- Weight
+3.8KB gzipped · no new dependencies
- Runs on
Main thread
Runs JavaScript every frame. Heavy work here competes with your own code and with scrolling — watch the per-frame budget below.
- Reduced motion
Row highlight transitions are dropped and the state change is instant. Nothing about the palette depends on animation to be understood.
- Accessibility
- role=dialog with aria-modal, and a Tab focus trap so keyboard users cannot land behind the open panel.
- Focus returns to the element that was focused before opening.
- combobox + listbox with aria-activedescendant: focus never leaves the input, so typing keeps working while arrowing.
- Arrow keys wrap, Home and End jump, Enter runs, Escape closes.
- Active row is scrolled with block: nearest rather than centred.
- Watch out
- Matching is scored in JavaScript on every keystroke. Fine to a few thousand commands; past that, debounce or index.
- The backdrop closes on click. If a command opens a nested dialog, mount it outside this one.
- It does not portal to the body — if an ancestor has a transform or overflow: hidden, the fixed overlay will be clipped by it.