Search Dialog
A button that opens a search in a modal: type, and the pages that hold every word are listed under the field.
$npx shadcn@latest add https://framebrick.co/r/search-dialog.jsonFrom the Denzel template, see it in context.
import { SearchDialog } from "@/components/ui/search-dialog";
const pages = [
{ title: "Getting started", href: "#", meta: "/docs/getting-started", keywords: "install setup begin" },
{ title: "Pricing", href: "#", meta: "/pricing", keywords: "plans cost billing" },
{ title: "Changelog", href: "#", meta: "/changelog", keywords: "releases updates news" },
{ title: "Integrations", href: "#", meta: "/docs/integrations", keywords: "connect apps api" },
{ title: "Keyboard shortcuts", href: "#", meta: "/docs/shortcuts", keywords: "keys hotkeys" },
{ title: "Customer stories", href: "#", meta: "/stories", keywords: "case studies testimonials" },
{ title: "Security", href: "#", meta: "/security", keywords: "privacy encryption" },
{ title: "Contact support", href: "#", meta: "/support", keywords: "help email chat" },
{ title: "Roadmap", href: "#", meta: "/roadmap", keywords: "plans future features" },
{ title: "Accessibility", href: "#", meta: "/accessibility", keywords: "a11y screen reader" },
{ title: "Brand assets", href: "#", meta: "/press/brand", keywords: "logo press kit" },
{ title: "Careers", href: "#", meta: "/careers", keywords: "jobs hiring" },
];
/* Press the button (or tab to it and press Enter): type a word, move with the arrows, press Enter; Escape closes. */
export default function SearchDialogDemo() {
return <SearchDialog items={pages} label="Search the site" placeholder="Search pages…" />;
}Installation
$ npx shadcn@latest add https://framebrick.co/r/search-dialog.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/search-dialog.tsx."use client"; import * as React from "react"; import Link from "next/link"; import { motion, type Transition } from "motion/react"; import { cn } from "@/lib/utils"; /* * Search Dialog — a site search in a modal: a button opens a field, and the pages that hold every word you type are listed * under it as you type (from the Denzel and Titen search panels, the second template with one). * * Framer's: from a tablet up (Denzel 810px; here shadcn's sm, 640px) a 500px panel 21.7% of the window's height from the * top (195px at 900) grows from 95% on a spring (stiffness 600, damping 40, mass 1) while it fades in with the scrim, and * closing runs back. On a phone it is a bar as wide as the screen with square corners at the top that drops 10px and fades in * over 0.15s (Denzel's spec says from 20% opacity, its port from 0; this starts from 0). The scale and the drop are transforms, * which <MotionConfig reducedMotion="user"> skips; the fade is opacity, which stays with reduced motion, as the 0.2s fade of * the Titen panel does. Which of the two sets of values runs is read from matchMedia when the dialog opens: only the animated * values depend on it, never the markup, and the dialog's contents exist only while it is open. * * It is a native <dialog> opened with showModal(), so the focus stays inside, the page behind is inert, Escape closes it and * the focus returns to the button. Escape, a click on the scrim and a click on a result all close it with the closing * animation first. The page does not scroll behind it (the scrollbar's space is kept, so nothing shifts). * * The field is a combobox over a listbox: the arrows move the highlight (the first result starts highlighted, the pointer * moves it too), Enter opens the highlighted page, and a polite status says how many pages match once typing pauses. A page * matches when every word typed is in its title, meta or keywords, ignoring case and accents; title matches come first. * Results are links (next/link). No cmdk: it is the one file, plus motion. */ const grow: Transition = { type: "spring", stiffness: 600, damping: 40, mass: 1 }; const fade: Transition = { duration: 0.15 }; export type SearchItem = { title: string; href: string; /** Shown under the title: a path, a summary. It is searched too. */ meta?: string; /** Words that find the page without being shown. */ keywords?: string; }; export type SearchDialogProps = { items: SearchItem[]; /** The dialog's accessible name, also the field's. */ label?: string; placeholder?: string; /** The button's content: a magnifier and "Search" by default. */ children?: React.ReactNode; /** Classes for the button. */ className?: string; }; function fold(text: string) { return text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase(); } function find(items: SearchItem[], query: string) { const words = fold(query).split(/\s+/).filter(Boolean); if (!words.length) return []; const holds = (text: string) => { const folded = fold(text); return words.every((word) => folded.includes(word)); }; const byTitle = items.filter((item) => holds(item.title)); return [...byTitle, ...items.filter((item) => !byTitle.includes(item) && holds(`${item.title} ${item.meta ?? ""} ${item.keywords ?? ""}`))]; } function Magnifier({ className }: { className?: string }) { return ( <svg aria-hidden viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" className={className}> <circle cx="9" cy="9" r="5.5" /> <path d="M13 13l4 4" /> </svg> ); } export function SearchDialog({ items, label = "Search", placeholder = "Search…", children, className }: SearchDialogProps) { const dialog = React.useRef<HTMLDialogElement>(null); const [mounted, setMounted] = React.useState(false); const [shown, setShown] = React.useState(false); const wasShown = React.useRef(false); const [wide, setWide] = React.useState(true); const [query, setQuery] = React.useState(""); const [active, setActive] = React.useState(0); const [announced, setAnnounced] = React.useState(""); const field = React.useRef<HTMLInputElement>(null); const links = React.useRef<(HTMLAnchorElement | null)[]>([]); const id = React.useId(); const results = React.useMemo(() => find(items, query), [items, query]); const listId = `${id}-results`; const optionId = (i: number) => `${id}-option-${i}`; function open() { setWide(window.matchMedia("(min-width: 640px)").matches); setQuery(""); setActive(0); setMounted(true); } /* Open as soon as the contents exist; the animation starts one frame later. */ React.useEffect(() => { if (!mounted) return; const element = dialog.current; if (element && !element.open) element.showModal(); const frame = requestAnimationFrame(() => { wasShown.current = true; setShown(true); }); const root = document.documentElement; const previous = { overflow: root.style.overflow, gutter: root.style.scrollbarGutter }; root.style.scrollbarGutter = "stable"; root.style.overflow = "hidden"; return () => { cancelAnimationFrame(frame); root.style.overflow = previous.overflow; root.style.scrollbarGutter = previous.gutter; }; }, [mounted]); /* Closing: run the animation back, then close the dialog (which returns the focus to the button). */ const requestClose = React.useCallback(() => setShown(false), []); function onExited() { if (shown || !wasShown.current) return; wasShown.current = false; dialog.current?.close(); } /* Say how many pages match once typing pauses. */ React.useEffect(() => { const timer = window.setTimeout(() => { const words = query.trim(); setAnnounced(!words ? "" : results.length === 0 ? "No results" : results.length === 1 ? "1 result" : `${results.length} results`); }, 400); return () => window.clearTimeout(timer); }, [query, results.length]); function onKeyDown(event: React.KeyboardEvent) { /* Escape closes at once: a search field would otherwise use its first Escape to clear itself. */ if (event.key === "Escape") { event.preventDefault(); requestClose(); } else if (event.key === "ArrowDown" && results.length) { event.preventDefault(); setActive((i) => (i + 1) % results.length); } else if (event.key === "ArrowUp" && results.length) { event.preventDefault(); setActive((i) => (i - 1 + results.length) % results.length); } else if (event.key === "Enter" && event.target === field.current && results[active]) { event.preventDefault(); links.current[active]?.click(); } } const panel = wide ? { opacity: 0, scale: 0.95 } : { opacity: 0, y: -10 }; return ( <> <button type="button" onClick={open} aria-haspopup="dialog" aria-expanded={mounted} className={cn( "inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border bg-background px-3 text-sm font-medium shadow-xs outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50", className, )} > {children ?? ( <> <Magnifier className="size-4" /> Search </> )} </button> <dialog ref={dialog} aria-label={label} onCancel={(event) => { event.preventDefault(); requestClose(); }} onClose={() => { setMounted(false); setShown(false); }} className="fixed inset-0 m-0 size-full max-h-none max-w-none bg-transparent p-0 text-foreground backdrop:bg-transparent" > {mounted && ( <div className="flex size-full items-start justify-center pt-0 sm:pt-[21.7vh]" onKeyDown={onKeyDown}> <motion.div aria-hidden initial={{ opacity: 0 }} animate={{ opacity: shown ? 1 : 0 }} transition={fade} onClick={requestClose} className="absolute inset-0 bg-black/50" /> <motion.div initial={panel} animate={shown ? { opacity: 1, scale: 1, y: 0 } : panel} transition={wide ? { ...grow, opacity: fade } : fade} onAnimationComplete={onExited} className="relative flex max-h-[calc(100vh-2rem)] w-full flex-col overflow-hidden border-b bg-background shadow-lg sm:w-[min(500px,calc(100%-2rem))] sm:rounded-xl sm:border" > <div className="flex items-center gap-2 border-b px-3"> <Magnifier className="size-[18px] shrink-0 text-muted-foreground" /> <input ref={field} autoFocus type="search" role="combobox" aria-label={label} aria-expanded={results.length > 0} aria-controls={listId} aria-autocomplete="list" aria-activedescendant={results.length ? optionId(active) : undefined} placeholder={placeholder} value={query} onChange={(event) => { setQuery(event.target.value); setActive(0); }} className="h-12 min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:hidden" /> {query && ( <button type="button" onClick={() => { setQuery(""); setActive(0); field.current?.focus(); }} aria-label="Clear" className="grid size-6 shrink-0 cursor-pointer place-items-center rounded-full bg-muted text-muted-foreground outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50" > <svg aria-hidden viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" className="size-3"> <path d="M4 4l8 8M12 4l-8 8" /> </svg> </button> )} </div> <ul id={listId} role="listbox" aria-label={label} className={cn("max-h-[min(24rem,55vh)] overflow-y-auto p-1.5", !results.length && "hidden")}> {results.map((item, i) => ( <li key={`${i}-${item.href}`} role="presentation"> <Link ref={(el) => { links.current[i] = el; }} href={item.href} id={optionId(i)} role="option" aria-selected={i === active} tabIndex={-1} onClick={requestClose} onMouseMove={() => setActive(i)} className={cn("flex flex-col gap-0.5 rounded-md px-3 py-2.5 outline-none", i === active && "bg-muted")} > <span className="truncate text-sm font-medium">{item.title}</span> {item.meta && <span className="truncate text-sm text-foreground/75">{item.meta}</span>} </Link> </li> ))} </ul> {query.trim() && results.length === 0 && <p className="px-4 py-6 text-center text-sm text-foreground/75">No results</p>} <p role="status" className="sr-only"> {announced} </p> </motion.div> </div> )} </dialog> </> ); }
Usage
import { SearchDialog } from "@/components/ui/search-dialog";
<SearchDialog
label="Search the site"
items={pages.map((page) => ({ title: page.title, href: page.path, meta: page.path, keywords: page.tags }))}
/>
// your own button
<SearchDialog items={pages}>Find a page</SearchDialog>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { title, href, meta?, keywords? }[] | — | The pages. A page matches when every word typed is in its title, meta or keywords, ignoring case and accents; title matches come first. meta is shown under the title. |
| label | string | "Search" | The dialog's accessible name, also the field's. |
| placeholder | string | "Search…" | The field's placeholder. |
| children | ReactNode | — | The button's content. A magnifier and "Search" by default. |
| className | string | — | Classes for the button. |
- It is the Denzel search (Titen has the same panel): from a tablet up a 500 px panel 21.7% of the window's height from the top grows from 95% on a spring (stiffness 600, damping 40, mass 1) while it fades in with the scrim; on a phone it is a bar as wide as the screen with square corners that drops 10 px and fades in over 0.15 s. Closing runs back. Denzel's tablet starts at 810 px; here it is shadcn's sm, 640 px.
- The scale and the drop are transforms, which <MotionConfig reducedMotion="user"> skips; the fade is opacity and stays with reduced motion, as the fade of Titen's panel does. Which set of values runs is read from matchMedia when the dialog opens; only the animated values depend on it, never the markup, and the dialog's contents exist only while it is open.
- It is a native <dialog> opened with showModal(): the focus stays inside, the page behind is inert, Escape closes it and the focus returns to the button. Escape, a click on the scrim and a click on a result all close it after the closing animation. The page does not scroll behind it, and the scrollbar's space is kept so nothing shifts.
- The field is a combobox over a listbox (aria-controls, aria-activedescendant): the arrows move the highlight, the first result starts highlighted, the pointer moves it too, Enter opens it, and a polite status says how many pages match once typing pauses. Results are next/link links.
- No cmdk and no shadcn Command: this is a page search with its own matching and no commands, in one file with motion. Secondary text sits in text-foreground/75, which keeps 4.5:1 on the highlighted row in the sample themes.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS + Motion (motion/react) component called SearchDialog, a client component.
- Props: items ({ title, href, meta?, keywords? }[]), label ("Search"), placeholder, children (the button's content; a magnifier and "Search" by default), className.
- A button (h-9, rounded-md, border, bg-background, text-sm font-medium, shadow-xs, ring-[3px] ring-ring/50 on focus-visible, aria-haspopup="dialog") opens a native <dialog> (fixed inset-0, transparent, no margin, aria-label from label) with showModal(). Its contents exist only while open. The scrollbar-gutter is stable and the root's overflow hidden while it is open.
- Inside: a scrim (absolute inset-0 bg-black/50, motion opacity) and a panel (relative, flex column, bg-background, shadow-lg; from sm: w-[min(500px,calc(100%-2rem))], rounded-xl, border, placed 21.7vh from the top; below sm: full width, square, at the top). From sm it animates opacity 0 to 1 and scale 0.95 to 1 with a spring (stiffness 600, damping 40, mass 1; opacity as a 0.15s tween); below sm opacity 0 to 1 and y -10 to 0 over 0.15s. Read matchMedia("(min-width: 640px)") when opening. Closing animates back, then calls dialog.close(): Escape (the cancel event, prevented), a click on the scrim and a click on a result all start it. The focus returns to the button.
- The panel: a row with a magnifier, an input (type search, role combobox, aria-controls the listbox, aria-activedescendant, aria-autocomplete list, text-base, autofocus) and a round clear button that shows when there is text; then a listbox (max-h 24rem, overflow-y-auto, p-1.5) of options, each a next/link (tabIndex -1) with the title (text-sm font-medium, truncated) over the meta (text-sm text-foreground/75, truncated). The highlighted row is bg-muted; the first starts highlighted, ArrowUp and ArrowDown move it (wrapping), the pointer moves it, Enter clicks it. "No results" when there is text and no match; a role="status" sr-only region says "n results" 400ms after typing stops.
- Matching: split the query into words, fold accents (normalize NFD, strip U+0300 to U+036F) and case, and a page matches when every word is in its title, meta or keywords; pages matching in the title come first. Nothing is listed for an empty query.










