Three static Astro directories. Over a thousand programmatic pages on the largest site, low hundreds on the other two. Users want to search. The choice between Algolia, Lunr, and Pagefind is something I've already written about — I picked Pagefind because it builds a static index at deploy time with no API key and no per-query cost. What I hadn't written about is how I wired the UI up, because that turned out to be where the interesting decisions happened.
This article is specifically about the SearchDialog.astro component: why I used the native <dialog> element instead of a custom overlay, how I deferred the 70KB Pagefind JS bundle so it only loads when someone actually opens search, and what I got wrong on the first attempt.
Why native <dialog> instead of a div overlay
The standard modal pattern — a fixed-position div, z-index management, a backdrop div, manual Escape key handling, focus trap logic, scroll lock — is something I've written three times across different projects. It's tedious and it's always slightly wrong in edge cases.
The HTML <dialog> element via dialog.showModal() gives you:
- A real focus trap (Tab cycles through dialog elements only)
- Escape key dismissal built into the browser
- A
::backdroppseudo-element for the overlay (no extra DOM node) - Placement in the top layer, above all other stacking contexts
Browser support as of mid-2026 covers all major browsers including Safari 15.4+. If you need to go further back than that, dialog-polyfill exists but adds complexity.
The one behavior to know: showModal() throws a DOMException if called on a dialog that's already open. Full disclosure: my shipped component doesn't guard against this — open() calls showModal() directly with only a null check on the dialog. If you wire up multiple triggers that could fire on the same event, add a guard before calling it:
function open() {
if (dialog.open) return; // showModal() throws if already open
loadPagefind();
dialog.showModal();
// ...
}
The lazy-loading pattern
Pagefind ships a UI component (pagefind-ui.js) that builds the search input and result list. It's around 70KB compressed. Loading it on every page — even pages where the user never touches the search button — is waste.
The fix is to append the <script> element dynamically, only when the dialog opens:
var loaded = false;
var root = document.getElementById("pagefind-search-root");
function loadPagefind() {
if (loaded || !root) return;
loaded = true;
var s = document.createElement("script");
s.src = "/_pagefind/pagefind-ui.js";
s.onload = function () {
if (window.PagefindUI) {
new window.PagefindUI({
element: root,
showSubResults: true,
resetStyles: false,
});
}
};
s.onerror = function () {
root.innerHTML =
'<p style="color: rgb(113 113 122); font-size: 0.875rem;">' +
"Search index not available yet (first build). Try again after next deploy." +
"</p>";
};
document.head.appendChild(s);
}
The loaded flag prevents calling new PagefindUI() more than once. Without it, every time the user opens and closes the dialog, you'd append another <script> tag. The second call would succeed silently (the URL is cached) but the onload would fire again and try to initialize Pagefind into an already-initialized root, producing duplicate results.
The resetStyles: false option is easy to miss. Pagefind's default behavior with PagefindUI is to apply inline styles that override the component's own stylesheet, which makes it hard to match your site's design. With resetStyles: false, the CSS cascade stays intact.
The focus timing problem
After dialog.showModal(), the keyboard focus should move to the search input. The problem: PagefindUI builds its DOM asynchronously, during the onload handler. If you query for the input immediately after showModal(), it doesn't exist yet.
The naive fix — call input.focus() inside the onload callback — fails when loadPagefind() returns early (the loaded guard). On the second open, the script is already loaded and onload never fires again. So focus() only works on the first open.
The solution I landed on: setTimeout with a small delay:
dialog.showModal();
setTimeout(function () {
var input = root && root.querySelector("input");
if (input) input.focus();
}, 100);
100ms isn't principled — it's empirically long enough that PagefindUI has finished its DOM construction on both first open (waits for script load) and subsequent opens (DOM already exists). I've seen requestAnimationFrame suggested instead, but in my testing it fired before the input was available on the initial load.
This is one of those timings I'd revisit if I saw focus failures in production. Month three, zero reports so far.
The onerror fallback
In astro dev, Pagefind's index doesn't exist. The index is generated post-build: pagefind --site dist. So /_pagefind/pagefind-ui.js returns 404 in development.
Without the onerror handler, the dialog opens to a blank white area. That's confusing. With the handler, you get a short message explaining the situation. This is not visible to anyone in production — the index exists after every successful build. It's purely for the development workflow.
| Scenario | Without onerror
|
With onerror
|
|---|---|---|
astro dev |
Empty dialog, no explanation | "Search index not available yet" message |
| Production | N/A (index always exists) | N/A |
| Build failure (index missing) | Empty dialog | Same fallback message |
What I got wrong: CSS still loads on every page
Pagefind's JS bundle is deferred. The CSS is not. The component template includes:
<link rel="stylesheet" href="/_pagefind/pagefind-ui.css" />
This loads on every page visit, unconditionally. It's about 6KB, so not catastrophic. But strictly speaking, it's unnecessary until the dialog opens.
I could lazy-load it the same way: insert a <link> element dynamically inside loadPagefind(). I didn't, for two reasons:
- 6KB is not material on a site where the main threat to performance is the number of programmatic pages, not stylesheet size
- If I lazy-load the CSS, I need to handle the brief window between
loadPagefind()returning and the styles actually applying — otherwise the UI flashes unstyled. That requires either aPromisechain or knowing when<link>onloadfires, which is less reliable than<script> onload
If I were building this from scratch today, I'd lazy-load both and accept the added complexity. At the time, the 70KB JS was the obvious target and I stopped once I'd addressed it.
The Cmd+K handler
One gotcha with keyboard shortcuts: metaKey (Mac) vs ctrlKey (Windows/Linux). The handler checks both:
document.addEventListener("keydown", function (e) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
open();
} else if (e.key === "Escape" && dialog && dialog.open) {
dialog.close();
}
});
The e.preventDefault() on Cmd+K prevents the browser's default "open link" behavior in some configurations. Without it, you may get a double-open on certain browser setups.
The Escape handler is technically redundant — showModal() already handles Escape. I kept it because the dialog.close() call also triggers the close event, which I might want to use later for cleanup.
How it fits into the three-site architecture
The SearchDialog component lives in each app's own src/components/ directory. The three-site architecture means each site has its own Base layout with its own component includes. I could move SearchDialog into the shared package, but the component has no configuration props right now — it's identical across sites — and the overhead of a shared component with a cross-app import path has bitten me before on Astro's path resolution specifics.
The Base layout in aiappdex.com includes SearchDialog in the <head> slot. Because the dialog is SSR-rendered as static HTML (it's just an Astro component), it ships as part of the initial HTML and is interactive as soon as the JS hydrates. No lazy component loading needed at the framework level.
One detail: the <script> tag uses is:inline rather than the default Astro behavior. Astro's default hoists and bundles <script> tags, which is great for module scripts but breaks the pattern of dynamically appending a new <script> element for lazy loading. is:inline keeps the script exactly as written.
FAQ
Does Pagefind work with Cloudflare Pages?
Yes — the index files are static assets and deploy like any other Astro output. In my setup the indexing runs as a postbuild hook in each app's package.json ("postbuild": "pagefind --site dist --output-subdir _pagefind"), so the Cloudflare Pages build command is just the normal per-app build — no extra dashboard step needed.
Does showModal() have good browser support?
Chrome 37+, Firefox 98+, Safari 15.4+. Baseline 2022. I haven't added a polyfill — if the dialog element isn't supported, the button still exists but clicking it does nothing. That's acceptable given the target audience (developers, tech-adjacent users).
Can I use Pagefind's lower-level API instead of PagefindUI?
Yes. Pagefind exports pagefind.search(query) that returns results as JSON. PagefindUI is a convenience wrapper. I used it because the default output is functional and resetStyles: false keeps the design close enough to the site's style.
What's the Pagefind build time overhead on a large site?
On aiappdex.com (~1,750 static pages when I wrote this), indexing adds a few seconds to the build. findindiegame.com is much smaller — around 140 pages — so its indexing overhead is negligible. I haven't profiled either precisely.
Is the search index updated on every deploy?
Yes. Pagefind runs as a post-build step, so every Cloudflare Pages deploy regenerates the index from the latest HTML output. There's no incremental indexing — it's a full rebuild each time. For daily content updates via cron, that's fine.
Related: Why I picked Pagefind over Algolia and Lunr for Astro in 2026 · Four monitoring tools wired into three Cloudflare Pages sites · Astro 5 content collections as an editorial layer in a programmatic site
Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.
Top comments (0)