Accessible Search UI for Static Sites
Search is the most-used interactive feature on most documentation sites, and on static sites it is usually the only substantial piece of JavaScript UI. That makes it the place where accessibility regressions concentrate: a dialog that does not trap focus, a results list that screen readers never announce, a keyboard shortcut that hijacks a screen reader command, a "no results" state that says nothing at all. Automated checkers catch some of this; most of it only shows up when someone searches with a keyboard or a screen reader.
This guide builds a search dialog for a static site — the front end for Pagefind or any search API — that works with keyboards, screen readers, zoom and reduced motion. It covers dialog semantics, the results pattern, live announcements, focus handling, the shortcut, and how to test it. It is part of Search for Static Sites. The implementation replaced a third-party search modal on a 2,300-page documentation site after an accessibility audit found eleven issues in it.
Prerequisites
- A search backend: Pagefind's JavaScript API, a Typesense or Algolia client, or a Lunr index.
- A small UI module you control, rather than a closed third-party widget.
- NVDA with Firefox or Chrome on Windows and VoiceOver with Safari on macOS for manual testing, plus axe in CI.
The Audit Findings
The previous third-party modal failed an audit with these issues, which are typical:
| Issue | WCAG criterion |
|---|---|
| Focus not moved into the dialog on open | 2.4.3 Focus Order |
| Focus could leave the open dialog with Tab | 2.4.3 Focus Order |
| No accessible name for the dialog | 4.1.2 Name, Role, Value |
| Result count never announced | 4.1.3 Status Messages |
| Arrow-key navigation not announced (fake combobox) | 4.1.2 Name, Role, Value |
/ shortcut could not be disabled | 2.1.4 Character Key Shortcuts |
| Highlighted terms conveyed by colour only | 1.4.1 Use of Color |
| Results text contrast 3.2:1 in dark mode | 1.4.3 Contrast (Minimum) |
| Dialog content clipped at 400% zoom | 1.4.10 Reflow |
| Closing did not return focus to the trigger | 2.4.3 Focus Order |
| Animation ignored reduced-motion preference | 2.3.3 Animation from Interactions |
Step 1: Use the Native Dialog Element
The <dialog> element opened with showModal() provides most of the dialog behaviour for free: it is announced as a dialog, background content becomes inert (so Tab cannot leave), Escape closes it, and it renders in the top layer above everything else.
<button type="button" class="search-trigger" aria-keyshortcuts="Control+K Meta+K">
<span>Search</span> <kbd>Ctrl K</kbd>
</button>
<dialog class="search-dialog" aria-labelledby="search-title">
<h2 id="search-title" class="visually-hidden">Search the documentation</h2>
<form role="search" method="dialog">
<label for="search-input">Search</label>
<input id="search-input" type="search" autocomplete="off" spellcheck="false" aria-describedby="search-status">
</form>
<p id="search-status" role="status" aria-live="polite" class="search-status"></p>
<ul class="search-results" aria-label="Search results"></ul>
<button type="button" class="search-close">Close</button>
</dialog>
const dialog = document.querySelector('.search-dialog');
const trigger = document.querySelector('.search-trigger');
trigger.addEventListener('click', () => { dialog.showModal(); input.focus(); });
dialog.addEventListener('close', () => trigger.focus());
dialog.querySelector('.search-close').addEventListener('click', () => dialog.close());
Returning focus in the close handler covers Escape, the close button and programmatic closes alike.
Step 2: Results as a List of Links
The simplest accessible results pattern is a list of links after the input. Readers type, the status announces the count, and Tab moves into the results. Each result is a link whose accessible name is the page title, with the section and an excerpt as plain text inside the list item:
function render(results, q) {
list.replaceChildren(...results.map((r) => {
const li = document.createElement('li');
li.innerHTML = `<a href="${r.url}">${escapeHtml(r.title)}</a>
<p class="result-section">${escapeHtml(r.section)}</p>
<p class="result-excerpt">${highlight(r.excerpt, q)}</p>`;
return li;
}));
status.textContent = results.length
? `${results.length} result${results.length === 1 ? '' : 's'} for ${q}`
: `No results for ${q}. Try a shorter or different term.`;
}
Highlighted terms use <mark>, which screen readers can announce and which has a default non-colour styling (a background); add font-weight: 600 so it is not colour alone.
The combobox pattern — arrow keys move a virtual cursor through results while focus stays in the input, with aria-activedescendant — is what many search modals imitate. Done correctly it is efficient; done partly, as in the old modal, screen readers announce nothing as the selection moves. The team implemented the list pattern first, tested it, and added arrow-key support later only as an enhancement: ArrowDown from the input moves real focus to the first result link, and arrows move between links, so nothing depends on aria-activedescendant.
Step 3: Announce, but Not Too Much
The status region is role="status" (implicitly aria-live="polite") and is updated only after search results are rendered, which with a 300 ms debounce means once per pause in typing. Announcing on every keystroke queued a dozen announcements in NVDA and made the dialog unusable; announcing nothing left VoiceOver users unsure whether search had run at all. The message includes the query ("12 results for cache headers") so it makes sense on its own.
Step 4: The Keyboard Shortcut
Ctrl+K / Cmd+K opens search and is shown on the trigger button and exposed with aria-keyshortcuts. The previous / shortcut was removed: single-character shortcuts conflict with screen reader quick navigation keys and must be switchable off under WCAG 2.1.4. If a single-key shortcut is important to your readers, offer a setting to disable it and ignore it when focus is in any editable field.
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
dialog.open ? dialog.close() : trigger.click();
}
});
Step 5: Zoom, Contrast and Motion
The dialog uses width: min(42rem, 100vw - 2rem) and max-height: calc(100dvh - 2rem) with the results list scrolling inside, so at 400% zoom the whole dialog stays within the viewport and nothing is clipped. Excerpt text uses the site's body colour tokens in both themes; the old modal's grey excerpt text at 3.2:1 in dark mode was the contrast failure. The open animation is a 120 ms fade that is removed entirely under prefers-reduced-motion: reduce.
Testing It
Automated checks run on every pull request: axe on the page with the dialog open, empty, with results and with no results, in both themes, as in Accessibility Testing with axe and pa11y in CI. Playwright tests assert the behaviour axe cannot: focus moves into the input on open, Tab cannot reach the page behind, Escape closes and focus returns to the trigger, and the status region contains the count after typing. Manual screen reader passes with NVDA and VoiceOver happen before each release of the component and quarterly.
Measured Impact
| Measure | Third-party modal | Custom accessible dialog |
|---|---|---|
| Audit issues | 11 | 0 |
| axe violations with dialog open | 3 | 0 |
| JavaScript for the search UI (brotli) | 41 KB | 6 KB (+ Pagefind runtime on open) |
| Keyboard-only task success (5 testers, find 3 pages) | 9 of 15 | 15 of 15 |
| Screen reader task success (3 testers) | 4 of 9 | 9 of 9 |
| Searches ending in a result click (all readers) | 58% | 63% |
Pitfalls & Rollback
- Fake dialogs built from divs. Use
<dialog>withshowModal(); recreating inertness by hand is error-prone. - Announcing every keystroke. Debounce the status update.
- Partial combobox implementations. Worse than a plain list; do it fully or not at all.
- Single-key shortcuts. Use a modifier or make them switchable.
- Rollback: the dialog is one component; the previous widget can be restored by swapping the component import.
Conclusion
An accessible search dialog on a static site is mostly native HTML used correctly: <dialog> with showModal(), a labelled search field, a polite status region, and results as a list of real links. Add a modifier-key shortcut, zoom-safe sizing, theme-aware contrast and reduced-motion support, and test both automatically and with screen readers. It is smaller than the third-party widget it replaced and everyone, not only assistive technology users, found pages faster with it.
FAQ
Should site search open in a modal dialog?
A dialog is a good fit for a search that overlays the page, as long as it uses the native dialog element or correct ARIA dialog semantics, moves focus into the search field on open, traps focus while open, closes on Escape and returns focus to the button that opened it.
Do search results need to be a combobox?
Not necessarily. A simpler and robust pattern is a search field followed by a list of result links, with a live region announcing the number of results. The combobox pattern with arrow-key navigation is more complex and easy to get wrong; implement it only if you can test it with screen readers.
How should result counts be announced?
Through a polite live region that updates after typing pauses, for example saying 12 results for cache headers. Announcing on every keystroke is noisy, and announcing nothing leaves screen reader users unsure whether search ran.
Is the keyboard shortcut to open search an accessibility problem?
Single-character shortcuts such as slash can conflict with assistive technology and must be possible to turn off or remap under WCAG 2.1. Shortcuts with a modifier, such as Ctrl plus K, are safer. Always show the shortcut and keep a visible button.
Related
- Parent: Search for Static Sites — choosing the search backend.
- Adding Pagefind to an Astro Site — the index behind the dialog.
- Tracking Zero-Result Searches — improving what the dialog returns.
- Evaluating SSG Accessibility Defaults — accessibility beyond search.
- End-to-End Tests for Static Sites with Playwright — the behavioural tests.
Filed underHow-tos