Customizing the Pagefind UI

Pagefind's default UI is good: accessible, fast, with excerpts and highlighting. It also looks like Pagefind, which on a site with a strong design system stands out — different font, different corner radius, a full-width input on a page where everything else sits in cards. Teams then face a choice between living with it, overriding it with CSS, or building their own UI on the JavaScript API. Each is the right answer for some sites.

This guide customises Pagefind on a product documentation site built with Astro: first theming the default UI with CSS variables, then adding filters and result metadata, then moving it into a keyboard-triggered modal, and finally — for the site's command palette — building a custom UI on the API. It belongs to Search for Static Sites; the initial setup is in Adding Pagefind to an Astro Site.

Prerequisites

  • Pagefind 1.x indexing the built site, with pagefind-ui.js and pagefind-ui.css in the output.
  • The site's design tokens as CSS custom properties.
  • A list of the metadata readers need in results — section, version, last updated.

Level 1: Theme the Default UI

The default UI reads CSS custom properties. Mapping them to the site's tokens takes care of most of the visual mismatch:

.pagefind-ui {
  --pagefind-ui-scale: 0.9;
  --pagefind-ui-primary: var(--color-accent);
  --pagefind-ui-text: var(--color-text);
  --pagefind-ui-background: var(--color-surface);
  --pagefind-ui-border: var(--color-border);
  --pagefind-ui-tag: var(--color-surface-muted);
  --pagefind-ui-border-width: 1px;
  --pagefind-ui-border-radius: var(--radius-md);
  --pagefind-ui-font: var(--font-sans);
}
[data-theme='dark'] .pagefind-ui { --pagefind-ui-background: var(--color-surface); }

Because the variables point at the site's own tokens, the dark theme follows automatically. A few class overrides handle the rest — result titles in the site's heading weight, excerpt highlights (mark) using the accent colour at 20% opacity so highlighted text keeps its contrast in both themes.

Levels of Pagefind UI customisation from least to most effort Four levels. Level one sets CSS variables to the site's tokens, about 15 lines. Level two adds filters and result metadata through data attributes in the HTML, no JavaScript. Level three places the default UI in a modal opened by a button and keyboard shortcut, about 40 lines. Level four replaces the UI with a custom one on the JavaScript API, about 200 lines including accessibility. Four levels of customisation 1. CSS variables map to design tokens ~15 lines CSS 2. Filters and metadata data attributes in HTML no JavaScript 3. Modal trigger dialog + shortcut ~40 lines 4. Custom UI on the API full control ~200 lines incl. a11y
Most sites stop at level three; the command palette on this site needed level four.

Level 2: Filters and Result Metadata

Filters come from attributes in the indexed HTML. The layout adds the page's section and product version:

<article data-pagefind-body>
  <span hidden data-pagefind-filter="section">{section}</span>
  <span hidden data-pagefind-filter="version">{version}</span>
  <span hidden data-pagefind-meta="updated">{updated.toISOString().slice(0, 10)}</span>
  <Content />
</article>

The default UI shows filters as checkbox groups with counts automatically. Metadata does not show by default; the UI's processResult option adds it to each result:

new PagefindUI({
  element: '#search',
  showSubResults: true,
  processResult: (r) => { r.meta.title = `${r.meta.title} · ${r.filters?.section?.[0] ?? ''}`; return r; },
});

showSubResults lists matching headings within a page, which on long reference pages took readers straight to the relevant section. In usability sessions it was the single most appreciated change.

Level 3: A Modal With a Keyboard Shortcut

The UI was first embedded on a /search/ page. Readers expected search in the header, opened with / or Ctrl+K. A native <dialog> holds the UI, and the script loads only when it first opens:

const dialog = document.querySelector('#search-dialog');
let ui;
async function open() {
  if (!ui) {
    await import('/pagefind/pagefind-ui.js');
    ui = new PagefindUI({ element: '#search-dialog .body', autofocus: true, showSubResults: true });
  }
  dialog.showModal();
}
document.querySelector('#search-button').addEventListener('click', open);
document.addEventListener('keydown', (e) => {
  if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || (e.key === '/' && !e.target.closest('input, textarea'))) {
    e.preventDefault(); open();
  }
});

showModal() traps focus, closes on Escape and returns focus to the button, which covers the accessibility behaviour a hand-rolled overlay usually gets wrong. Loading on first open keeps 22 KB of UI script and the stylesheet off every page load.

Loading sequence for the search modal Three actors, the reader, the page and the Pagefind files. On page load, only the search button and 0.6 kilobytes of trigger script load. When the reader presses Control K, the page imports the Pagefind UI script and stylesheet, then opens the dialog. When the reader types, the UI fetches the Pagefind entry file, the WebAssembly module and the index fragments the query needs. What loads when Reader Page Pagefind files page load: button + 0.6 KB trigger Ctrl+K import UI (22 KB) + CSS types a query entry, wasm, fragments
Nothing search-related beyond the trigger is downloaded until the reader asks for search.

Level 4: A Custom UI on the API

The site also has a command palette that mixes navigation ("Go to Settings"), recent pages and search. Embedding the default UI there did not fit, so the palette queries Pagefind directly:

const pagefind = await import('/pagefind/pagefind.js');
await pagefind.options({ excerptLength: 18 });
pagefind.init();

async function search(q) {
  const res = await pagefind.debouncedSearch(q, { filters: currentFilters }, 120);
  if (!res) return;                       // superseded by a newer query
  const top = await Promise.all(res.results.slice(0, 8).map((r) => r.data()));
  render(top.map((d) => ({ title: d.meta.title, url: d.url, excerpt: d.excerpt, section: d.filters.section?.[0] })));
}

debouncedSearch cancels stale queries as the reader types. r.data() is called only for results that will be shown, so fragments for results beyond the first eight are never fetched. The render function produces a listbox with aria-activedescendant for arrow-key navigation, following the combobox pattern described in Accessible Search UI for Static Sites. The excerpt is HTML containing <mark> elements and is inserted as such; it comes from the site's own index, not from user input.

The custom palette is 4 KB of script, against 22 KB for the default UI, because it renders only what it needs.

Search Across Versions and Languages

Two settings decided what each reader searched. The docs have three product versions, each built into its own folder; by default Pagefind indexed all of them together, and readers on version 3 saw version 2 results first for older pages that had more text. A version filter, preselected from the page the reader is on (ui.triggerFilter('version', current) for the default UI, or an initial filter object for the API), scoped search to the current version while still letting readers widen it. For languages, Pagefind builds a separate index per <html lang> and the UI loads the one matching the current page automatically, so the German docs searched German content without any configuration; the only change needed was translating the UI's placeholder and "no results" strings through its translations option, which ships defaults for many languages.

Testing the Customised UI

A Playwright test opens search with the keyboard shortcut, types three known queries, checks that the expected page is the first result and that a filter narrows the results, and runs axe on the open dialog in both themes. It catches the regressions customisation tends to introduce: a CSS override that hides the focus ring, a filter attribute removed from a layout, or a theme token that makes highlighted excerpts unreadable in dark mode. The suite takes about 9 seconds and runs on every pull request that touches layouts or styles.

Measured Impact

Measure/search/ page, default lookModal, themed (L1–3)Palette (L4)Tool
Search script on page load22 KB0.6 KB0.6 KBDevTools
Time to first result after opening640 ms610 ms480 msDevTools, throttled
Searches per 1,000 sessions41118162 (incl. navigation)Analytics events
Clicks on sub-resultsn/a23% of result clicks19%Analytics events
axe violations in search UI000axe-core 4.10
Search usage per 1,000 sessions for each search placement Vertical bars. A separate search page had 41 searches per 1,000 sessions. The themed modal in the header with a keyboard shortcut had 118. The command palette, which also includes navigation, had 162. Searches per 1,000 sessions 41 Search page 118 Header modal 162 Command palette
Four weeks of analytics events per variant; the palette count includes navigation commands typed into it.

Moving search into the header nearly tripled its use. Theming had no measurable effect on usage, but stopped a steady trickle of "the search box looks broken in dark mode" reports.

Pitfalls & Rollback

  • Overriding internal class names heavily. Prefer the CSS variables; class names can change between Pagefind versions.
  • Loading the UI on every page. Load it when search opens.
  • A custom UI without the combobox pattern. Accessibility is most of the work in level 4.
  • Fetching data() for every result. Fetch only what you render.
  • Rollback: each level is independent; removing the custom palette leaves the modal, and removing the modal returns search to its page.

Conclusion

Pagefind's UI can be fitted to a design system in stages: CSS variables mapped to tokens, filters and metadata from HTML attributes, a lazily loaded modal behind a keyboard shortcut, and — only when the result layout must be different — a custom UI on the API. On this site the header modal tripled search use without adding anything to page load, and the command palette built on the API was smaller than the default UI it replaced.

FAQ

Can Pagefind's default UI be styled to match my site?

Yes. The default UI exposes CSS custom properties for colours, font, border radius and spacing, and its elements have stable class names. Most sites can match their design system by setting a dozen variables and a few overrides, without replacing the UI.

When should I build my own UI on the Pagefind API?

When the result layout needs to be fundamentally different, such as grouping results by section, showing results inside an existing command palette, or mixing search with navigation suggestions. The JavaScript API returns results and fragments with excerpts and metadata, so a custom UI is mostly rendering and accessibility work.

How do I add filters such as section or version?

Mark pages with data-pagefind-filter attributes, for example section colon guides, in the HTML. Pagefind collects the values at index time and the default UI shows them as checkboxes automatically. The API exposes them for custom UIs with counts per value.

Does customising the UI affect performance?

Styling does not. Loading the UI script only when the reader opens search keeps it off the critical path. A custom UI built on the API can be smaller than the default UI, since it includes only what it renders; on the site in this guide it was 4 KB against 22 KB.

Filed underHow-tosPagefind