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 indexruns 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.
| Element | Role |
|---|---|
#search-trigger | Header button that opens the modal |
#search-modal | The dialog itself; hidden by class until opened |
#search-backdrop | Dimmed layer behind the panel; clicking it closes the modal |
#search-input | The query field; focus lands here on open |
#search-results | Result 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#
| Option | Purpose |
|---|---|
params.pagefindPath | Site parameter locating the Pagefind bundle; defaults to /pagefind/pagefind.js |
pagefind_exclude | Page 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"witharia-modal="true"and a localizedaria-label(“Search this site”), and the trigger is a native<button type="button">carryingaria-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
Eschint 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