SearchDialog
A centred, focus-trapped command palette: a trigger opens a query field over a keyboard-navigated list of hits, with a footer of key hints. The caller runs the search and feeds the results, so the dialog stays index-agnostic.
Basic
A trigger — commonly a Button — opens SearchDialog.Root, which owns the open state, the controlled query, and the keyboard-driven active row. Compose a SearchDialog.Input for the query, a SearchDialog.Results listing one SearchDialog.Item per hit, and a SearchDialog.Footer of SearchDialog.Hints. The root reports onSelect when a row is clicked or opened with Enter; here it just closes the dialog.
"use client";
import { Button, Kbd, SearchDialog, type SearchResult } from "@stridge/noctis";
import { Search } from "lucide-react";
import { useState } from "react";
const RESULTS: SearchResult[] = [
{
id: "/docs/foundations/color",
title: "Color",
crumb: "/docs/foundations/color",
excerpt: "Neutrals carry the interface and a single chromatic <mark>accent</mark> is held in reserve for signal.",
},
{
id: "/docs/foundations/layers",
title: "Layers",
crumb: "/docs/foundations/layers",
excerpt: "Each scope re-runs the whole ramp at a shifted base; read down a column to see one shade.",
},
{
id: "/docs/components/button",
title: "Button",
crumb: "/docs/components/button",
excerpt: "Variants, sizes, and leading or trailing icons over the shared control rhythm.",
},
];
export default function SearchDialogBasic() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("accent");
return (
<>
<Button variant="secondary" startIcon={Search} onClick={() => setOpen(true)}>
Search docs
</Button>
<SearchDialog.Root
open={open}
onOpenChange={setOpen}
query={query}
onQueryChange={setQuery}
results={RESULTS}
onSelect={() => setOpen(false)}
>
<SearchDialog.Input placeholder="Search…" />
<SearchDialog.Empty searching="Searching…" noResults="No results for">
Type to search…
</SearchDialog.Empty>
<SearchDialog.Results>
{RESULTS.map((result, index) => (
<SearchDialog.Item key={result.id} result={result} index={index} />
))}
</SearchDialog.Results>
<SearchDialog.Footer>
<SearchDialog.Hint label="Navigate">
<Kbd keys="ArrowUp" />
<Kbd keys="ArrowDown" />
</SearchDialog.Hint>
<SearchDialog.Hint label="Open">
<Kbd keys="Enter" />
</SearchDialog.Hint>
<SearchDialog.Hint label="Close">
<Kbd keys="Escape" />
</SearchDialog.Hint>
</SearchDialog.Footer>
</SearchDialog.Root>
</>
);
}
Empty state
The result region shows one of several panes, gated on the dialog state in a fixed priority. SearchDialog.Empty supplies the copy for three of them: the empty-query prompt (children, before anything is typed), the in-flight searching line, and the noResults line — which receives the trimmed query so it can quote the term back. Feed an empty results array with a settled query to land on the no-results pane.
"use client";
import { Button, Kbd, SearchDialog } from "@stridge/noctis";
import { Search } from "lucide-react";
import { useState } from "react";
export default function SearchDialogEmptyState() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("quasar");
return (
<>
<Button variant="secondary" startIcon={Search} onClick={() => setOpen(true)}>
Search docs
</Button>
<SearchDialog.Root
open={open}
onOpenChange={setOpen}
query={query}
onQueryChange={setQuery}
results={[]}
onSelect={() => setOpen(false)}
>
<SearchDialog.Input placeholder="Search…" />
<SearchDialog.Empty searching="Searching…" noResults="No results for">
Type to search…
</SearchDialog.Empty>
<SearchDialog.Results />
<SearchDialog.Footer>
<SearchDialog.Hint label="Navigate">
<Kbd keys="ArrowUp" />
<Kbd keys="ArrowDown" />
</SearchDialog.Hint>
<SearchDialog.Hint label="Open">
<Kbd keys="Enter" />
</SearchDialog.Hint>
<SearchDialog.Hint label="Close">
<Kbd keys="Escape" />
</SearchDialog.Hint>
</SearchDialog.Footer>
</SearchDialog.Root>
</>
);
}
Anatomy
Compose the dialog from its parts. SearchDialog.Root owns the open state, the controlled query, the settled results, and the keyboard-driven active row, and shares them with every part through context — so a part rendered outside the root throws a named error.
SearchDialog.Root— the chassis and the keyboard contract, built on Base UI'sDialog(focus trap, dismissal) inside a portal. Props:open/onOpenChange, the controlledquery/onQueryChange,results,onSelect, theloadingandsearchingflags, and an accessibletitle(defaultSearch).SearchDialog.Input— the query field, with a leading search glyph, an in-flight spinner, and a decorativeEscclose chip. It auto-focuses when the dialog opens and forwards arrow / Enter keys to the root.SearchDialog.Results— therole="listbox"container; renders nothing unless there are results to show.SearchDialog.Item— onerole="option"row: itstitleopposite acrumb(ormethod+path), over an optional<mark>-highlightedexcerpt. Pass the hit asresultand its zero-basedindex; pointer hover makes it active, click chooses it.SearchDialog.Loading— the index-loading copy, shown before any query can run; renders nothing once the index is ready.SearchDialog.Empty— the empty-query,searching, andnoResultscopy; the root gates which one shows, so it renders nothing while results are present.SearchDialog.Footer+SearchDialog.Hint— the key-hints row and one hint each, a keys cluster (composeKbds) beside itslabel.
All copy arrives as part children, so a consuming app routes it through its own i18n. Every rendered part carries a data-slot (noctis-search-dialog, noctis-search-dialog-header, noctis-search-dialog-input, noctis-search-dialog-results, noctis-search-dialog-result, noctis-search-dialog-footer) for host-side styling — the row's resting highlight keys off the aria-selected axis, and its scroll-into-view off data-index. Per-part prop types are exposed through the matching namespace — e.g. SearchDialog.Item.Props.
Keyboard
| Key | Action |
|---|---|
| ↓ / ↑ | Move the active row down / up. The active row stays scrolled into view. |
| Enter | Open the active row — calls onSelect with its result and index. |
| Esc | Close the dialog and return focus to the trigger. |
The field auto-focuses on open, so typing filters immediately without a tab in. Arrow and Enter handling lives on the input; Esc is Base UI's Dialog dismissal. A fresh result set always re-highlights the top row.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every search dialog in that region retunes — e.g. .brand { --noctis-search-dialog-result-background-color-highlighted: var(--noctis-color-accent-muted); } recolors the active row. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI list just the props they pass through.