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 } from "@stridge/noctis/button";
import { Kbd } from "@stridge/noctis/kbd";
import { SearchDialog } from "@stridge/noctis/search-dialog";
import type { SearchResult } from "@stridge/noctis/search-dialog";
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 } from "@stridge/noctis/button";
import { Kbd } from "@stridge/noctis/kbd";
import { SearchDialog } from "@stridge/noctis/search-dialog";
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>
</>
);
}
Theme scope
ThemeScope re-tunes a whole region's shape — corner radius, density, and type scale — through the cascade, with no per-component prop and without touching the app-wide theme. Tweak the knobs below, then open the search dialog: the floating panel re-tunes too, even though it portals to <body>, because the scope rides a portal-safe context. See Customization for nesting, reset, and the raw-token portal caveat.
"use client";
import { DENSITY_PRESETS, FONT_SCALE_PRESETS, RADIUS_PRESETS, ThemeScope } from "@stridge/noctis";
import { Button } from "@stridge/noctis/button";
import { Kbd } from "@stridge/noctis/kbd";
import { SearchDialog } from "@stridge/noctis/search-dialog";
import type { SearchResult } from "@stridge/noctis/search-dialog";
import { Select } from "@stridge/noctis/select";
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.",
},
];
/** Title-case a preset key for its select label. */
const label = (key: string) => key.charAt(0).toUpperCase() + key.slice(1);
/** One labelled preset picker — its options are a shipped `ThemeScope` preset map, so the playground's
* vocab is exactly what the primitive accepts. */
function Knob({
name,
presets,
value,
onChange,
}: {
name: string;
presets: Record<string, number>;
value: string;
onChange: (value: string) => void;
}) {
const keys = Object.keys(presets);
const items = Object.fromEntries(keys.map((key) => [key, label(key)]));
return (
<label className="flex flex-col gap-1.5">
<span className="text-mini font-medium text-subtle">{name}</span>
<Select.Root items={items} value={value} onValueChange={(next) => onChange(String(next))}>
<Select.Trigger aria-label={name} className="w-40">
<Select.Value />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
{keys.map((key) => (
<Select.Item key={key} value={key}>
{label(key)}
</Select.Item>
))}
</Select.Popup>
</Select.Root>
</label>
);
}
/**
* A live `ThemeScope` playground for a floating component: the three seed knobs drive a scope wrapping
* the search trigger. Open the dialog and the palette re-tunes too — corners, spacing, and text size —
* even though it portals out to `<body>`, because the scope rides a portal-safe context, not just the
* DOM cascade.
*/
export default function SearchDialogThemeScope() {
const [radius, setRadius] = useState<keyof typeof RADIUS_PRESETS>("pill");
const [density, setDensity] = useState<keyof typeof DENSITY_PRESETS>("default");
const [fontScale, setFontScale] = useState<keyof typeof FONT_SCALE_PRESETS>("default");
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("accent");
return (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap gap-3">
<Knob name="Radius" presets={RADIUS_PRESETS} value={radius} onChange={(v) => setRadius(v as typeof radius)} />
<Knob
name="Density"
presets={DENSITY_PRESETS}
value={density}
onChange={(v) => setDensity(v as typeof density)}
/>
<Knob
name="Font scale"
presets={FONT_SCALE_PRESETS}
value={fontScale}
onChange={(v) => setFontScale(v as typeof fontScale)}
/>
</div>
<div className="rounded-md border border-dashed border-border p-4">
<ThemeScope radius={radius} density={density} fontScale={fontScale}>
<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>
</ThemeScope>
</div>
</div>
);
}
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.