navigationclient
command palette
a searchable list of actions in a dialog. press ⌘k on this site to try the one that searches these docs.
installation
new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.
bash
npx @blurrow/ui init›rather own the code? copy the source instead
let the cli copy it, along with anything it needs:
bash
npx @blurrow/ui add command-paletteor by hand: drop these files into components/ui and import from there. command palette leans on Kbd.tsx and useModalBehavior.ts, so those come along too.
components/ui/CommandPalette.tsxtsx
1"use client";2
3import React, {4 useEffect,5 useLayoutEffect,6 useRef,7 useState,8 useMemo,9 useCallback,10 useId,11} from "react";12import { Kbd } from "./Kbd";13import { useModalBehavior } from "./useModalBehavior";14
15interface CommandItem {16 key: string;17 label: string;18 description?: string;19 icon?: React.ReactNode;20 shortcut?: string;21 onSelect?: () => void;22}23
24interface CommandSection {25 title: string;26 items: CommandItem[];27}28
29interface CommandPaletteProps {30 open: boolean;31 onClose: () => void;32 sections: CommandSection[];33 placeholder?: string;34 className?: string;35}36
37const CLOSE_MS = 240;38
39export function CommandPalette({40 open,41 onClose,42 sections,43 placeholder = "search commands…",44 className = "",45}: CommandPaletteProps) {46 const [mounted, setMounted] = useState(false);47 const [visible, setVisible] = useState(false);48 const [query, setQuery] = useState("");49 const [activeIndex, setActiveIndex] = useState(0);50
51 const dialogRef = useRef<HTMLDivElement>(null);52 const inputRef = useRef<HTMLInputElement>(null);53 const listRef = useRef<HTMLDivElement>(null);54 const uid = useId();55
56 // Filter sections based on query57 const filtered = useMemo(() => {58 const q = query.toLowerCase().trim();59 if (!q) return sections;60 return sections61 .map((section) => ({62 ...section,63 items: section.items.filter(64 (item) =>65 item.label.toLowerCase().includes(q) ||66 item.description?.toLowerCase().includes(q),67 ),68 }))69 .filter((section) => section.items.length > 0);70 }, [sections, query]);71
72 // Flat list of all visible items for keyboard nav73 const flatItems = useMemo(74 () => filtered.flatMap((s) => s.items),75 [filtered],76 );77
78 // Mount / unmount lifecycle (matches Modal pattern)79 useEffect(() => {80 if (open) {81 setMounted(true);82 setQuery("");83 setActiveIndex(0);84 return;85 }86 if (mounted) {87 setVisible(false);88 const t = setTimeout(() => setMounted(false), CLOSE_MS);89 return () => clearTimeout(t);90 }91 }, [open, mounted]);92
93 // Enter animation (matches Modal pattern)94 useLayoutEffect(() => {95 if (!mounted || !open || visible) return;96
97 const el = dialogRef.current;98 if (el) {99 void el.getBoundingClientRect();100 }101
102 const id = requestAnimationFrame(() => {103 setVisible(true);104 });105 return () => cancelAnimationFrame(id);106 }, [mounted, open, visible]);107
108 // Reset active index when filter changes109 useEffect(() => {110 setActiveIndex(0);111 }, [query]);112
113 // Scroll active item into view114 useEffect(() => {115 if (!listRef.current) return;116 const activeEl = listRef.current.querySelector(117 `[data-command-index="${activeIndex}"]`,118 );119 if (activeEl) {120 activeEl.scrollIntoView({ block: "nearest" });121 }122 }, [activeIndex]);123
124 // Handle selection125 const handleSelect = useCallback(126 (item: CommandItem) => {127 item.onSelect?.();128 onClose();129 },130 [onClose],131 );132
133 // Keyboard navigation134 useEffect(() => {135 if (!open) return;136 const onKey = (e: KeyboardEvent) => {137 switch (e.key) {138 case "ArrowDown":139 e.preventDefault();140 setActiveIndex((i) => (i + 1 >= flatItems.length ? 0 : i + 1));141 break;142 case "ArrowUp":143 e.preventDefault();144 setActiveIndex((i) =>145 i - 1 < 0 ? Math.max(flatItems.length - 1, 0) : i - 1,146 );147 break;148 case "Enter":149 e.preventDefault();150 if (flatItems[activeIndex]) {151 handleSelect(flatItems[activeIndex]);152 }153 break;154 }155 };156 document.addEventListener("keydown", onKey);157 return () => document.removeEventListener("keydown", onKey);158 }, [open, onClose, flatItems, activeIndex, handleSelect]);159
160 // Escape, focus trap + restore, scroll lock161 useModalBehavior({ open, ready: visible, ref: dialogRef, onClose, initialFocus: inputRef });162
163 if (!mounted) return null;164
165 let itemIndex = 0;166
167 return (168 <div className="fixed inset-0 z-50 flex items-start justify-center pt-[min(20vh,140px)] px-4">169 {/* Backdrop */}170 <div171 className={[172 "absolute inset-0 backdrop-blur-[6px]",173 "transition-opacity ease-out",174 visible ? "opacity-100 duration-300" : "opacity-0 duration-200",175 ].join(" ")}176 style={{ background: "var(--scrim)" }}177 aria-hidden178 onClick={onClose}179 />180
181 {/* Palette panel */}182 <div183 ref={dialogRef}184 role="dialog"185 aria-modal="true"186 aria-label="Command palette"187 className={[188 "relative z-10 w-full max-w-lg rounded-[20px] p-1.5 overflow-hidden",189 "transition-[opacity,transform,scale,translate] ease-[var(--ease-spring)]",190 visible191 ? "opacity-100 scale-100 translate-y-0 duration-300"192 : "opacity-0 scale-[0.94] translate-y-3 duration-200",193 className,194 ]195 .filter(Boolean)196 .join(" ")}197 style={{ background: "var(--panel-bezel)", boxShadow: "var(--modal-shadow)" }}198 >199 <div200 className="bg-white rounded-[14px] overflow-hidden"201 style={{ boxShadow: "var(--panel-face-shadow)" }}202 >203 {/* Search input */}204 <div className="flex items-center gap-3 px-4 py-3.5">205 <svg206 className="shrink-0 text-neutral-400"207 width="14"208 height="14"209 fill="none"210 stroke="currentColor"211 strokeWidth="1.75"212 viewBox="0 0 24 24"213 >214 <path215 strokeLinecap="round"216 strokeLinejoin="round"217 d="M21 21l-4.35-4.35M11 19a8 8 0 100-16 8 8 0 000 16z"218 />219 </svg>220 <input221 ref={inputRef}222 type="text"223 value={query}224 onChange={(e) => setQuery(e.target.value)}225 placeholder={placeholder}226 role="combobox"227 aria-expanded="true"228 aria-controls={`${uid}-list`}229 aria-activedescendant={flatItems[activeIndex] ? `${uid}-item-${activeIndex}` : undefined}230 aria-autocomplete="list"231 className="flex-1 min-w-0 text-[14.5px] font-medium text-neutral-900 placeholder:text-neutral-400 placeholder:font-normal bg-transparent border-none outline-none"232 />233 <Kbd className="shrink-0">esc</Kbd>234 </div>235
236 {/* Separator */}237 <div className="border-b border-neutral-100" />238
239 {/* Results */}240 <div241 ref={listRef}242 id={`${uid}-list`}243 role="listbox"244 className="max-h-[340px] overflow-y-auto scroll-thin py-2 px-2"245 >246 {filtered.length === 0 ? (247 <div className="py-10 flex flex-col items-center gap-1 text-center">248 <p className="text-[13px] font-medium text-neutral-700">249 nothing for “{query}”250 </p>251 <p className="text-[12px] text-neutral-400">try a shorter or different word.</p>252 </div>253 ) : (254 filtered.map((section, si) => (255 <div key={section.title} role="group" aria-label={section.title} className={si > 0 ? "mt-3" : ""}>256 <div aria-hidden className="px-3 py-1.5 text-[11.5px] font-medium text-neutral-400 lowercase select-none">257 {section.title}258 </div>259 <div className="flex flex-col gap-0.5">260 {section.items.map((item) => {261 const currentIndex = itemIndex++;262 const isActive = currentIndex === activeIndex;263 return (264 <div265 key={item.key}266 id={`${uid}-item-${currentIndex}`}267 role="option"268 aria-selected={isActive}269 data-command-index={currentIndex}270 onClick={() => handleSelect(item)}271 onMouseMove={() => setActiveIndex(currentIndex)}272 className={[273 "group/cmd flex items-center gap-3 w-full text-left px-3 py-2 rounded-[10px] cursor-pointer",274 "transition-[background,box-shadow] duration-100",275 isActive ? "bg-neutral-50" : "",276 ].join(" ")}277 style={{ boxShadow: isActive ? "var(--panel-face-shadow)" : undefined }}278 >279 {item.icon && (280 <span281 className={[282 "shrink-0 w-7 h-7 rounded-[8px] flex items-center justify-center transition-colors duration-100",283 isActive ? "text-neutral-900" : "text-neutral-500",284 ].join(" ")}285 style={{ background: "var(--face-light)", boxShadow: "var(--face-light-shadow)" }}286 >287 {item.icon}288 </span>289 )}290 <div className="flex-1 min-w-0 flex flex-col">291 <span className="text-[13px] font-medium text-neutral-900 truncate">292 {item.label}293 </span>294 {item.description && (295 <span className="text-[12px] text-neutral-500 truncate">296 {item.description}297 </span>298 )}299 </div>300 {item.shortcut && <Kbd className="shrink-0">{item.shortcut}</Kbd>}301 {!item.shortcut && (302 <svg303 aria-hidden304 width="12"305 height="12"306 fill="none"307 stroke="currentColor"308 strokeWidth="2"309 viewBox="0 0 24 24"310 className={[311 "shrink-0 text-neutral-400 transition-all duration-150",312 isActive ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-1",313 ].join(" ")}314 >315 <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />316 </svg>317 )}318 </div>319 );320 })}321 </div>322 </div>323 ))324 )}325 </div>326
327 {/* Key hints — teach the palette while it's open */}328 <div329 className="flex items-center gap-4 px-4 py-2.5 border-t border-neutral-100 text-[11.5px] text-neutral-500 select-none"330 style={{ background: "linear-gradient(180deg, #fcfcfc 0%, #f8f8f8 100%)" }}331 >332 <span className="inline-flex items-center gap-1.5">333 <Kbd>↑</Kbd>334 <Kbd>↓</Kbd>335 Navigate336 </span>337 <span className="inline-flex items-center gap-1.5">338 <Kbd>↵</Kbd>339 Open340 </span>341 <span className="ml-auto tabular-nums text-neutral-400">342 {flatItems.length} {flatItems.length === 1 ? "result" : "results"}343 </span>344 </div>345 </div>346 </div>347 </div>348 );349}components/ui/Kbd.tsxtsx
1import { ReactNode } from "react";2
3export function Kbd({4 children,5 className = "",6}: {7 children: ReactNode;8 className?: string;9}) {10 return (11 <kbd12 className={`inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-md text-[11px] font-semibold text-neutral-500 ${className}`}13 style={{14 /* A keycap: lit top, solid 1.5px skirt below so it reads as pressable */15 background: "var(--face-light)",16 boxShadow:17 "0 0 3px rgba(17,17,17,0.136), 0 1.5px 1px rgba(17,17,17,0.12), 0 2px 3px -1px rgba(17,17,17,0.1), inset 0 2px 3px -2px rgba(255,255,255,1)",18 textShadow: "0 1px 0 rgba(255,255,255,0.9)",19 }}20 >21 {children}22 </kbd>23 );24}components/ui/useModalBehavior.tstsx
1"use client";2
3import { RefObject, useEffect } from "react";4
5interface ModalBehaviorOptions {6 /** The dialog is logically open */7 open: boolean;8 /** The dialog is mounted and painted — focus can move into it */9 ready: boolean;10 ref: RefObject<HTMLElement | null>;11 onClose: () => void;12 /** Element to focus on open; defaults to the first focusable */13 initialFocus?: RefObject<HTMLElement | null>;14}15
16const FOCUSABLE =17 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';18
19/* Everything a modal surface owes the person using it: Escape closes,20 focus is trapped inside, the page behind doesn't scroll, and focus21 returns to whatever opened it. */22export function useModalBehavior({ open, ready, ref, onClose, initialFocus }: ModalBehaviorOptions) {23 // Escape24 useEffect(() => {25 if (!open) return;26 const onKey = (e: KeyboardEvent) => {27 if (e.key === "Escape") {28 e.stopPropagation();29 onClose();30 }31 };32 document.addEventListener("keydown", onKey);33 return () => document.removeEventListener("keydown", onKey);34 }, [open, onClose]);35
36 // Scroll lock37 useEffect(() => {38 if (!open) return;39 const prev = document.body.style.overflow;40 document.body.style.overflow = "hidden";41 return () => {42 document.body.style.overflow = prev;43 };44 }, [open]);45
46 // Focus trap + restore47 useEffect(() => {48 if (!open || !ready) return;49 const el = ref.current;50 if (!el) return;51
52 const opener = document.activeElement as HTMLElement | null;53 const target = initialFocus?.current ?? el.querySelector<HTMLElement>(FOCUSABLE);54 target?.focus();55
56 const trap = (e: KeyboardEvent) => {57 if (e.key !== "Tab") return;58 const focusable = el.querySelectorAll<HTMLElement>(FOCUSABLE);59 if (focusable.length === 0) return;60 const first = focusable[0];61 const last = focusable[focusable.length - 1];62 if (e.shiftKey && document.activeElement === first) {63 e.preventDefault();64 last.focus();65 } else if (!e.shiftKey && document.activeElement === last) {66 e.preventDefault();67 first.focus();68 }69 };70
71 el.addEventListener("keydown", trap);72 return () => {73 el.removeEventListener("keydown", trap);74 opener?.focus?.();75 };76 }, [open, ready, ref, initialFocus]);77}usage
tsx
import { CommandPalette } from "@blurrow/ui";tsx
<CommandPalette open={open} onClose={() => setOpen(false)} sections={sections} />props
straight from the source, so they're always up to date.
propsts
interface CommandSection { title: string; items: CommandItem[];}
interface CommandPaletteProps { open: boolean; onClose: () => void; sections: CommandSection[]; placeholder?: string; className?: string;}