React search box
The ZBSearch search dialog as a standalone React component.
@zbsearch/searchbox-react is the command-palette search dialog the
Docusaurus and Starlight
plugins render, published on its own so you can use it anywhere React runs.
If you are on Vue, @zbsearch/searchbox-vue is its exact
counterpart: same markup, same stylesheet, same behaviour.
It is engine-agnostic. You give it a searcher function; it handles the dialog, the keyboard, the grouping,
the highlighting and the accessibility.
Installation
npm install @zbsearch/searchbox-reactUsage
import { SearchBox, SearchButton, useSearchHotkeys } from '@zbsearch/searchbox-react';
import type { SearchHit } from '@zbsearch/searchbox-react';
import '@zbsearch/searchbox-react/styles.css';
import { useCallback, useState } from 'react';
export function Search() {
const [open, setOpen] = useState(false);
useSearchHotkeys(() => setOpen(true));
const searcher = useCallback(async (term: string, signal: AbortSignal): Promise<SearchHit[]> => {
const response = await fetch(`/api/search?q=${encodeURIComponent(term)}`, { signal });
return response.json();
}, []);
return (
<>
<SearchButton onClick={() => setOpen(true)} />
<SearchBox open={open} onClose={() => setOpen(false)} searcher={searcher} />
</>
);
}The dialog is fully controlled: it renders nothing until open is true, and calls onClose when the user
dismisses it or picks a result.
Searching a local index
Pairing it with a ZBSearch instance in the browser takes a few lines:
import { create, insertMultiple, search } from 'zbsearch';
const db = create({ schema: { title: 'string', content: 'string' } });
await insertMultiple(db, documents);
const searcher = async (term: string) => {
const results = await search(db, { term, limit: 12 });
return results.hits.map((hit) => ({
id: String(hit.id),
url: hit.document.url,
title: hit.document.title,
snippet: hit.document.content,
}));
};Hits
interface SearchHit {
id: string; // unique across the result set
url: string; // where the hit points
title: string; // title of the page it belongs to
section?: string; // heading it was extracted from
snippet?: string; // excerpt of the matching content
breadcrumb?: string[]; // ancestor headings, outermost first
category?: string; // label such as 'Docs', used to tag groups
}Hits that share a page - ignoring the fragment - are grouped under one heading automatically, so five matches in one document read as one entry with five sections rather than five unrelated results.
The searcher receives an AbortSignal that is aborted as soon as the query becomes stale. Every superseded
request is discarded, so a slow searcher can never overwrite the results of a newer one.
Props
| Prop | Default | Description |
|---|---|---|
open | - | Whether the dialog is visible |
onClose | - | Called on dismissal, and after a result is opened |
searcher | - | Resolves a query to hits |
onNavigate | full page load | Opens a result; pass a router-aware function to keep client-side navigation |
labels | English defaults | Copy overrides |
debounceMs | 0 | Milliseconds to wait after the last keystroke |
recentSearches | true | Remember and replay opened results |
recentSearchesKey | 'zbsearch:searchbox:recent' | localStorage key backing that history |
className | - | Extra class on the dialog |
container | document.body | Where the dialog is portalled |
Leave debounceMs at 0 when searching a local index: ZBSearch answers in microseconds, and a debounce only
adds lag. Raise it when each query is a network request.
Theming
Every value is a --zbs-* custom property. Light is the default, dark follows the operating system, and an
explicit data-theme attribute on any ancestor wins over both.
:root {
--zbs-accent: #0aa;
--zbs-radius: 8px;
--zbs-font-family: 'Inter', sans-serif;
}| Property | Purpose |
|---|---|
--zbs-accent, --zbs-accent-soft | Highlights, icons, the selected row |
--zbs-surface, --zbs-surface-raised, --zbs-surface-hover | Panel, footer and row backgrounds |
--zbs-text, --zbs-text-muted, --zbs-text-faint | Text, in decreasing prominence |
--zbs-border, --zbs-border-strong | Dividers and outlines |
--zbs-backdrop, --zbs-shadow | The overlay behind the dialog, and its shadow |
--zbs-radius, --zbs-radius-sm | Panel and row corners |
--zbs-font, --zbs-font-family | Typeface |
--zbs-z-index | Stacking order of the overlay |
Accessibility
The dialog implements the ARIA 1.2 combobox pattern: the input is a combobox that owns a listbox and
reports the active row through aria-activedescendant. Focus is trapped while it is open and restored to
whatever was focused before, the page behind cannot scroll, and the scrollbar width is compensated so nothing
shifts sideways.
Rows are real links, so a result shows its destination in the status bar and opens in a new tab on a modifier-click.
Building your own UI
The pieces are exported individually if the dialog is not the shape you want:
| Export | Description |
|---|---|
useSearch | The query state machine: debouncing, cancellation, status |
useSearchHotkeys | Binds ⌘K / Ctrl+K and / |
useScrollLock, useIsMounted, useIsApplePlatform | Behaviour hooks |
Highlighted | Renders text with matches wrapped in <mark> |
highlight, snippetAround | The text helpers behind it |
groupHits, flattenGroups, wrapIndex | Result helpers |
readRecentSearches, addRecentSearch, removeRecentSearch | History helpers |
ZBSearchWordmark, ZBSearchLogo | The ZBSearch lockup and its mark |
Highlighting is done by @zbsearch/highlight, through its match
positions rather than its HTML output, so nothing is ever rendered with dangerouslySetInnerHTML.