Search
Installation
npx shadcn@latest add https://uibrik.com/r/search.jsonInstall the packages
npm install @tabler/icons-react motionAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Set up squircle corners
Needs
@squircle/core,@squircle/tailwindcssand a few lines of CSS. See squircle setup.Copy the source into your project
components/uibrik/search/SearchPalette.tsx"use client"; import { EASE_IN_OUT, EASE_OUT } from "./easing"; import BookmarkIcon from "./icons/BookmarkIcon"; import CloseIcon from "./icons/CloseIcon"; import SearchIcon from "./icons/SearchIcon"; import FileIcon from "./icons/FileIcon"; import SettingsIcon from "./icons/SettingsIcon"; import { cn } from "@/lib/utils"; import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react"; import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; // The pill and the palette are one surface. Opening grows it sideways to the // palette width first, then downward to fit its contents; closing runs the // same two steps in reverse. The inner layout is always rendered at the open // width and simply clipped while collapsed, so the header never reflows — // the icon and "Search" placeholder sit exactly where the pill drew them. type ScopeId = "all" | "files" | "saved" | "settings"; type ChipKey = "type" | "date"; type Chip = { key: ChipKey; value: string }; export type SearchItem = { id: string; title: string; scope: Exclude<ScopeId, "all">; type?: "pdf" | "image" | "doc"; date?: "today" | "week" | "older"; }; type Item = SearchItem; // Placeholder icons until the final set lands — swap them here. const SCOPES: { id: ScopeId; label: string; Icon: React.ComponentType<{ className?: string }> }[] = [ { id: "all", label: "All", Icon: SearchIcon }, { id: "files", label: "Files", Icon: FileIcon }, { id: "saved", label: "Saved", Icon: BookmarkIcon }, { id: "settings", label: "Settings", Icon: SettingsIcon }, ]; const ITEM_ICONS: Record<Item["scope"], React.ComponentType<{ className?: string }>> = { files: FileIcon, saved: BookmarkIcon, settings: SettingsIcon, }; const file = (title: string, type: Item["type"], date: Item["date"]) => ({ title, scope: "files" as const, type, date }); const saved = (title: string, type: Item["type"], date: Item["date"]) => ({ title, scope: "saved" as const, type, date }); const setting = (title: string) => ({ title, scope: "settings" as const }); export const SAMPLE_SEARCH_ITEMS: Item[] = [ file("Q3 Roadmap", "pdf", "week"), file("Invoice 0142", "pdf", "today"), file("Invoice 0141", "pdf", "week"), file("Invoice 0139", "pdf", "older"), file("Brand guidelines", "pdf", "older"), file("Contract — Northwind", "pdf", "week"), file("Security review", "pdf", "older"), file("Board deck", "pdf", "week"), file("Annual report", "pdf", "older"), file("Hero shot", "image", "today"), file("Team offsite", "image", "week"), file("App icon exploration", "image", "today"), file("Onboarding illustrations", "image", "older"), file("Pricing page mock", "image", "week"), file("Dashboard dark mode", "image", "today"), file("Product photos", "image", "older"), file("Launch notes", "doc", "today"), file("Research interviews", "doc", "older"), file("Hiring plan", "doc", "week"), file("Weekly sync notes", "doc", "today"), file("API migration guide", "doc", "week"), file("Postmortem — September outage", "doc", "older"), file("Writing style guide", "doc", "older"), file("Quarterly planning", "doc", "week"), saved("Design system audit", "doc", "week"), saved("Onboarding flow", "image", "older"), saved("Pricing page review", "pdf", "today"), saved("Motion principles", "doc", "week"), saved("Competitor teardown", "pdf", "older"), saved("Color tokens", "doc", "today"), saved("Icon grid reference", "image", "week"), saved("Customer quotes", "doc", "older"), saved("Empty state patterns", "image", "today"), setting("Appearance"), setting("Keyboard shortcuts"), setting("Notifications"), setting("Account"), setting("Billing"), setting("Privacy"), setting("Language & region"), setting("Connected apps"), setting("Security"), setting("Members"), setting("Integrations"), setting("Export data"), ].map((item, i) => ({ ...item, id: String(i) })); const SUGGESTED_CHIPS: Chip[] = [ { key: "type", value: "pdf" }, { key: "type", value: "image" }, { key: "type", value: "doc" }, { key: "date", value: "today" }, { key: "date", value: "week" }, ]; const TYPE_LABELS: Record<NonNullable<Item["type"]>, string> = { pdf: "PDF", image: "Image", doc: "Doc", }; const DATE_LABELS: Record<NonNullable<Item["date"]>, string> = { today: "Today", week: "This week", older: "Older", }; const DEFAULT_RECENTS = ["Roadmap", "Invoice", "Appearance"]; // Typing `type:pdf ` (with the trailing space) turns the token into a chip. const CHIP_PATTERN = /(?:^|\s)(type|date):(\S+)\s$/i; // A filter still being typed: the query ends mid-token, before the space. const PENDING_FILTER = /(?:^|\s)(type|date):\S*$/i; const FILTER_WORD = /^(type|date):(.*)$/; const FILTER_VALUES: Record<ChipKey, string[]> = { type: ["pdf", "image", "doc"], date: ["today", "week", "older"], }; const GUTTER = 16; // On open the body fades in this long after the shell starts growing, just // enough that rows don't appear before there's room for them. const BODY_DELAY = 0.05; // Rows past this index enter together, so long lists don't trickle in. const MAX_STAGGER = 8; const sameChip = (a: Chip, b: Chip) => a.key === b.key && a.value === b.value; const chipText = (c: Chip) => `${c.key}:${c.value}`; function hasValue(item: Item, key: ChipKey, value: string) { if (key === "type") return item.type === value; // "This week" includes today. return value === "week" ? item.date === "week" || item.date === "today" : item.date === value; } // Filters typed into the query count before they become chips, so results // never depend on how quickly Space follows. A half-typed value matches as a // prefix (`type:p` → pdf); a bare `type:` doesn't narrow anything yet. function matchesFilter(item: Item, key: ChipKey, value: string, prefix: boolean) { if (prefix && !value) return true; const candidates = FILTER_VALUES[key].filter((v) => (prefix ? v.startsWith(value) : v === value)); return candidates.some((v) => hasValue(item, key, v)); } function matches(item: Item, scope: ScopeId, chips: Chip[], query: string) { if (scope !== "all" && item.scope !== scope) return false; if (!chips.every((chip) => matchesFilter(item, chip.key, chip.value, false))) return false; const title = item.title.toLowerCase(); return query .toLowerCase() .split(/\s+/) .filter(Boolean) .every((word) => { const filter = word.match(FILTER_WORD); if (filter) return matchesFilter(item, filter[1] as ChipKey, filter[2], true); return title.includes(word); }); } function describe(item: Item) { if (item.scope === "settings") return "Settings"; return [item.type && TYPE_LABELS[item.type], item.date && DATE_LABELS[item.date]] .filter(Boolean) .join(" · "); } const isMac = () => typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent); type Row = { kind: "recent"; text: string } | { kind: "result"; item: Item }; type View = { rows: Row[]; hasQuery: boolean; loading: boolean; suggestions: Chip[]; activeIndex: number }; export type SearchPaletteProps = { items?: SearchItem[]; /** Text on the closed pill and in the empty field. */ placeholder?: string; /** Widest the open palette gets, in px. */ maxWidth?: number; /** Milliseconds before results show, so the lens has something to spin for. */ searchDelay?: number; /** With ⌘ / Ctrl, toggles the palette. Empty disables. */ shortcut?: string; /** Recent searches kept. */ maxRecents?: number; /** localStorage key for recent searches. */ recentsKey?: string; onSelect?: (item: SearchItem) => void; className?: string; }; /** A search pill that grows into a palette with scopes, filter chips and results. */ export function SearchPalette({ items: ITEMS = SAMPLE_SEARCH_ITEMS, placeholder = "Search", maxWidth: MAX_WIDTH = 560, searchDelay: SEARCH_DELAY = 500, shortcut = "k", maxRecents: MAX_RECENTS = 3, recentsKey: RECENTS_KEY = "search-19-recents", onSelect, className, }: SearchPaletteProps) { const reduceMotion = useReducedMotion(); const [open, setOpen] = useState(false); // True for the first moments after opening, while the body fades in; // rows wait for it. Later result changes stagger in straight away. const [opening, setOpening] = useState(false); const [hovered, setHovered] = useState(false); const [focusVisible, setFocusVisible] = useState(false); const [openWidth, setOpenWidth] = useState(MAX_WIDTH); const [query, setQuery] = useState(""); const [chips, setChips] = useState<Chip[]>([]); const [scopeIndex, setScopeIndex] = useState(0); const [active, setActive] = useState(0); const [recents, setRecents] = useState<string[]>(DEFAULT_RECENTS); // A recent search mid-flight into the input. const [flying, setFlying] = useState<string | null>(null); const [done, setDone] = useState<{ key: string; results: Item[] }>({ key: "", results: [] }); const rootRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement>(null); const listRef = useRef<HTMLUListElement>(null); const pillRef = useRef<HTMLDivElement>(null); const [pill, setPill] = useState<{ width: number; height: number } | null>(null); const contentRef = useRef<HTMLDivElement>(null); const [contentHeight, setContentHeight] = useState(0); const [iconRef, animateIcon] = useAnimate<HTMLSpanElement>(); const scope = SCOPES[scopeIndex]; const trimmed = query.trim(); const hasQuery = trimmed.length > 0 || chips.length > 0; const searchKey = JSON.stringify([trimmed, chips, scope.id]); const loading = open && hasQuery && done.key !== searchKey; const showShortcut = !open && (hovered || focusVisible); const rows: Row[] = hasQuery ? done.results.map((item) => ({ kind: "result", item })) : recents.map((text) => ({ kind: "recent", text })); const activeIndex = Math.min(active, rows.length - 1); const suggestions = SUGGESTED_CHIPS.filter((s) => !chips.some((c) => sameChip(c, s))); // Closing clears the query at once, so the pill shows its placeholder. The // body keeps what it last showed while it fades and folds away, instead of // swapping results for recents on the way out. const live: View = { rows, hasQuery, loading, suggestions, activeIndex }; const liveRef = useRef(live); useEffect(() => { liveRef.current = live; }); const [frozen, setFrozen] = useState<View>(live); const view = open ? live : frozen; // The collapsed size comes from an invisible copy of the original pill, so // the closed state matches it exactly (and follows font loading). useLayoutEffect(() => { const el = pillRef.current; if (!el) return; const read = () => setPill({ width: el.offsetWidth, height: el.offsetHeight }); read(); const observer = new ResizeObserver(read); observer.observe(el); return () => observer.disconnect(); }, []); // Height of the open palette. The body is always mounted, so this is known // before the first open; the observer follows results arriving and chips // wrapping. useLayoutEffect(() => { const el = contentRef.current; if (!el) return; const read = () => setContentHeight(el.offsetHeight); read(); const observer = new ResizeObserver(read); observer.observe(el); return () => observer.disconnect(); }, [pill]); useEffect(() => { if (!open || !hasQuery) return; const timer = setTimeout(() => { const results = ITEMS.filter((item) => matches(item, scope.id, chips, trimmed)); setDone({ key: searchKey, results }); setActive(0); listRef.current?.scrollTo({ top: 0 }); // No shake while a filter is still being typed — that's not a miss yet. const pending = PENDING_FILTER.test(query); if (results.length === 0 && !pending && iconRef.current && !reduceMotion) { animateIcon(iconRef.current, { x: [0, -3, 3, -2, 2, 0] }, { duration: 0.4, ease: EASE_OUT }); } }, SEARCH_DELAY); return () => clearTimeout(timer); }, [open, hasQuery, searchKey, scope.id, chips, trimmed, query, iconRef, animateIcon, reduceMotion, ITEMS, SEARCH_DELAY]); useEffect(() => { if (!opening) return; const timer = setTimeout(() => setOpening(false), 400); return () => clearTimeout(timer); }, [opening]); const openPalette = useCallback(() => { // Recents only show while open, so read them here rather than on mount. try { const stored = JSON.parse(localStorage.getItem(RECENTS_KEY) ?? "null"); if (Array.isArray(stored)) setRecents(stored.slice(0, MAX_RECENTS)); } catch {} setOpenWidth(Math.min(MAX_WIDTH, window.innerWidth - GUTTER * 2)); setOpening(true); setOpen(true); setFocusVisible(false); inputRef.current?.focus({ preventScroll: true }); }, [MAX_WIDTH, MAX_RECENTS, RECENTS_KEY]); const closePalette = useCallback(() => { setFrozen(liveRef.current); setOpen(false); setOpening(false); setHovered(false); setQuery(""); setChips([]); setScopeIndex(0); setActive(0); setFlying(null); inputRef.current?.blur(); }, []); useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (shortcut && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === shortcut.toLowerCase()) { e.preventDefault(); if (open) closePalette(); else openPalette(); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [open, openPalette, closePalette, shortcut]); useEffect(() => { if (!open) return; const onPointerDown = (e: PointerEvent) => { if (!rootRef.current?.contains(e.target as Node)) closePalette(); }; document.addEventListener("pointerdown", onPointerDown); return () => document.removeEventListener("pointerdown", onPointerDown); }, [open, closePalette]); const saveRecent = (text: string) => { const next = [text, ...recents.filter((r) => r.toLowerCase() !== text.toLowerCase())].slice(0, MAX_RECENTS); setRecents(next); try { localStorage.setItem(RECENTS_KEY, JSON.stringify(next)); } catch {} }; const addChip = (chip: Chip) => { setChips((prev) => (prev.some((c) => sameChip(c, chip)) ? prev : [...prev, chip])); inputRef.current?.focus({ preventScroll: true }); }; const removeChip = (chip: Chip) => { setChips((prev) => prev.filter((c) => !sameChip(c, chip))); inputRef.current?.focus({ preventScroll: true }); }; const selectRow = (row: Row) => { if (row.kind === "recent") { setFlying(row.text); setQuery(row.text); inputRef.current?.focus({ preventScroll: true }); return; } saveRecent(trimmed || row.item.title); onSelect?.(row.item); closePalette(); }; const moveActive = (index: number) => { setActive(index); listRef.current?.querySelector(`[data-index="${index}"]`)?.scrollIntoView({ block: "nearest" }); }; const onChange = (e: React.ChangeEvent<HTMLInputElement>) => { const value = e.target.value; const match = value.match(CHIP_PATTERN); if (match) { addChip({ key: match[1].toLowerCase() as ChipKey, value: match[2].toLowerCase() }); const rest = value.slice(0, value.length - match[0].length); setQuery(rest ? `${rest} ` : ""); return; } setQuery(value); }; const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const input = e.currentTarget; switch (e.key) { case "ArrowDown": case "ArrowUp": { e.preventDefault(); if (!rows.length) return; const step = e.key === "ArrowDown" ? 1 : -1; moveActive((activeIndex + step + rows.length) % rows.length); return; } case "Enter": { e.preventDefault(); const row = rows[activeIndex]; if (row && !loading) selectRow(row); return; } case "Tab": { e.preventDefault(); const step = e.shiftKey ? -1 : 1; setScopeIndex((i) => (i + step + SCOPES.length) % SCOPES.length); return; } case "Escape": { e.preventDefault(); closePalette(); return; } case "Backspace": { // Backspace at the very start hands the last chip back as text. if (input.selectionStart !== 0 || input.selectionEnd !== 0 || !chips.length) return; e.preventDefault(); const last = chips[chips.length - 1]; const text = chipText(last); setChips(chips.slice(0, -1)); setQuery(text + query); requestAnimationFrame(() => input.setSelectionRange(text.length, text.length)); return; } } }; // Width and height move together on one fast-start curve, so the shell // answers the click on the very first frame and opens in one motion. const instant = { duration: 0 }; const sizeTransition = reduceMotion ? instant : open ? { duration: 0.42, ease: EASE_OUT } : { duration: 0.3, ease: EASE_OUT }; // The loading spinner lives in the lens, so every scope hands its slot back // to the magnifier while a search is in flight. const iconKey = loading ? "all" : scope.id; const Icon = loading ? SearchIcon : scope.Icon; // Layered surfaces: the pill is the outer shell. Open, the query row and // filter chips stay on the shell, results sit in an inset panel, and the key // hints run along the shell's foot. Shell 24px → panel 18px → row 12px. const header = "flex items-start gap-2.5 py-1.5 pl-2.5 pr-3"; const text = "text-base leading-6"; return ( <div className={cn("relative", className)} style={pill ?? undefined}> {/* Until measured (server render, first paint) this copy is the pill. */} <div ref={pillRef} aria-hidden="true" className={cn( "whitespace-nowrap p-1.5", pill ? "invisible absolute left-0 top-0" : "rounded-2xl bg-neutral-100 text-zinc-600 dark:bg-neutral-800 dark:text-zinc-300", )} > <div className={header}> <SearchIcon className="mt-0.5 size-5" /> <p className={text}>{placeholder}</p> </div> </div> {pill && ( <AnimatePresence> {showShortcut && shortcut && ( <motion.div className="pointer-events-none absolute left-1/2 flex items-center gap-1" style={{ top: pill.height + 10, x: "-50%" }} initial={{ opacity: 0, y: -4, filter: "blur(2px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, y: -4, filter: "blur(2px)" }} transition={{ duration: reduceMotion ? 0 : 0.2, ease: EASE_OUT }} > <Kbd className="bg-neutral-100 dark:bg-neutral-800">{isMac() ? "⌘" : "Ctrl"}</Kbd> <Kbd className="bg-neutral-100 dark:bg-neutral-800">{shortcut.toUpperCase()}</Kbd> </motion.div> )} </AnimatePresence> )} {pill && ( <motion.div ref={rootRef} onPointerEnter={() => !open && setHovered(true)} onPointerLeave={() => setHovered(false)} className={cn( "absolute left-1/2 top-0 z-10 overflow-hidden rounded-2xl text-zinc-600 dark:text-zinc-300 transition-colors duration-200 ease-out-strong", !open && hovered ? "bg-neutral-200/70 dark:bg-neutral-700/70" : "bg-neutral-100 dark:bg-neutral-800", )} style={{ x: "-50%" }} initial={false} animate={{ width: open ? openWidth : pill.width, height: open ? contentHeight || pill.height : pill.height, }} transition={{ width: sizeTransition, height: sizeTransition, }} > <div ref={contentRef} className="p-1.5" style={{ width: openWidth }}> <div className={header}> <span ref={iconRef} className={cn("relative mt-0.5 size-5 shrink-0 transition-colors", (open || hovered) && "text-zinc-900 dark:text-zinc-100")}> <AnimatePresence initial={false}> <motion.span key={iconKey} className="absolute inset-0" initial={{ opacity: 0, scale: 0.8, filter: "blur(2px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.8, filter: "blur(2px)" }} transition={{ duration: 0.2, ease: EASE_OUT }} > {iconKey === "all" ? <SearchIcon className="size-5" loading={loading} /> : <Icon className="size-5" />} </motion.span> </AnimatePresence> </span> {/* Label and chips grow from zero width instead of popping in, so the input is pushed by real layout, frame by frame, and can never slide underneath them. Spacing lives inside each grown item (not a flex gap) so nothing jumps before it opens. */} <div className="flex min-w-0 flex-1 flex-wrap items-center gap-y-1.5"> <AnimatePresence initial={false}> {scope.id !== "all" && ( <Grow key={scope.id} reduceMotion={reduceMotion}> <span className={cn("block pr-2 font-medium text-zinc-900 dark:text-zinc-100", text)}>{scope.label}</span> </Grow> )} {chips.map((chip) => ( <Grow key={chipText(chip)} reduceMotion={reduceMotion}> <span className="block pr-1.5"> <span onMouseDown={(e) => e.preventDefault()} className={cn(chipClass, "group/chip cursor-pointer select-none")}> <span> <span className="text-zinc-400">{chip.key}:</span> {chip.value} </span> {/* The remove slot opens from zero width on hover, so the chip grows instead of the × covering its text. */} <span className="grid grid-cols-[0fr] transition-[grid-template-columns] duration-200 ease-out-strong group-hover/chip:grid-cols-[1fr]"> <span className="overflow-hidden"> <button type="button" tabIndex={-1} aria-label={`Remove ${chipText(chip)}`} onMouseDown={(e) => e.preventDefault()} onClick={() => removeChip(chip)} className="ml-1 flex size-4 cursor-pointer items-center justify-center text-zinc-400 opacity-0 transition-[opacity,color] duration-200 ease-out-strong hover:text-zinc-800 group-hover/chip:opacity-100 dark:hover:text-zinc-100" > <CloseIcon className="size-3" /> </button> </span> </span> </span> </span> </Grow> ))} </AnimatePresence> <div className="relative min-w-32 flex-1"> <input ref={inputRef} value={query} onChange={onChange} onKeyDown={onKeyDown} tabIndex={open ? 0 : -1} placeholder={placeholder} aria-label="Search" aria-keyshortcuts="Meta+K" autoComplete="off" spellCheck={false} className={cn( "h-6 w-full bg-transparent p-0 text-zinc-900 outline-none dark:text-zinc-100 transition-colors duration-200 ease-out-strong placeholder:transition-colors", text, !open ? hovered ? "placeholder:text-zinc-800 dark:placeholder:text-zinc-100" : "placeholder:text-zinc-600 dark:placeholder:text-zinc-300" : "placeholder:text-zinc-400 dark:placeholder:text-zinc-500", flying && "text-transparent dark:text-transparent", )} /> {flying && ( <motion.span layoutId={`search-19-recent-${flying}`} className={cn("pointer-events-none absolute left-0 top-0 whitespace-pre text-zinc-900 dark:text-zinc-100", text)} transition={{ duration: reduceMotion ? 0 : 0.36, ease: EASE_IN_OUT }} onLayoutAnimationComplete={() => setFlying(null)} > {flying} </motion.span> )} </div> </div> </div> {/* The body stays mounted while closed (clipped, hidden and inert), so opening only retargets the shell's size: no mount work delays the first frame, and its height is already known. */} <motion.div inert={!open} aria-hidden={!open} initial={false} animate={{ opacity: open ? 1 : 0 }} transition={ open ? { duration: 0.22, ease: EASE_OUT, delay: reduceMotion ? 0 : BODY_DELAY } : { duration: 0.12, ease: EASE_OUT } } > {view.suggestions.length > 0 && ( <div className="flex flex-wrap gap-1.5 px-2.5 pb-2.5 pt-1"> {view.suggestions.map((chip) => ( <motion.button key={chipText(chip)} layout="position" type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => addChip(chip)} className={cn( chipClass, "cursor-pointer select-none outline-none transition-opacity duration-200 ease-out-strong hover:opacity-70 focus-visible:ring-2 focus-visible:ring-zinc-400", )} transition={{ duration: 0.24, ease: EASE_OUT }} > <span className="text-zinc-400">{chip.key}:</span> {chip.value} </motion.button> ))} </div> )} <div> {!view.hasQuery && view.rows.length > 0 && ( <p className="px-2.5 pb-1.5 pt-1 text-xs font-medium text-zinc-400 dark:text-zinc-500">Recent</p> )} <motion.ul ref={listRef} layoutScroll role="listbox" aria-label={view.hasQuery ? "Results" : "Recent searches"} className="scrollbar-soft max-h-80 overflow-y-auto" > {view.rows.map((row, i) => { const selected = i === view.activeIndex; const key = row.kind === "recent" ? `recent-${row.text}` : row.item.id; const RowIcon = row.kind === "recent" ? SearchIcon : ITEM_ICONS[row.item.scope]; return ( <motion.li key={key} data-index={i} role="option" aria-selected={selected} onMouseMove={() => active !== i && setActive(i)} onMouseDown={(e) => e.preventDefault()} onClick={() => selectRow(row)} className="relative flex h-10 cursor-pointer items-center gap-3 px-2.5" // Rows reset out of sight once the body has faded, then stagger // back in on the next open; no remount needed. initial={{ opacity: 0, y: row.kind === "recent" ? -6 : 4 }} animate={open ? { opacity: 1, y: 0 } : { opacity: 0, y: row.kind === "recent" ? -6 : 4 }} transition={ open ? { duration: 0.24, ease: EASE_OUT, delay: reduceMotion ? 0 : (opening ? BODY_DELAY : 0) + Math.min(i, MAX_STAGGER) * (row.kind === "recent" ? 0.04 : 0.03), } : { duration: 0, delay: 0.15 } } > {selected && ( <motion.div layoutId="search-19-highlight" className="absolute inset-0" transition={{ duration: reduceMotion ? 0 : 0.22, ease: EASE_OUT }} > <div className="size-full bg-white dark:bg-neutral-700 squircle-mask squircle-xl" /> </motion.div> )} <RowIcon className={cn("relative z-[2] size-4 shrink-0 transition-colors", selected ? "text-zinc-800 dark:text-zinc-100" : "text-zinc-500 dark:text-zinc-400")} /> {row.kind === "recent" ? ( <motion.span layoutId={`search-19-recent-${row.text}`} className="relative z-[2] flex-1 truncate text-sm text-zinc-800 dark:text-zinc-100" > {row.text} </motion.span> ) : ( <> <span className="relative z-[2] flex-1 truncate text-sm text-zinc-800 dark:text-zinc-100">{row.item.title}</span> <span className="relative z-[2] shrink-0 text-[13px] text-zinc-400 dark:text-zinc-500">{describe(row.item)}</span> </> )} </motion.li> ); })} </motion.ul> {view.hasQuery && !view.loading && view.rows.length === 0 && ( <p className="px-4 py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">No results</p> )} </div> <div className="flex flex-wrap gap-x-4 gap-y-1 px-2.5 pb-1 pt-2.5 text-xs text-zinc-400 dark:text-zinc-500"> <Hint keys="Tab" label="Scope" /> <Hint keys="↑↓" label="Navigate" /> <Hint keys="↵" label="Open" /> <Hint keys="Esc" label="Close" /> </div> </motion.div> </div> {!open && ( <button type="button" // Opens on press rather than release, so the palette answers the // instant the pointer goes down; click still covers Enter / Space. onPointerDown={(e) => { if (e.button !== 0) return; e.preventDefault(); openPalette(); }} onClick={openPalette} onFocus={(e) => setFocusVisible(e.currentTarget.matches(":focus-visible"))} onBlur={() => setFocusVisible(false)} aria-label="Search" aria-keyshortcuts="Meta+K" className="absolute inset-0 cursor-pointer rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-zinc-400" /> )} </motion.div> )} </div> ); } const chipClass = "flex h-6 items-center bg-white dark:bg-neutral-700 squircle-mask squircle-lg px-2 text-[13px] font-medium leading-6 text-zinc-800 dark:text-zinc-100"; // Opens from zero width to its natural width (and back on exit), clipping its // contents meanwhile, so siblings after it are pushed smoothly by layout. const Grow = ({ children, reduceMotion }: { children: React.ReactNode; reduceMotion: boolean | null }) => ( <motion.span className="block shrink-0 overflow-hidden whitespace-nowrap" initial={{ width: 0, opacity: 0 }} animate={{ width: "auto", opacity: 1 }} exit={{ width: 0, opacity: 0 }} transition={{ width: { duration: reduceMotion ? 0 : 0.26, ease: EASE_OUT }, opacity: { duration: reduceMotion ? 0 : 0.18, ease: EASE_OUT }, }} > {children} </motion.span> ); const Kbd = ({ children, className }: { children: React.ReactNode; className?: string }) => ( <kbd className={cn( "bg-white dark:bg-neutral-700 squircle-mask squircle-md px-1.5 font-sans text-[11px] font-medium leading-[18px] text-zinc-500 dark:text-zinc-300", className, )} > {children} </kbd> ); const Hint = ({ keys, label }: { keys: string; label: string }) => ( <span className="flex items-center gap-1.5"> <Kbd>{keys}</Kbd> {label} </span> );
Adds the files to
components/uibrik/search and installs @tabler/icons-react and motion. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/search.Props
SearchPalette- placeholder
- Type string
- "Search"
- Text on the closed pill and in the empty field.
- maxWidth
- Type number
- 560
- Widest the open palette gets.
- searchDelay
- Type number
- 500
- Wait before results show.
- shortcut
- Type string
- "k"
- Key that toggles it with ⌘ / Ctrl. Empty disables.
- maxRecents
- Type number
- 3
- Recent searches kept.
- items
- Type SearchItem[]
- SAMPLE_SEARCH_ITEMS
- Searchable items: id, title, scope, type and date.
- onSelect
- Type (item: SearchItem) => void
- –
- Called when a result is picked.
- recentsKey
- Type string
- "search-19-recents"
- localStorage key for recent searches.
- className
- Type string
- –
- Classes for the wrapper.