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:

IssueWCAG criterion
Focus not moved into the dialog on open2.4.3 Focus Order
Focus could leave the open dialog with Tab2.4.3 Focus Order
No accessible name for the dialog4.1.2 Name, Role, Value
Result count never announced4.1.3 Status Messages
Arrow-key navigation not announced (fake combobox)4.1.2 Name, Role, Value
/ shortcut could not be disabled2.1.4 Character Key Shortcuts
Highlighted terms conveyed by colour only1.4.1 Use of Color
Results text contrast 3.2:1 in dark mode1.4.3 Contrast (Minimum)
Dialog content clipped at 400% zoom1.4.10 Reflow
Closing did not return focus to the trigger2.4.3 Focus Order
Animation ignored reduced-motion preference2.3.3 Animation from Interactions
Accessibility issues found in the old search modal by principle Vertical bars of issue counts grouped by WCAG principle. Operable 5 issues, covering focus order, focus trapping, the character shortcut, focus return and animation. Robust 2 issues, covering the dialog name and the fake combobox. Perceivable 4 issues, covering the status message, colour-only highlighting, contrast and reflow. Audit findings by WCAG principle 5 Operable 4 Perceivable 2 Robust
Eleven issues from a manual audit with NVDA, VoiceOver and keyboard-only testing; axe had flagged three of them.

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.

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.

Links list pattern compared with the combobox pattern Two panels. The list of links keeps focus in real elements, needs only a live region and native links, and works with every screen reader tested. The combobox pattern keeps focus in the input with aria-activedescendant, needs listbox and option roles plus careful state updates, and was silent in two of four screen reader and browser combinations when implemented partially. Two result patterns Input + list of links real focus moves to links live region announces count native links, no custom roles arrow keys move real focus passed in 4 of 4 AT combinations Combobox with activedescendant focus stays in the input listbox, option, aria-selected every state change must be exact silent in 2 of 4 when partial
The list pattern plus arrow keys that move real focus gave keyboard users speed without the combobox's fragility.

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.

Testing layers for the accessible search dialog axe runs on four dialog states in two themes on every pull request. Playwright asserts focus on open, focus containment, Escape closing, focus return and the live region text. Manual testing with NVDA and VoiceOver runs before each component release and quarterly. Findings from manual tests become new Playwright assertions where possible. Three layers of checks for the search dialog axe 4 states x 2 themes Playwright focus, Escape, live region Manual AT pass NVDA, VoiceOver New assertions from manual findings
Manual findings feed the automated suite, so each issue is only ever found by hand once.

Measured Impact

MeasureThird-party modalCustom accessible dialog
Audit issues110
axe violations with dialog open30
JavaScript for the search UI (brotli)41 KB6 KB (+ Pagefind runtime on open)
Keyboard-only task success (5 testers, find 3 pages)9 of 1515 of 15
Screen reader task success (3 testers)4 of 99 of 9
Searches ending in a result click (all readers)58%63%

Pitfalls & Rollback

  • Fake dialogs built from divs. Use <dialog> with showModal(); 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.

Filed underHow-tos