Skip to content

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.json

From the Denzel template, see it in context.

Installation

$ npx shadcn@latest add https://framebrick.co/r/search-dialog.json

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

PropTypeDefaultDescription
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.
labelstring"Search"The dialog's accessible name, also the field's.
placeholderstring"Search…"The field's placeholder.
childrenReactNode—The button's content. A magnifier and "Search" by default.
classNamestring—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.

Be the first to know

Get new templates and components first

One short email when we release a template or a free component. No spam — unsubscribe anytime.

We use your email only for Framebrick updates. Privacy policy