## Command Palette — Overlay Results reorder as you type. Docs: https://www.interior.dev/docs/command-palette Reference: https://www.interior.dev/reference/command-palette License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/command-palette.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useRouter } from "next/navigation"; import { CommandPalette, type CommandItem, } from "@/components/interior/command-palette"; const commands: CommandItem[] = [ { id: "new", label: "New document", hint: "Workspace", shortcut: ["⌘", "N"] }, { id: "dup", label: "Duplicate document", keywords: "copy clone" }, { id: "export", label: "Export as PDF", keywords: "download print" }, { id: "history", label: "Version history", keywords: "revisions restore" }, { id: "settings", label: "Open settings", shortcut: ["⌘", ","] }, ]; export function Launcher({ onClose }: { onClose: () => void }) { const router = useRouter(); return ( { router.push(`/actions/${item.id}`); onClose(); }} onDismiss={onClose} /> ); } ``` ### Props - `items` (`CommandItem[]`) The full command set. Each item needs a stable id; label, optional hint, keywords for alias matching, and shortcut keycaps. - `onSelect` (`(item: CommandItem) => void`) Runs the highlighted command on Enter or click. Receives the item, never an index. - `onDismiss` (`() => void`) — default: `undefined` Called by Escape only once the query is already empty, so the first Escape clears rather than closes. - `placeholder` (`string`) — default: `"Search commands"` Input placeholder. - `emptyLabel` (`string`) — default: `"No command matches"` Shown inside the reserved list box when nothing matches, and announced to screen readers. - `label` (`string`) — default: `"Command palette"` Accessible name for the combobox and the listbox. - `maxRows` (`number`) — default: `6` Rows of height the list reserves. The box is sized once from the item count and never resizes while typing. - `autoFocus` (`boolean`) — default: `false` Focuses the input on mount with preventScroll, so opening the palette never scrolls the page behind it. - `className` (`string`) Appended last to the outer panel, so callers can override width, radius or surface. ### Behavior notes - Selection is anchored to a command id, not to a row index, so a keystroke that reranks the list cannot slide a different command under the highlight between the moment you press Enter and the moment it fires. - The list box reserves its height from the item count at mount and scrolls inside, so filtering eight commands down to one never resizes the panel or moves the input under the cursor. - Rows respond to onPointerMove and ignore repeated coordinates, so a reordering list sliding beneath a stationary mouse cannot steal the highlight from the keyboard. - Ranking is a deterministic subsequence score with a stable index tiebreak: equal scores keep their authored order, so results never shuffle for reasons the typist cannot see. - Rows move with layout="position" and their own background opacity rather than a shared highlight that flies across the list, and no layout property is animated. - The result count is written to a polite live region through a ref after a 400ms pause, so a screen reader hears one total instead of one per keystroke, and reduced motion drops every spring while keeping the selection visible. ### Source (`components/interior/command-palette.tsx`) ```tsx "use client"; import { useEffect, useId, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; const BOUNDARY = /[\s\-_/.:]/; const ROW = 36; const GAP = 2; const PAD = 5; export type CommandItem = { id: string; label: string; hint?: string; keywords?: string; shortcut?: string[]; }; export type UseCommandPaletteOptions = { items: CommandItem[]; onSelect: (item: CommandItem) => void; onDismiss?: () => void; }; function scoreOne(text: string, query: string): number { const t = text.toLowerCase(); let cursor = 0; let total = 0; let streak = 0; for (let i = 0; i < query.length; i++) { const at = t.indexOf(query[i], cursor); if (at < 0) return -1; streak = at === cursor && i > 0 ? streak + 1 : 0; total += 2 + streak * 4; if (at === 0) total += 12; else if (BOUNDARY.test(t[at - 1])) total += 8; cursor = at + 1; } return total; } function rank(items: CommandItem[], query: string): CommandItem[] { const q = query.trim().toLowerCase(); if (!q) return items; const scored: { item: CommandItem; score: number; order: number }[] = []; for (let i = 0; i < items.length; i++) { const item = items[i]; const direct = scoreOne(item.label, q); const aliased = item.keywords ? scoreOne(item.keywords, q) - 3 : -1; const best = Math.max(direct, item.keywords ? aliased : -1); if (best < 0) continue; scored.push({ item, score: best - item.label.length * 0.05, order: i }); } scored.sort((a, b) => b.score - a.score || a.order - b.order); return scored.map((s) => s.item); } export function useCommandPalette({ items, onSelect, onDismiss, }: UseCommandPaletteOptions) { const [query, setQuery] = useState(""); const [pinned, setPinned] = useState(null); const listRef = useRef(null); const pointer = useRef({ x: -1, y: -1 }); const select = useRef(onSelect); select.current = onSelect; const dismiss = useRef(onDismiss); dismiss.current = onDismiss; const results = useMemo(() => rank(items, query), [items, query]); const activeId = results.some((r) => r.id === pinned) ? pinned : (results[0]?.id ?? null); const activeIndex = results.findIndex((r) => r.id === activeId); useEffect(() => { if (listRef.current) listRef.current.scrollTop = 0; }, [query]); const reveal = (index: number) => { const list = listRef.current; const row = list?.children[index]; if (!list || !(row instanceof HTMLElement)) return; const top = row.offsetTop - PAD; const bottom = row.offsetTop + row.offsetHeight + PAD; if (top < list.scrollTop) list.scrollTop = top; else if (bottom > list.scrollTop + list.clientHeight) { list.scrollTop = bottom - list.clientHeight; } }; const jump = (index: number) => { if (results.length === 0) return; const next = Math.max(0, Math.min(results.length - 1, index)); setPinned(results[next].id); reveal(next); }; const move = (delta: number) => { if (results.length === 0) return; const from = activeIndex < 0 ? 0 : activeIndex; jump((from + delta + results.length) % results.length); }; const run = (item?: CommandItem) => { const target = item ?? results.find((r) => r.id === activeId); if (target) select.current(target); }; const pointerActivate = (id: string, event: React.PointerEvent) => { const { x, y } = pointer.current; if (event.clientX === x && event.clientY === y) return; pointer.current = { x: event.clientX, y: event.clientY }; if (id !== activeId) setPinned(id); }; const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === "ArrowDown") { event.preventDefault(); move(1); } else if (event.key === "ArrowUp") { event.preventDefault(); move(-1); } else if (event.key === "Home") { event.preventDefault(); jump(0); } else if (event.key === "End") { event.preventDefault(); jump(results.length - 1); } else if (event.key === "Enter") { event.preventDefault(); run(); } else if (event.key === "Escape") { event.preventDefault(); dismiss.current?.(); } }; return { query, setQuery, results, activeId, activeIndex, listRef, onKeyDown, pointerActivate, jump, move, run, }; } export type CommandPaletteProps = { items: CommandItem[]; onSelect: (item: CommandItem) => void; onDismiss?: () => void; open?: boolean; placeholder?: string; emptyLabel?: string; label?: string; maxRows?: number; autoFocus?: boolean; className?: string; }; export function CommandPalette({ items, onSelect, onDismiss, open, placeholder = "Search commands", emptyLabel = "No command matches", label = "Command palette", maxRows = 6, autoFocus = false, className = "", }: CommandPaletteProps) { const uid = useId(); const reduced = useReducedMotion(); const panelRef = useRef(null); const inputRef = useRef(null); const liveRef = useRef(null); const { query, setQuery, results, activeId, listRef, onKeyDown, pointerActivate, run, } = useCommandPalette({ items, onSelect, onDismiss }); const rows = Math.max(1, Math.min(maxRows, items.length)); const height = PAD * 2 + rows * ROW + (rows - 1) * GAP; const count = results.length; useEffect(() => { if (autoFocus) inputRef.current?.focus({ preventScroll: true }); }, [autoFocus]); useEffect(() => { if (open) setQuery(""); }, [open, setQuery]); useEffect(() => { const id = setTimeout(() => { if (!liveRef.current) return; liveRef.current.textContent = count === 0 ? emptyLabel : `${count} ${count === 1 ? "command" : "commands"} available`; }, 400); return () => clearTimeout(id); }, [count, emptyLabel]); const spring = reduced ? { duration: 0 } : CELL; const overlaid = open !== undefined; const surface = (
setQuery(e.target.value)} onKeyDown={onKeyDown} className="h-full min-w-0 flex-1 bg-transparent text-[13.5px] text-stone-700 outline-none placeholder:text-stone-400 dark:text-stone-200 dark:placeholder:text-stone-500" /> {count}
    e.preventDefault()} className="absolute inset-0 flex flex-col gap-[2px] overflow-y-auto overscroll-contain p-[5px] [scrollbar-gutter:stable]" > {results.map((item) => { const active = item.id === activeId; return ( /* eslint-disable-next-line jsx-a11y/interactive-supports-focus */ pointerActivate(item.id, e)} onClick={() => run(item)} className="relative flex h-9 shrink-0 cursor-default items-center rounded-[9px] px-2.5" > {item.label} {item.hint ? ( {item.hint} ) : null} {item.shortcut ? ( {item.shortcut.map((key) => ( {key} ))} ) : null} ); })}
{count === 0 ? ( {emptyLabel} ) : null}
); if (!overlaid) return surface; return ( {surface} ); } const LAYER_EASE = [0.23, 1, 0.32, 1] as const; const LAYER_OUT = [0.4, 0, 1, 1] as const; const PANEL = { type: "spring", stiffness: 420, damping: 36, mass: 0.9 } as const; function PaletteLayer({ open, onDismiss, reduced, panelRef, children, }: { open: boolean; onDismiss?: () => void; reduced: boolean; panelRef: React.RefObject; children: React.ReactNode; }) { const [host, setHost] = useState(null); const downedOutside = useRef(false); const leave = useRef(onDismiss); leave.current = onDismiss; useEffect(() => setHost(document.body), []); useEffect(() => { if (!open) return; const onKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; event.preventDefault(); event.stopPropagation(); leave.current?.(); }; document.addEventListener("keydown", onKeyDown, true); return () => document.removeEventListener("keydown", onKeyDown, true); }, [open]); useEffect(() => { if (!open) return; const root = document.documentElement; const overflow = root.style.overflow; const padding = root.style.paddingRight; const gutter = window.innerWidth - root.clientWidth; root.style.overflow = "hidden"; if (gutter > 0) root.style.paddingRight = `${gutter}px`; return () => { root.style.overflow = overflow; root.style.paddingRight = padding; }; }, [open]); if (!host) return null; return createPortal( {open ? ( { const panel = panelRef.current; downedOutside.current = !panel?.contains(event.target as Node); }} onClick={(event) => { const panel = panelRef.current; if (panel?.contains(event.target as Node)) return; if (!downedOutside.current) return; downedOutside.current = false; leave.current?.(); }} > {children} ) : null} , host, ); } ```