Skip to content

Search dialog

A search that opens in a modal dialog, with the field at the top, results as you type, and key hints at the bottom. Use it for site or documentation search. For filtering a list on the page, use Search.

Code
demo.tsx
import {KeyboardKey, SearchDialog, Text} from '@earendil-works/elanor-react';
import {useState} from 'react';

const pages = [
  {
    href: '#getting-started',
    section: 'Guides',
    title: 'Getting started',
    excerpt: 'Install the command-line tool, sign in, and create your first project.',
  },
  {
    href: '#deploying',
    section: 'Guides',
    title: 'Deploying a project',
    excerpt: 'Every project deploys from its default branch. Connect a repository first.',
  },
  {
    href: '#rollbacks',
    section: 'Guides',
    title: 'Rollbacks',
    excerpt: 'A deploy that fails its checks never replaces the running version.',
  },
  {
    href: '#configuration',
    section: 'Reference',
    title: 'Configuration file',
    excerpt: 'Settings for builds, environments, and domains, in one file at the project root.',
  },
];

export default function SearchDialogDemo() {
  const [query, setQuery] = useState('');
  const terms = query.trim().toLowerCase();
  const results = terms
    ? pages.filter((page) => `${page.title} ${page.excerpt}`.toLowerCase().includes(terms))
    : [];

  return (
    <SearchDialog.Root onOpenChange={(open) => open && setQuery('')}>
      <SearchDialog.Trigger>Search documentation</SearchDialog.Trigger>
      <SearchDialog.Popup aria-label="Search documentation" value={query} onValueChange={setQuery}>
        <SearchDialog.Form>
          <SearchDialog.Input placeholder="Search documentation" />
          <SearchDialog.Close aria-label="Close search" />
        </SearchDialog.Form>
        <SearchDialog.Results>
          <SearchDialog.Status>
            {terms ? `${results.length} ${results.length === 1 ? 'result' : 'results'}` : ''}
          </SearchDialog.Status>
          {terms && results.length === 0 ? (
            <SearchDialog.Message>
              <Text>No results for “{query.trim()}”.</Text>
            </SearchDialog.Message>
          ) : null}
          {terms ? null : (
            <SearchDialog.Message>
              <Text>Search guides and reference pages.</Text>
            </SearchDialog.Message>
          )}
          <SearchDialog.List aria-label="Results">
            {results.map((page) => (
              <SearchDialog.Result key={page.href} href={page.href}>
                <SearchDialog.ResultContext>{page.section}</SearchDialog.ResultContext>
                <SearchDialog.ResultTitle>{page.title}</SearchDialog.ResultTitle>
                <SearchDialog.ResultExcerpt>{page.excerpt}</SearchDialog.ResultExcerpt>
              </SearchDialog.Result>
            ))}
          </SearchDialog.List>
        </SearchDialog.Results>
        <SearchDialog.Footer>
          <SearchDialog.Hint>
            <KeyboardKey>↑</KeyboardKey>
            <KeyboardKey>↓</KeyboardKey> to move
          </SearchDialog.Hint>
          <SearchDialog.Hint>
            <KeyboardKey>↵</KeyboardKey> to open
          </SearchDialog.Hint>
          <SearchDialog.Hint>
            <KeyboardKey>Esc</KeyboardKey> to close
          </SearchDialog.Hint>
        </SearchDialog.Footer>
      </SearchDialog.Popup>
    </SearchDialog.Root>
  );
}

Usage

import {SearchDialog} from '@earendil-works/elanor-react';

<SearchDialog.Root>
  <SearchDialog.Trigger>Search documentation</SearchDialog.Trigger>
  <SearchDialog.Popup aria-label="Search documentation" value={query} onValueChange={setQuery}>
    <SearchDialog.Form action="/search">
      <SearchDialog.Input name="q" />
      <SearchDialog.Close aria-label="Close search" />
    </SearchDialog.Form>
    <SearchDialog.Results>
      <SearchDialog.Status />
      <SearchDialog.Message />
      <SearchDialog.List aria-label="Results">
        <SearchDialog.Result href="/guides/deploying">
          <SearchDialog.ResultContext />
          <SearchDialog.ResultTitle />
          <SearchDialog.ResultExcerpt />
        </SearchDialog.Result>
      </SearchDialog.List>
    </SearchDialog.Results>
    <SearchDialog.Footer>
      <SearchDialog.Hint />
    </SearchDialog.Footer>
  </SearchDialog.Popup>
</SearchDialog.Root>;

The application finds the results; the dialog shows them. Control the query with value and onValueChange on the Popup, render a Result per match, and show a Message, such as an Empty state, when there are none. Status announces the count to assistive technology. Clear the query when the dialog opens, in Root’s onOpenChange, so each search starts empty.

The field is a combobox that controls the list. The first result is selected as results arrive; the arrow keys move the selection, and Enter or a click follows the selected result’s link and closes the dialog. A click with a modifier key, such as opening the link in a new tab, leaves it open, and so does ⌘, Ctrl, or Alt with Enter, which opens the selected result in a new tab. Without results, Enter submits the form, so set action and the field’s name for a search page.

Results are a elements. For a router’s link component, pass it as render, such as render={<Link />}; it receives the Result’s href.

The dialog sits semantic.dimension.space.padding.xxxxLarge from the top, so results grow downward without moving the field, and it is as wide as Modal. It grows with its results up to semantic.dimension.size.maxHeight.searchDialog, or the window if shorter. Only the results scroll, in a Scroll area, so the scrollbar overlays them instead of narrowing them. The query uses semantic.typography.ui.searchDialog.input, one step above the UI text.

Keyboard shortcut

The Trigger looks like a search field. The application chooses the keyboard shortcut that opens the dialog: create a handle with SearchDialog.createHandle(), pass it to Root, and call open(null) from a key listener. Show the keys in the Trigger with shortcut, such as a Keyboard shortcut, and name them in aria-keyshortcuts. The shortcut and the Footer’s hints show only on devices that hover, which usually have a keyboard.

Code
keyboard-shortcut.tsx
import {KeyboardKey, KeyboardShortcut, SearchDialog, Text} from '@earendil-works/elanor-react';
import {useEffect, useState} from 'react';

const searchDialog = SearchDialog.createHandle();

export default function SearchDialogKeyboardShortcutDemo() {
  const [query, setQuery] = useState('');

  // The application owns the shortcut: here, Ctrl+K or ⌘K opens the search.
  useEffect(() => {
    function onKeyDown(event: KeyboardEvent) {
      if (event.defaultPrevented || event.isComposing) return;
      if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
        event.preventDefault();
        searchDialog.open(null);
      }
    }
    document.addEventListener('keydown', onKeyDown);
    return () => document.removeEventListener('keydown', onKeyDown);
  }, []);

  return (
    <SearchDialog.Root handle={searchDialog} onOpenChange={(open) => open && setQuery('')}>
      <SearchDialog.Trigger
        aria-keyshortcuts="Control+K Meta+K"
        shortcut={
          <KeyboardShortcut>
            <KeyboardKey>Ctrl</KeyboardKey>
            <KeyboardKey>K</KeyboardKey>
          </KeyboardShortcut>
        }
      >
        Search documentation
      </SearchDialog.Trigger>
      <SearchDialog.Popup aria-label="Search documentation" value={query} onValueChange={setQuery}>
        <SearchDialog.Form>
          <SearchDialog.Input placeholder="Search documentation" />
          <SearchDialog.Close aria-label="Close search" />
        </SearchDialog.Form>
        <SearchDialog.Results>
          <SearchDialog.Message>
            <Text>
              {query.trim()
                ? `No results for “${query.trim()}”.`
                : 'Search guides and reference pages.'}
            </Text>
          </SearchDialog.Message>
        </SearchDialog.Results>
      </SearchDialog.Popup>
    </SearchDialog.Root>
  );
}

Static rendering

On pages rendered on the server without React in the browser, use the static parts. Form, Footer, and Hint stay the same. The results come from a source in the page’s script instead of React:

<SearchDialog.StaticRoot>
  <SearchDialog.StaticTrigger>Search documentation</SearchDialog.StaticTrigger>
  <SearchDialog.StaticPopup aria-label="Search documentation">
    <SearchDialog.Form action="/search">
      <SearchDialog.StaticInput name="q" placeholder="Search documentation" />
      <SearchDialog.StaticClose aria-label="Close search" />
    </SearchDialog.Form>
    <SearchDialog.StaticResults>
      <SearchDialog.StaticStatus />
      <SearchDialog.StaticMessage state="idle">
        Search guides and reference pages.
      </SearchDialog.StaticMessage>
      <SearchDialog.StaticMessage state="empty">
        No results for “<SearchDialog.StaticQuery />
        ”.
      </SearchDialog.StaticMessage>
      <SearchDialog.StaticList aria-label="Results" />
    </SearchDialog.StaticResults>
  </SearchDialog.StaticPopup>
</SearchDialog.StaticRoot>
import {
  createModal,
  createScrollArea,
  createSearchDialog,
  enhance,
} from '@earendil-works/elanor-react';

const source = {
  load: () => loadIndex(),
  search: (query) => index.search(query), // [{url, context, title, excerpt}, …]
};

enhance({
  modal: createModal,
  'scroll-area': createScrollArea,
  'search-dialog': (element) => createSearchDialog(element, source),
});

The popup is a native modal dialog, opened and closed like Modal’s static parts. Each opening starts with an empty query; the source loads when the dialog first opens, and up to ten results show once the query has two characters, the first selected. StaticMessages show per state: idle, short, loading, empty, and error. Keyboard shortcuts are the page’s: open the dialog by clicking its trigger, such as with trigger.click(), so it animates like any other opening. Give StaticRoot an id to open the dialog from other buttons too, with commandfor and command="show-modal", and pass the page’s own behaviors to StaticPopup in data-elanor.

API

SearchDialog.Root

A search that opens in a modal dialog. Put a Trigger and a Popup inside. To open it with a keyboard shortcut, pass a handle from createHandle() and call open() from the application.

Accepts Base UI Dialog.Root props.

SearchDialog.Trigger

Opens the dialog. It looks like a search field, with its text as the placeholder.

PropTypeDefaultDescription
childrenReactNode-The trigger's text, such as "Search documentation".
shortcutReactNode-A keyboard shortcut shown at the end on devices that hover, such as a KeyboardShortcut. It only shows the keys: the application listens for them, and names them in aria-keyshortcuts.

Also accepts Base UI Dialog.Trigger props.

SearchDialog.Popup

The dialog, near the top of the window over a backdrop. Its parts are the Form, the Results, and the Footer, in that order. The field is a combobox that controls the results; the first result is selected as results arrive, and the arrow keys move the selection.

PropTypeDefaultDescription
aria-labelrequiredstring-The accessible name, such as "Search documentation".
childrenReactNode-

Also accepts Base UI Autocomplete.Root props.

SearchDialog.Form

The top row, a search form: holds the Input and a Close. Without results, Enter submits.

Renders <form>. Accepts its HTML attributes and ref.

SearchDialog.Input

The field: the search icon and the query, without a field's fill or border; the dialog is the field. Focused when the dialog opens. Its type is text, because in a search field Escape first clears the query and browsers add their own clear button.

Accepts Base UI Combobox.Input props.

SearchDialog.Close

Closes the dialog.

PropTypeDefaultDescription
aria-labelrequiredstring-The accessible name, such as "Close search".

Also accepts Base UI Autocomplete props.

SearchDialog.Results

The area below the form, for Messages, the List, and a Status. It scrolls in a scroll area, whose scrollbar overlays the results instead of narrowing them.

PropTypeDefaultDescription
childrenReactNode-

Also accepts Base UI ScrollArea.Root props.

SearchDialog.Status

Announces the results to assistive technology, such as "5 results". Visually hidden.

Accepts Base UI Autocomplete props.

SearchDialog.Message

What the results area shows instead of results, such as an EmptyState.

Renders <div>. Accepts its HTML attributes and ref.

SearchDialog.List

The results, a listbox the field controls. Put Results inside.

PropTypeDefaultDescription
aria-labelrequiredstring-The accessible name, such as "Results".

Also accepts Base UI Combobox.List props.

SearchDialog.Result

A result, a link: put a ResultContext, a ResultTitle, and a ResultExcerpt inside. The selected one gets the active fill. Choosing it follows the link and closes the dialog. It renders an a; for a router's link component, pass it as render, which receives href.

PropTypeDefaultDescription
hrefrequiredstring-The result's URL. Choosing the result follows it.
renderReact.ReactElement | ComponentRenderFn<RenderFunctionProps, State>renderResultLinkAllows you to replace the component's HTML element with a different tag, or compose it with another component.

Also accepts Base UI Autocomplete.Item props.

SearchDialog.ResultContext

Where the result sits, such as its section.

Renders <span>. Accepts its HTML attributes and ref.

SearchDialog.ResultTitle

Renders <span>. Accepts its HTML attributes and ref.

SearchDialog.ResultExcerpt

Up to two lines of the matching text.

Renders <span>. Accepts its HTML attributes and ref.

The bottom row, for Hints. Shown on devices that hover, which usually have keyboards.

Renders <div>. Accepts its HTML attributes and ref.

SearchDialog.Hint

A key hint, such as KeyboardKeys followed by what they do.

Renders <span>. Accepts its HTML attributes and ref.

SearchDialog.StaticRoot

A search dialog for pages without React in the browser; createModal, createScrollArea, and createSearchDialog make it work. Renders no element.

PropTypeDefaultDescription
idstring-The dialog's id, so other buttons can open it with commandfor. Generated by default.
childrenReactNode-

Also accepts HTML attributes and ref.

SearchDialog.StaticTrigger

PropTypeDefaultDescription
childrenReactNode-The trigger's text, such as "Search documentation".
shortcutReactNode-A keyboard shortcut shown at the end on devices that hover, such as a KeyboardShortcut.

Renders <button>. Also accepts its HTML attributes and ref.

SearchDialog.StaticPopup

PropTypeDefaultDescription
data-elanorstring-The page's own behaviors for this element, attached after the component's.
aria-labelrequiredstring-The accessible name, such as "Search documentation".

Renders <dialog>. Also accepts its HTML attributes and ref.

SearchDialog.StaticInput

Renders <span>. Accepts its HTML attributes and ref.

SearchDialog.StaticClose

PropTypeDefaultDescription
aria-labelrequiredstring-The accessible name, such as "Close search".

Also accepts Base UI Button props.

SearchDialog.StaticResults

The area below the form, for StaticMessages, the StaticList, and a StaticStatus.

Renders <div>. Accepts its HTML attributes and ref.

SearchDialog.StaticStatus

Announces the result count to assistive technology. Visually hidden.

Renders <span>. Accepts its HTML attributes and ref.

SearchDialog.StaticMessage

PropTypeDefaultDescription
staterequired'idle' | 'short' | 'loading' | 'empty' | 'error'-When it shows: no query yet, a query too short to search, while the source loads, no results, or a source that failed to load.

Renders <div>. Also accepts its HTML attributes and ref.

SearchDialog.StaticQuery

The query, inside a StaticMessage, such as “No results for …”.

Renders <span>. Accepts its HTML attributes and ref.

SearchDialog.StaticList

The results, filled by the behavior: each has its context, title, and excerpt.

PropTypeDefaultDescription
aria-labelrequiredstring-The accessible name, such as "Results".

Renders <div>. Also accepts its HTML attributes and ref.