Integrations

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-react

Usage

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

PropDefaultDescription
open-Whether the dialog is visible
onClose-Called on dismissal, and after a result is opened
searcher-Resolves a query to hits
onNavigatefull page loadOpens a result; pass a router-aware function to keep client-side navigation
labelsEnglish defaultsCopy overrides
debounceMs0Milliseconds to wait after the last keystroke
recentSearchestrueRemember and replay opened results
recentSearchesKey'zbsearch:searchbox:recent'localStorage key backing that history
className-Extra class on the dialog
containerdocument.bodyWhere 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;
}
PropertyPurpose
--zbs-accent, --zbs-accent-softHighlights, icons, the selected row
--zbs-surface, --zbs-surface-raised, --zbs-surface-hoverPanel, footer and row backgrounds
--zbs-text, --zbs-text-muted, --zbs-text-faintText, in decreasing prominence
--zbs-border, --zbs-border-strongDividers and outlines
--zbs-backdrop, --zbs-shadowThe overlay behind the dialog, and its shadow
--zbs-radius, --zbs-radius-smPanel and row corners
--zbs-font, --zbs-font-familyTypeface
--zbs-z-indexStacking 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:

ExportDescription
useSearchThe query state machine: debouncing, cancellation, status
useSearchHotkeysBinds ⌘K / Ctrl+K and /
useScrollLock, useIsMounted, useIsApplePlatformBehaviour hooks
HighlightedRenders text with matches wrapped in <mark>
highlight, snippetAroundThe text helpers behind it
groupHits, flattenGroups, wrapIndexResult helpers
readRecentSearches, addRecentSearch, removeRecentSearchHistory helpers
ZBSearchWordmark, ZBSearchLogoThe 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.

On this page