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.jsandpagefind-ui.cssin 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.
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.
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 look | Modal, themed (L1–3) | Palette (L4) | Tool |
|---|---|---|---|---|
| Search script on page load | 22 KB | 0.6 KB | 0.6 KB | DevTools |
| Time to first result after opening | 640 ms | 610 ms | 480 ms | DevTools, throttled |
| Searches per 1,000 sessions | 41 | 118 | 162 (incl. navigation) | Analytics events |
| Clicks on sub-results | n/a | 23% of result clicks | 19% | Analytics events |
| axe violations in search UI | 0 | 0 | 0 | axe-core 4.10 |
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.
Related
- Parent: Search for Static Sites — static search options.
- Adding Pagefind to an Astro Site — the base setup.
- Accessible Search UI for Static Sites — the accessibility pattern for custom UIs.
- Fixing Missing Results in Pagefind Indexes — making sure the UI has results to show.
- Accessible Dark Mode Toggles for Static Sites — the theme tokens the UI follows.