Search

Search is a modal over the current page: a button in the header opens a Pagefind-backed panel with one input and a live result list. The index is built at deploy time from the rendered HTML, so searching costs no server and no third party — the browser fetches index fragments as the visitor types.

The search button in this page’s own header is the live trigger — press it, or ⌘K, and the modal opens over this page.

When to use#

  • Site-wide search from any page: the trigger sits in the header and the modal is rendered once per page by the base template
  • Each site searches its own index — npm run index runs Pagefind separately over the main site and the brand site output
  • On the links section the query is filtered to that section, so the reading list searches itself
  • Not a data filter: for narrowing a table or list on one page, use the filter row component instead

Implementation#

{{ partial "search.html" . }}       {{/* trigger, rendered by header.html */}}
{{ partial "search-modal.html" . }} {{/* modal, rendered once by baseof.html */}}
<button id="search-trigger" class="search-trigger" type="button" aria-keyshortcuts="Control+K Meta+K" aria-haspopup="dialog" aria-controls="search-modal">
  <svg class="search-trigger-icon" viewBox="0 0 24 24" aria-hidden="true">…</svg>
  <span>Search</span>
  <kbd>⌘K</kbd>
</button>

<div id="search-modal" class="search-modal hidden" role="dialog" aria-modal="true" aria-label="Search this site">
  <div id="search-backdrop" class="search-backdrop"></div>
  <div class="search-panel">
    <div class="search-field">
      <input id="search-input" type="search" autocomplete="off" placeholder="Search this site" aria-label="Search this site" />
      <kbd>Esc</kbd>
    </div>
    <div id="search-results" class="search-results">…</div>
  </div>
</div>

The wiring is in assets/js/site.js. Opening — a click on the trigger, or ⌘K / Ctrl+K anywhere — records whatever element currently has focus, reveals the modal, locks body scroll, clears the input, and moves focus into it; the Pagefind module is imported lazily on first open. Input is debounced at 180 ms, each query renders up to eight results as links, and Escape, a click on the backdrop, or following a result closes the modal. Closing returns focus to whatever had it before the modal opened — the trigger button in the ordinary case — and a Tab keydown listener on the modal traps focus between the first and last focusable element for as long as it stays open, since aria-modal="true" is a promise to assistive technology, not an enforcement mechanism.

ElementRole
#search-triggerHeader button that opens the modal
#search-modalThe dialog itself; hidden by class until opened
#search-backdropDimmed layer behind the panel; clicking it closes the modal
#search-inputThe query field; focus lands here on open
#search-resultsResult links and the placeholder, empty, and error messages

The trigger partial is layouts/partials/search.html, rendered inside the header, and the modal is layouts/partials/search-modal.html, rendered once by layouts/_default/baseof.html. All strings are localized: the partials read i18n keys directly, and the JavaScript gets its messages through the app-config JSON block the base template embeds. Pagefind loads from params.pagefindPath and searches the per-site index that npm run index builds after Hugo renders. Styling is the .search-* rules in assets/css/site.css.

Options#

OptionPurpose
params.pagefindPathSite parameter locating the Pagefind bundle; defaults to /pagefind/pagefind.js
pagefind_excludePage front matter flag that drops data-pagefind-body, keeping the page out of the index

Interface manifest

Kind
Component
Category
Action
Status
Implemented
Implementation
Partial:layouts/partials/search.htmllayouts/partials/search-modal.htmlCSS:assets/css/site.cssJavaScript:assets/js/site.jsi18n:searchShortsearchPlaceholdersearchUnavailablesearchErrorsearchNoResultsConfiguration:config/brand/hugo.yml

Accessibility#

  • The modal is a role="dialog" with aria-modal="true" and a localized aria-label (“Search this site”), and the trigger is a native <button type="button"> carrying aria-keyshortcuts="Control+K Meta+K", so Enter and Space open it and the ⌘K hint reaches screen readers alongside the visible <kbd>
  • Focus moves into the search input as the modal opens and is trapped inside the dialog — Tab from the last focusable element wraps to the first, Shift+Tab from the first wraps to the last — so nothing behind the backdrop is reachable while it’s open
  • Escape closes the modal from anywhere in the document, matching the visible Esc hint in the field; closing returns focus to whichever element opened it, so a keyboard user resumes exactly where they left off
  • Avoid replacing the trigger with a link — the component depends on button semantics, and a link would break Space activation and announce the wrong role