Skip to content

Accessibility

PWA accessibility is web accessibility plus the problems that come from behaving like an app: client-side routing that never triggers a page load, so screen readers don't hear that anything changed; standalone windows that remove the browser's back button, reload button and address bar; offline states, install prompts, notifications and custom title bars that have no built-in semantics. This page covers those PWA-specific mechanisms in depth, with production code, the WCAG 2.2 success criteria they map to, and how to test them with automated tools and real screen readers.

Key takeaways

  • Build the app shell from native landmarks (header, nav, main) and native controls. A bottom tab bar is navigation (<nav> with links and aria-current="page"), not an ARIA tablist.
  • On every client-side route change: update document.title, move focus to the new view's heading (or main), and restore scroll deliberately. The Navigation API's intercept() resets focus to <body> by default. Take control with focusReset: "manual".
  • Announce asynchronous status (offline, sync complete, update available) with a live region that exists at page load, or with ariaNotify() (Chrome 141, Firefox 150, Safari 27) where available. These are WCAG 4.1.3 status messages.
  • Standalone mode removes back, reload, find and the URL bar. Provide in-app equivalents, never disable zoom, and keep document.title meaningful because it becomes the window and task-switcher name.
  • Respect prefers-reduced-motion, prefers-contrast and forced-colors, and check theme colors and focus indicators for contrast in both light and dark schemes.
  • WCAG 2.2 (ISO/IEC 40500:2025) adds criteria that PWAs commonly fail: 2.4.11 Focus Not Obscured (sticky bars), 2.5.7 Dragging Movements, 2.5.8 Target Size (Minimum) and 3.3.8 Accessible Authentication.
  • Automated tools such as axe-core find a subset of issues. Test every release with a keyboard and at least one screen reader per platform you ship to: VoiceOver, TalkBack and NVDA.

Why PWAs need extra accessibility work

Browsers give traditional multi-page sites a lot of accessibility for free. When a link loads a new document, the screen reader announces the new title, focus resets to the top of the page, and the user can press back, reload or zoom with the browser's controls. PWAs undo several of these defaults:

Browser default What a PWA changes Consequence Section
New page load announces the title SPA routing swaps DOM without a load Screen reader users hear nothing Route changes
Focus resets on navigation Focus stays on the clicked link, which may be removed from the DOM Focus is lost to <body>; keyboard users start over Route changes
Errors are pages Network failures become in-page states Silent failure unless announced Offline and online
Browser UI: back, reload, zoom, find standalone and fullscreen remove it Lost controls, especially for keyboard and switch users Standalone mode
Native controls Custom tab bars, sheets, switches, menus Missing roles, states and keyboard support Custom controls
OS title bar Window Controls Overlay puts your UI there Small targets, drag regions unreachable by keyboard WCO title bars

A semantic app shell

The app shell is rendered on every screen, so its semantics are multiplied across the whole app. Get it right once:

index.html (body)
<body>
  <a class="skip-link" href="#main">Skip to content</a>

  <header class="app-header">
    <!-- The visible app name is not the page's h1: each view has its own h1. -->
    <a href="/" class="app-name">Mailbird</a>
    <button type="button" class="icon-button" aria-label="Search" aria-keyshortcuts="Control+K Meta+K">
      <svg aria-hidden="true" focusable="false"><use href="#i-search"/></svg>
    </button>
  </header>

  <nav class="app-nav" aria-label="Main">
    <ul role="list">
      <li>
        <a href="/inbox" aria-current="page">
          Inbox
          <!-- aria-label on a plain <span> is prohibited (generic role) and ignored by
               several screen readers, so give the badge real text instead. -->
          <span class="count" aria-hidden="true">12</span>
          <span class="visually-hidden">, 12 unread</span>
        </a>
      </li>
      <li><a href="/sent">Sent</a></li>
      <li><a href="/settings">Settings</a></li>
    </ul>
  </nav>

  <main id="main" tabindex="-1">
    <h1 id="view-title">Inbox</h1>
    <!-- view content -->
  </main>

  <!-- Created at load, empty: live regions must exist before content is injected. -->
  <div id="announcer" class="visually-hidden" aria-live="polite" aria-atomic="true"></div>
  <div id="announcer-assertive" class="visually-hidden" aria-live="assertive" aria-atomic="true"></div>
</body>
a11y.css
/* Hidden visually, still in the accessibility tree. */
.visually-hidden {
  position: absolute !important;
  inline-size: 1px;
  block-size: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* Skip link: visible when focused (WCAG 2.4.1 Bypass Blocks). */
.skip-link {
  position: absolute;
  inset-inline-start: 0.5rem;
  inset-block-start: -3rem;
  z-index: 1000;
  padding: 0.5rem 1rem;
  background: var(--surface-2);
  color: var(--text);
}
.skip-link:focus { inset-block-start: calc(0.5rem + env(safe-area-inset-top, 0px)); }

/* main receives programmatic focus but shouldn't show a ring around the page. */
main:focus { outline: none; }

Design decisions in this shell:

  • Landmarks. header at the top level maps to the banner landmark, nav to navigation (labeled "Main" to distinguish it from other navs), main to main. Screen reader users jump between landmarks with a single key (NVDA D, VoiceOver rotor, TalkBack's landmark navigation).
  • The tab bar is navigation. Bottom tab bars look like ARIA tabs, but they change the route and URL. ARIA tablist/tab means "switches panels within this page" and implies arrow-key navigation and aria-selected. Use links in a nav, and mark the current one with aria-current="page".
  • role="list" on the ul restores list semantics that Safari/VoiceOver removes from lists styled with list-style: none.
  • Badges need text. A red "12" has no meaning out of context. The visible digits are hidden from assistive technology and replaced by visually hidden text, so the link's accessible name becomes "Inbox, 12 unread". Don't reach for aria-label on the badge <span>: ARIA 1.2 prohibits naming the generic role, and screen readers are inconsistent about reading it. When the count updates live, don't make it a live region, since it would interrupt constantly. Announce only significant changes.
  • One h1 per view, set by the view, not the shell. It becomes the focus target on route changes.
  • lang on <html> and matching lang/dir members in the manifest (Members Reference), so screen readers pick the right voice and the OS labels the app correctly.

SPA route changes: focus, title and announcements

When a client-side router renders a new view, three things must happen that a full page load would do for you. The recipe most accessible SPAs converge on is:

  1. Update document.title to describe the new view (WCAG 2.4.2 Page Titled). In an installed app the title is also the window name in the task switcher, Alt+Tab and the Dock.
  2. Move focus to the new view's h1 (made focusable with tabindex="-1"), or to main if the view has no heading. Focusing the heading makes the screen reader read it, which doubles as the announcement, and puts the keyboard user at the start of the new content (WCAG 2.4.3 Focus Order).
  3. Reset or restore scroll: scroll to top for new navigations, restore the previous position for back/forward.

Some routes shouldn't move focus: filtering a list with query parameters, switching a sort order, or opening a detail pane beside a list in a two-pane layout. There, keep focus where it is and announce the result through a live region ("24 results").

Why the Navigation API default isn't enough

The Navigation API (Chrome 102, Firefox 147, Safari 26.2) has built-in focus handling. After a navigation intercepted with event.intercept() finishes, the default focusReset: "after-transition" focuses the first element with the autofocus attribute, or <body> if there is none. Focusing <body> is better than leaving focus on a removed element, but screen readers don't announce anything useful when <body> receives focus. Either put autofocus on the view's heading or use focusReset: "manual" and manage focus yourself, which gives you control over the cases above.

A route-change accessibility module

src/a11y/route-focus.js
import { announce } from "./announce.js";

/**
 * Call after a view has been rendered and committed to the DOM.
 *
 * @param {object} options
 * @param {string} options.title  Human-readable view title, e.g. "Inbox (12 unread)".
 * @param {"push"|"replace"|"traverse"|"reload"} options.navigationType
 * @param {boolean} [options.moveFocus=true]  false for in-place updates (filters, panes).
 * @param {{x:number,y:number}|null} [options.savedScroll]  For traverse navigations.
 */
export function afterRouteChange({ title, navigationType, moveFocus = true, savedScroll = null }) {
  // 1. Title: "View · App" keeps the view first, which is what AT reads first.
  document.title = `${title} · Mailbird`;

  // 2. Scroll. Do this before focusing so focus() doesn't fight the restore.
  if (navigationType === "traverse" && savedScroll) {
    window.scrollTo(savedScroll.x, savedScroll.y);
  } else if (navigationType !== "reload") {
    window.scrollTo(0, 0);
  }

  if (!moveFocus) {
    announce(`${title} loaded`);
    return;
  }

  // 3. Focus: the view's h1, else main.
  const target =
    document.querySelector("main h1") ?? document.getElementById("main");
  if (!target) return;
  if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1");

  // preventScroll: we already positioned the page; traverse restores must stick.
  target.focus({ preventScroll: navigationType === "traverse" });

  // Safety net: some screen reader and browser combinations don't speak a focused
  // non-interactive heading. If focus didn't land, announce instead.
  if (document.activeElement !== target) {
    announce(`${title} loaded`);
  }
}

Wire it into the router. With the Navigation API:

src/router.js (excerpt)
import { afterRouteChange } from "./a11y/route-focus.js";

const scrollPositions = new Map(); // NavigationHistoryEntry.key -> {x, y}

navigation.addEventListener("navigate", (event) => {
  if (!event.canIntercept || event.hashChange || event.downloadRequest !== null) return;
  const route = matchRoute(new URL(event.destination.url));
  if (!route) return;

  // Remember where we were before leaving this entry.
  scrollPositions.set(navigation.currentEntry.key, { x: scrollX, y: scrollY });

  event.intercept({
    focusReset: "manual", // we handle focus: <body> focus announces nothing useful
    scroll: "manual", // and scroll, so we can order scroll before focus
    async handler() {
      const data = await route.load(event.signal);
      route.render(data);
      afterRouteChange({
        title: route.title(data),
        navigationType: event.navigationType,
        moveFocus: !route.inPlace,
        savedScroll: scrollPositions.get(event.destination.key) ?? null,
      });
    },
  });
});

Frameworks differ in what they do by default. Some ship their own route announcer (Next.js does), some give you a building block (Angular CDK's LiveAnnouncer), and others do nothing. Check yours, and don't stack a second announcement on top of one the framework already makes: users then hear every title twice. Framework specifics are on Framework Integrations.

Loading states between routes

If a route takes more than a moment to load, give the pending state semantics:

  • set aria-busy="true" on main while loading, and remove it when done,
  • show a visible progress indicator with role="progressbar" or a text "Loading…" message,
  • don't move focus to a skeleton screen: skeletons have no content. Move focus when the real heading exists.

If you animate route changes, see View Transitions: the transition itself doesn't move focus, and input is blocked while it runs.

Announcing offline, online and sync status

Status changes that happen without user action, like losing the network, finishing a background sync or an update becoming available, are status messages in WCAG terms. Criterion 4.1.3 (Status Messages, AA) requires that assistive technology can present them without receiving focus. That means live regions, or the newer ariaNotify().

A robust announcer

Live regions have sharp edges. A region only announces changes after the browser has registered it, so it must be in the DOM (and empty) before you write to it. Setting the same text twice doesn't re-announce. Some screen readers drop announcements made in the same tick as a focus change. This module handles those, and prefers ariaNotify() where supported:

src/a11y/announce.js
/**
 * Announce a message to assistive technology without moving focus.
 * Uses Document.ariaNotify() where available (Chrome 141, Firefox 150, Safari 27),
 * otherwise the live regions created in index.html.
 *
 * @param {string} message
 * @param {{ priority?: "normal" | "high" }} [options]  "high" interrupts (assertive).
 */
export function announce(message, { priority = "normal" } = {}) {
  if (!message) return;

  if (typeof document.ariaNotify === "function") {
    document.ariaNotify(message, { priority });
    return;
  }

  const id = priority === "high" ? "announcer-assertive" : "announcer";
  const region = document.getElementById(id);
  if (!region) {
    console.warn(`[announce] #${id} missing; message dropped: ${message}`);
    return;
  }

  // Clear first, then set after a short delay, so identical consecutive messages
  // are announced and the update isn't coalesced with a focus change.
  region.textContent = "";
  clearTimeout(region._timer);
  region._timer = setTimeout(() => {
    region.textContent = message;
  }, 100);
}

ariaNotify(announcement, { priority }) accepts "normal" (the default, like aria-live="polite") or "high" (like assertive). It returns undefined, needs no user activation, silently does nothing when the aria-notify Permissions Policy blocks it, and uses the lang of the document element (or the user agent default) to select a voice. Element.ariaNotify() exists as well and behaves the same way. MDN notes two behaviors that shape how you use it: aria-live announcements take priority over ariaNotify() announcements, and screen readers typically speak only the most recent notification, so several calls in quick succession can collapse into the last one. Combine related messages into a single call ("3 messages sent. You're back online.") instead of firing them one after another. With support arriving in Safari only at version 27, keep the live-region fallback for older engines.

Network status

src/a11y/network-status.js
import { announce } from "./announce.js";

const banner = document.getElementById("offline-banner"); // visible, persistent UI

function render(online, { announceChange }) {
  banner.hidden = online;
  document.documentElement.toggleAttribute("data-offline", !online);
  if (!announceChange) return;
  announce(
    online
      ? "You're back online. Changes are syncing."
      : "You're offline. You can keep working; changes will sync when you reconnect.",
  );
}

// Initial state: render silently (the user didn't do anything yet).
render(navigator.onLine, { announceChange: false });

// navigator.onLine === true only means "a network interface is up". The offline
// event is reliable; the online event can be optimistic, so confirm with a request.
addEventListener("offline", () => render(false, { announceChange: true }));
addEventListener("online", async () => {
  try {
    // The service worker must pass /api/ping straight to the network: a cached or
    // synthetic offline response would make this check meaningless.
    const response = await fetch("/api/ping", { method: "HEAD", cache: "no-store" });
    if (!response.ok) return; // captive portal or server trouble: don't claim "online"
    render(true, { announceChange: true });
  } catch {
    /* still effectively offline; stay quiet */
  }
});

Guidelines for connectivity UX (the visual side is covered in depth on Offline UX & Fallbacks):

  • Polite, once. Announce transitions, not the state on every screen. Use "high" priority only if the user is about to lose work.
  • Visible and persistent, not a toast that disappears after four seconds. A timed toast fails users who need more time to read (WCAG 2.2.1 Timing Adjustable) and screen magnifier users who never see it.
  • Not by color alone (WCAG 1.4.1). A grayed-out UI or a red dot isn't enough: include text such as "Offline".
  • Don't disable everything. Disabled controls are skipped by Tab in most browsers and read as unavailable. Keep actions usable, queue them (see Background Sync), and say so.
  • Queued actions need feedback. "Message queued. It will be sent when you're back online" is a status message too, and "3 messages sent" when the sync completes.

Service worker updates

"A new version is available" is also a status message. Announce it politely, show a persistent control ("Reload to update") that's reachable by keyboard, and never reload automatically while the user is typing. Reloading moves focus to the top of the page and can discard input. Update flows are covered on Updating Service Workers.

ARIA for custom app controls

The first rule of ARIA applies doubly to PWAs: use the native element when it exists. Native <button>, <dialog>, <details>, <select>, checkboxes (Safari 17.4+ also renders <input type="checkbox" switch> as a native switch) and the popover attribute come with keyboard handling and accessibility semantics built in. When you do need custom widgets, follow the ARIA Authoring Practices Guide (APG) patterns exactly. Screen reader users rely on the keyboard conventions each role implies.

Common app widgets and their correct semantics

App UI Use Keyboard Common mistake
Bottom tab bar / navigation rail <nav> + links + aria-current="page" Tab role="tablist" for route navigation
In-view tabs (e.g. "Details / Reviews") APG Tabs: tablist, tab with aria-selected, aria-controls, tabpanel Arrow keys between tabs, Tab into panel Every tab in the Tab order
Modal bottom sheet, full-screen dialog <dialog> + showModal() Esc closes, focus trapped by the browser div overlay without inert on the page behind
Non-modal sheet, menu popover attribute or APG Menu Button Esc closes, focus returns to the invoker No way back to the trigger
Toggle setting <button aria-pressed> or role="switch" with aria-checked Space Clickable div
Floating action button <button> with visible or aria-label name Enter/Space Icon-only with no accessible name
Swipe-to-delete row A visible "Delete" button or a menu; swipe as a shortcut Tab to the button Swipe as the only way (fails 2.5.1)
Drag-to-reorder list "Move up/down" buttons or a menu; drag as enhancement Buttons Drag-only (fails 2.5.7)
Pull-to-refresh A "Refresh" button Enter Gesture-only
Toast / snackbar Live region; actions also available elsewhere n/a Auto-dismissing toast with the only "Undo"

Dialogs and sheets with native <dialog>

<dialog> opened with showModal() is the right base for sheets and modal screens: the browser makes the rest of the page inert, traps focus, closes on Esc (firing cancel), and puts the dialog in the top layer. Invoker commands (commandfor/command attributes on buttons, supported in Chrome 135, Firefox 144 and Safari 26.2) open and close dialogs declaratively:

share-sheet.html
<button type="button" commandfor="share-sheet" command="show-modal">Share</button>

<dialog id="share-sheet" class="sheet" aria-labelledby="share-title">
  <h2 id="share-title">Share this list</h2>
  <!-- first focusable element receives focus when the dialog opens -->
  <button type="button" class="copy-link">Copy link</button>
  <button type="button" commandfor="share-sheet" command="close">Cancel</button>
</dialog>
src/sheet.js
// Fallback for browsers without invoker commands (commandfor/command).
if (!("commandForElement" in HTMLButtonElement.prototype)) {
  document.addEventListener("click", (event) => {
    // Delegated, so buttons rendered later by the router are covered too.
    const invoker = event.target.closest("button[commandfor]");
    if (!invoker) return;
    const dialog = document.getElementById(invoker.getAttribute("commandfor"));
    if (!(dialog instanceof HTMLDialogElement)) return;
    const command = invoker.getAttribute("command");
    if (command === "show-modal" && !dialog.open) dialog.showModal();
    else if (command === "close" && dialog.open) dialog.close();
  });
}

Two app-specific details: on Android the system back gesture should close an open sheet instead of leaving the screen (push a history entry when opening, or use the Navigation API; see App-Like UX Patterns), and focus must return to the invoking button on close, which <dialog> does for you when opened with showModal().

Roving tabindex for toolbars and in-view tabs

Composite widgets (toolbars, tab lists, grids) should be a single Tab stop with arrow keys inside:

src/a11y/roving-tabindex.js
/**
 * Make a set of controls a single tab stop, navigable with arrow keys.
 * @param {HTMLElement} container  e.g. [role=tablist] or [role=toolbar]
 * @param {string} itemSelector   e.g. '[role=tab]'
 * @param {{ orientation?: "horizontal"|"vertical", onActivate?: (el: HTMLElement) => void }} [opts]
 */
export function rovingTabindex(container, itemSelector, { orientation = "horizontal", onActivate } = {}) {
  const items = () => [...container.querySelectorAll(itemSelector)].filter((el) => !el.disabled);
  const prevKey = orientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
  const nextKey = orientation === "horizontal" ? "ArrowRight" : "ArrowDown";

  function setActive(el, { focus = true } = {}) {
    for (const item of items()) item.tabIndex = item === el ? 0 : -1;
    if (focus) el.focus();
    onActivate?.(el);
  }

  container.addEventListener("keydown", (event) => {
    const list = items();
    const i = list.indexOf(document.activeElement);
    if (i === -1) return;
    const rtl = getComputedStyle(container).direction === "rtl" && orientation === "horizontal";
    let next = null;
    if (event.key === (rtl ? prevKey : nextKey)) next = list[(i + 1) % list.length];
    else if (event.key === (rtl ? nextKey : prevKey)) next = list[(i - 1 + list.length) % list.length];
    else if (event.key === "Home") next = list[0];
    else if (event.key === "End") next = list[list.length - 1];
    if (next) {
      event.preventDefault();
      setActive(next);
    }
  });

  // Initialize: the selected item (or the first) is the tab stop.
  const selected = items().find((el) => el.getAttribute("aria-selected") === "true") ?? items()[0];
  if (selected) setActive(selected, { focus: false });
}

Standalone mode: replacing the browser UI

In standalone, fullscreen and window-controls-overlay display modes the browser's toolbar is gone (see Display Modes for the per-platform breakdown). For users of assistive technology these controls often matter more than for anyone else:

Lost control Who depends on it What to provide
Back button Switch and voice control users ("click Back"), screen reader users on desktop A visible, labeled in-app back button in app-like modes. Android's system back still works; handle it.
Reload Anyone recovering from a stuck state "Try again" on error states, a Refresh control where data goes stale
Zoom controls Low-vision users Keep pinch zoom and keyboard zoom working; support 200% zoom and text scaling
Find in page Screen magnifier and cognitive accessibility users In-app search where content is long
Address bar (URL, copy link) Users who share or bookmark Share / Copy link action
Text size menu (some browsers) Low-vision users Respect OS text size; optionally an in-app text size setting

Desktop Chromium app windows keep the browser's keyboard shortcuts (Alt+Left for back, Ctrl/Cmd+R for reload, Ctrl/Cmd++ for zoom, Ctrl/Cmd+F for find), but nothing on screen tells users they exist, and voice-control users can't "click" a shortcut. iOS and iPadOS web apps have no back or reload control at all.

Never disable zoom

Don't do this
<!-- Fails WCAG 1.4.4 Resize Text: blocks pinch zoom where honored. -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">

Safari on iOS has ignored user-scalable=no since iOS 10 for accessibility reasons, and Chrome on Android has a "Force enable zoom" accessibility setting, but you can't count on every context and every user overriding it. The usual motivation (avoiding the iOS auto-zoom on focusing inputs smaller than 16 px) has a better fix: give form controls a font size of at least 1rem (16 px).

Respect the user's text size

Browser zoom isn't the only way people enlarge text. OS-level text size settings are separate:

  • iOS and iPadOS: Safari maps Dynamic Type to the -apple-system-body (and -apple-system-headline, and so on) system fonts. Setting font: -apple-system-body on html and sizing everything else in rem/em makes your web app follow the user's Dynamic Type size, like native apps. The size at the default Dynamic Type setting isn't exactly 16 px, so check your layout after adopting it.
  • Android: Chrome applies its own text scaling setting, which follows the OS font size by default. Chromium 146 added the experimental <meta name="text-scale" content="scale">, which makes the root element's default font size scale with both the OS and the browser text-size settings and, in exchange, turns off the browser's own heuristics such as mobile text autosizing. It only helps if your sizes are in rem/em and your root font size isn't set in px. Don't combine it with env(preferred-text-scale) (Chromium 138, which exposes the scale factor for you to apply yourself), or scaling is applied twice.
text-size.css
html {
  font-size: 100%; /* never a px value: it would override user and OS preferences */
}

/* Scoped to touch devices: on macOS the system text styles map to smaller desktop sizes. */
@supports (font: -apple-system-body) {
  @media (pointer: coarse) {
    html { font: -apple-system-body; } /* follow iOS Dynamic Type */
    body { font-family: system-ui, sans-serif; } /* keep your own font family */
  }
}

/* Inputs at 16px or larger: prevents iOS focus auto-zoom without disabling zoom. */
input, select, textarea { font-size: max(1rem, 16px); }

Test at 200% text size (WCAG 1.4.4) and, on mobile, at the largest accessibility text sizes: fixed-height app bars and bottom tab bars with px heights clip or overflow first. The responsive layout techniques on Responsive & Adaptive Design help here, because large text moves the app into smaller size classes.

Titles, names and the task switcher

In an app window, document.title is the window title (read by screen readers when switching windows) and appears in Alt+Tab, the taskbar, the Dock menu and Android's recents. Put the view first and the app name last ("Inbox (12 unread) · Mailbird"), keep it short, and update it on every route change. The manifest name and short_name are what screen readers announce on the home screen and in the launcher, so make short_name a real word, not an abbreviation that TalkBack spells letter by letter.

Theme color, contrast and forced colors

Installed PWAs paint parts of the OS chrome with your colors: the title bar on desktop, the status bar on Android, the splash screen background. Contrast rules apply to anything you control:

  • Text contrast of at least 4.5:1 (3:1 for large text) is WCAG 1.4.3. Non-text contrast of 3:1 applies to UI component boundaries, icons that convey meaning and focus indicators (WCAG 1.4.11).
  • Title bar text on desktop Chromium and the Android status bar icons are drawn by the browser or OS, which chooses light or dark foregrounds based on your theme_color. Mid-luminance brand colors (saturated oranges, mid-blues) can produce borderline contrast for that system text, which you can't change. Test the actual colors in both light and dark mode.
  • Dark mode: provide separate theme-color values with media="(prefers-color-scheme: dark)" so the title bar doesn't stay bright when the page is dark. Splash Screens & Theming covers the details.
  • Focus indicators must be visible against the surface they're on. Title bars and bottom bars are often dark or saturated: use a focus color with 3:1 contrast against that background, not only against your content background. WCAG 2.4.13 (Focus Appearance, AAA) gives a stricter target: an indicator at least as large as a 2 CSS pixel perimeter with 3:1 contrast between focused and unfocused states.
focus.css
:root {
  --focus-ring: light-dark(#0b57d0, #a8c7fa);
}

:focus-visible {
  outline: 3px solid var(--focus-ring);
  outline-offset: 2px;
}

/* On colored bars, derive the ring from the bar's foreground color. */
.app-header :focus-visible,
.app-nav :focus-visible {
  outline-color: currentColor;
}

/* Windows contrast themes: use system colors and keep boundaries visible. */
@media (forced-colors: active) {
  :focus-visible { outline: 3px solid Highlight; }
  .app-nav [aria-current="page"] { border-block-end: 3px solid Highlight; }
  .card { border: 1px solid CanvasText; }
}

In forced colors mode (Windows contrast themes), the browser replaces your colors with the user's system palette, strips box-shadow, and ignores background images. Meaning conveyed only by a shadow or a background tint disappears. Borders and outlines survive, so add transparent borders where boundaries matter: they become visible in forced colors mode and stay invisible otherwise.

Motion and reduced motion

App-like UIs are full of motion: sheet slides, route transitions, parallax headers, animated skeletons. Motion triggered by interaction can cause dizziness and nausea for people with vestibular disorders. The relevant criteria are 2.3.3 Animation from Interactions (AAA) and 2.2.2 Pause, Stop, Hide (A) for anything that moves automatically for more than five seconds, such as carousels and looping animations.

motion.css
/* Opt motion in. Reduced motion still gets instant state changes and subtle fades. */
@media (prefers-reduced-motion: no-preference) {
  .sheet { transition: translate 250ms cubic-bezier(0.2, 0, 0, 1); }
  .skeleton { animation: shimmer 1.2s linear infinite; }
}

@media (prefers-reduced-motion: reduce) {
  .skeleton { background: var(--skeleton-static); } /* no shimmer */
}
src/motion.js
// Read the preference in JS for scripted animations and view transitions,
// and react to changes: users can toggle it while the app is open.
export const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
reducedMotion.addEventListener("change", () => {
  document.documentElement.toggleAttribute("data-reduced-motion", reducedMotion.matches);
});
document.documentElement.toggleAttribute("data-reduced-motion", reducedMotion.matches);

View transitions don't respect the preference automatically. The patterns are on View Transitions.

Keyboard access in Window Controls Overlay title bars

With Window Controls Overlay, your own UI occupies the title bar. That area has constraints that don't exist elsewhere:

  • Drag regions are not keyboard accessible. An element with Chromium's long-standing app-region: drag, or the standardized window-drag: move from CSS Basic User Interface Level 4 (Chrome 152, still marked experimental by MDN), becomes a window-move handle: the browser fires no pointer, mouse or drag events on it during a window move, and it can't be activated by the keyboard. Never put the only copy of a control, or a clickable title, inside a drag region. Mark interactive elements app-region: no-drag (or window-drag: none; the property is inherited, so children of a drag region are drag handles too unless you reset them).
  • Tab order follows the DOM. The title bar comes first visually, so it should come first in the DOM and the tab order. The OS window controls (minimize, maximize, close) and the browser's app menu are browser UI, not in your tab order; users reach them with OS shortcuts.
  • The area is short, and zoom makes it shorter in CSS pixels. At 200% zoom a 32-pixel title bar is 16 CSS pixels tall. Targets must still meet WCAG 2.5.8 (24 by 24 CSS pixels, or enough spacing), so hide optional items at high zoom and make sure every title bar function also exists elsewhere.
  • Keep document.title meaningful. With the title bar hidden, the title is still the window's accessible name in the OS and in screen readers' window lists.
  • Provide a keyboard shortcut for the title bar's primary action (search is typical), and expose it with aria-keyshortcuts. Avoid single-character shortcuts, or let users turn them off or remap them (WCAG 2.1.4 Character Key Shortcuts).

Accessible notifications and badges

System notifications from push and the Notifications API are rendered by the OS, which makes them accessible to screen readers in the platform's usual way. Your job is the content and the alternatives:

  • Put the essential information in title and body. icon, image and badge are decorative for assistive technology and many platforms don't show image. "New message" with the sender only in an avatar image is useless to a screen reader user.
  • Action titles are the accessible names of buttons in the notification. Make them verbs ("Reply", "Mark as read"), not "OK". actions work in Chromium and, from Firefox 152, in Firefox. Safari ignores them, so every action must also be reachable in the app.
  • Time-sensitive does not mean interrupting. requireInteraction: true keeps a notification visible until the user acts (Chromium, and Firefox on Windows), which helps users who need more time to read. Don't use it for routine updates.
  • silent and vibrate control sound and vibration. Don't rely on either as the only signal.
  • Ask for permission in context, after a user action that explains the benefit. A permission prompt on page load interrupts screen reader users before they know where they are, and is also a poor pattern for everyone (Permissions).
  • Provide an in-app notification center. Notifications disappear, OS focus-assist modes suppress them, and on iOS they require the app to be installed (Web Push on iOS & Safari). Everything a notification says must be findable in the app.
  • Badges from the Badging API are a visual count on the app icon. Mirror the count in text inside the app (for example the unread count in the navigation, with an accessible name), because not every screen reader announces icon badges.

Forms, gestures and authentication

The WCAG 2.2 additions hit app-style interfaces particularly often:

  • 2.5.7 Dragging Movements (AA). Anything that can be dragged (reorder, sliders, map panning, swipe-to-dismiss) needs a single-pointer alternative without dragging: buttons, a menu, or tapping a position.
  • 2.5.1 Pointer Gestures (A). Multipoint or path-based gestures (pinch, two-finger swipe, swipe from edge) need single-tap alternatives.
  • 3.3.7 Redundant Entry (A). Don't make users retype information within a process. Offline-capable forms that persist drafts in IndexedDB help here naturally.
  • 3.3.8 Accessible Authentication (Minimum) (AA). Don't require a cognitive function test (remembering a password, transcribing a code) unless there's an alternative or assistance. Allow paste and password managers (correct autocomplete attributes: username, current-password, new-password, one-time-code), and offer passkeys. See Authentication & Passkeys.
  • 3.2.6 Consistent Help (A). If your app offers help (chat, contact link), keep it in the same place across views, including in the installed app where the website's footer may not exist.

WCAG 2.2 criteria that matter most for PWAs

WCAG 2.2 was published by the W3C in October 2023 and approved as the international standard ISO/IEC 40500:2025 in October 2025. It adds nine criteria to WCAG 2.1 and removes 4.1.1 Parsing. Most legal requirements still reference level AA of WCAG 2.1 or 2.2. This table maps the criteria to PWA features:

Criterion Level PWA-specific risk
1.3.4 Orientation AA Manifest orientation locks without an essential reason
1.4.1 Use of Color A Offline state shown only by graying out or a colored dot
1.4.3 Contrast (Minimum) AA Theme-colored bars, splash text, dark mode
1.4.4 Resize Text AA user-scalable=no, px root font size, fixed-height bars
1.4.10 Reflow AA App shells that scroll horizontally at 320 CSS px
1.4.11 Non-text Contrast AA Icon-only tab bars, focus rings on colored bars
1.4.13 Content on Hover or Focus AA Tooltips in dense desktop layouts
2.1.1 Keyboard / 2.1.2 No Keyboard Trap A Custom sheets, drag regions, canvas UI
2.1.4 Character Key Shortcuts A Desktop-style single-key shortcuts
2.2.1 Timing Adjustable A Auto-dismissing toasts with actions ("Undo")
2.2.2 Pause, Stop, Hide A Carousels, looping animations, auto-updating feeds
2.3.3 Animation from Interactions AAA Route transitions, parallax
2.4.2 Page Titled A SPA routes that never update document.title
2.4.3 Focus Order A Focus lost after route change; panes reordered visually
2.4.7 Focus Visible AA outline: none without replacement
2.4.11 Focus Not Obscured (Minimum) AA Sticky headers, bottom tab bars and install banners covering the focused element
2.4.13 Focus Appearance AAA Thin or low-contrast focus rings
2.5.1 Pointer Gestures A Swipe and pinch-only features
2.5.7 Dragging Movements AA Drag-to-reorder, swipe-to-dismiss
2.5.8 Target Size (Minimum) AA Dense toolbars, title bar buttons, tab bar icons
3.2.6 Consistent Help A Help links that move or vanish in the installed app
3.3.7 Redundant Entry A Multi-step flows that lose data offline
3.3.8 Accessible Authentication (Minimum) AA Blocking paste, no password manager support
4.1.2 Name, Role, Value A Custom switches, tabs, menus
4.1.3 Status Messages AA Offline/online, sync, update available, search result counts

(Bold: new in WCAG 2.2.)

Focus Not Obscured and sticky app bars

2.4.11 is the new criterion PWAs fail most. A sticky header and a sticky bottom tab bar together can hide a third of a phone screen. When a keyboard user tabs to a link near the bottom, the browser scrolls it into view behind the tab bar. Tell the browser about the obscured areas with scroll-padding:

sticky-bars.css
:root {
  --header-h: 3.5rem;
  --tabbar-h: 4rem;
  /* Scrolling into view (focus, fragment links, scrollIntoView) keeps clear of the bars. */
  scroll-padding-block-start: calc(var(--header-h) + env(safe-area-inset-top, 0px) + 0.5rem);
  scroll-padding-block-end: calc(var(--tabbar-h) + env(safe-area-inset-bottom, 0px) + 0.5rem);
}

@media (width >= 600px) {
  :root { --tabbar-h: 0rem; } /* the tab bar becomes a side rail */
}

Install banners, cookie notices and "update available" bars that stay fixed on screen need the same treatment, or must not cover focusable content.

The European Accessibility Act has applied since 28 June 2025 to many consumer-facing digital services in the EU, with EN 301 549 (which incorporates WCAG) as the harmonized standard. In the US, the Department of Justice's ADA Title II rule requires WCAG 2.1 AA for state and local government web content and mobile apps; an interim final rule published on 20 April 2026 moved the compliance dates to 26 April 2027 for entities serving 50,000 people or more and 26 April 2028 for smaller entities and special districts. An installed PWA is "web content" and a "mobile app" at the same time, so neither framing exempts it. This is technical context, not legal advice.

Testing PWA accessibility

No single method is enough. Automated rules catch a fraction of issues (missing names, contrast, invalid ARIA), while focus management, announcements and gesture alternatives need manual and screen reader testing.

Automated checks with axe-core

axe-core (4.13 as of September 2026) is the engine behind Lighthouse's accessibility category, axe DevTools and most CI integrations. Run it in your end-to-end tests against every route and every state (offline banner shown, dialog open, update available), because SPA states never appear as separate URLs:

tests/a11y.spec.js
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

const ROUTES = ["/", "/inbox", "/inbox/42", "/settings"];

async function expectNoViolations(page, context) {
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])
    .analyze();
  // Print a readable summary instead of a giant JSON diff.
  const summary = results.violations.map((v) => `${v.id} (${v.impact}): ${v.nodes.length} node(s)`);
  expect(summary, `axe violations in ${context}`).toEqual([]);
}

for (const route of ROUTES) {
  test(`no axe violations on ${route}`, async ({ page }) => {
    await page.goto(route);
    await expectNoViolations(page, route);
  });
}

test("offline state is accessible and announced", async ({ page, context }) => {
  await page.goto("/inbox");
  await page.evaluate(() => navigator.serviceWorker.ready.then(() => true)); // SW active
  await context.setOffline(true);
  await expect(page.getByText("You're offline", { exact: false })).toBeVisible();
  await expectNoViolations(page, "offline /inbox");
  await context.setOffline(false);
});

test("route change moves focus to the new heading and updates the title", async ({ page }) => {
  await page.goto("/inbox");
  await page.getByRole("link", { name: "Settings" }).click();
  await expect(page).toHaveTitle(/^Settings/);
  await expect(page.getByRole("heading", { level: 1 })).toBeFocused();
});

test("share sheet traps focus and restores it on Escape", async ({ page }) => {
  await page.goto("/inbox/42");
  const trigger = page.getByRole("button", { name: "Share" });
  await trigger.click();
  await expect(page.getByRole("dialog", { name: "Share this list" })).toBeVisible();
  await page.keyboard.press("Escape");
  await expect(trigger).toBeFocused();
});

More on test infrastructure for PWAs, including service worker control and offline emulation, is on Automated Testing. Lighthouse's accessibility audit (see Lighthouse & Auditing) runs a subset of the same rules on a single page load.

Manual keyboard pass

Unplug the mouse (or ignore the trackpad) and run through every core task:

  • Tab order follows the visual order in each size class (compact, medium, expanded).
  • A focus indicator is always visible and never hidden behind sticky bars.
  • Every route change puts focus somewhere sensible, and the title changes.
  • Dialogs and sheets trap focus, close with Esc, and return focus to their trigger.
  • Every gesture (swipe, drag, pull-to-refresh, long-press) has a keyboard-operable alternative.
  • Back, refresh, share and search are reachable in the installed app window without browser shortcuts.
  • The app works at 200% zoom and at 320 CSS pixels wide without horizontal scrolling.

Screen readers on each platform

Test with the screen reader and browser pairs your users actually have. Installed PWAs use the platform browser engine, so test in the installed window, not only in a tab:

Platform Screen reader Browser Turn on Useful checks
iOS / iPadOS VoiceOver (built in) Safari, Home Screen web app Settings → Accessibility → VoiceOver, or the Accessibility Shortcut (triple-click the side or Home button) Rotor navigation by headings and landmarks, route announcements, Dynamic Type
macOS VoiceOver (built in) Safari, Chrome app windows Cmd+F5 Window title on app switching, WCO title bar, live regions
Android TalkBack (built in) Chrome, installed WebAPK Settings → Accessibility → TalkBack, or hold both volume keys for the shortcut Swipe navigation order, custom actions, system back closing sheets
Windows NVDA (free, open source) Chrome, Edge, Firefox app windows Installer from nvaccess.org; Ctrl+Alt+N Browse vs focus mode on custom widgets, landmark (D) and heading (H) navigation
Windows Narrator (built in), JAWS (commercial) Edge, Chrome Win+Ctrl+Enter for Narrator Cross-check announcements that NVDA handles leniently

What to listen for in a PWA specifically:

  1. Launch: the app name and first view title are announced when the installed app opens.
  2. Route change: the new heading is announced once (not twice, not zero times).
  3. Offline: going offline and back online produces one polite announcement each.
  4. Update: "A new version is available" is announced and the reload control is reachable.
  5. Notifications: the notification's title, body and action names make sense without images.
  6. Title bar (desktop): the title bar controls are reachable and labeled, and the window name matches the current view.

Browser DevTools

  • Chrome DevTools: the Accessibility pane in Elements shows the computed name, role and ARIA state; enabling the full-page accessibility tree view shows the tree screen readers consume; the Rendering drawer emulates prefers-reduced-motion, prefers-color-scheme, forced-colors, prefers-contrast and vision deficiencies. CSS Overview reports low-contrast text across the page.
  • Firefox Accessibility Inspector checks contrast, keyboard focusability and missing names, and simulates color vision deficiencies.
  • Safari Web Inspector shows the accessibility node for each element in the Elements tab. Attach it to a Home Screen web app on a connected iPhone through the Develop menu.

See Browser DevTools for general PWA debugging.

Common pitfalls

  • Silent route changes. No title update, no focus move, no announcement. The single most common SPA accessibility failure.
  • Focus lost to <body> after the clicked element is removed by re-rendering, including after the Navigation API's default focus reset.
  • Live regions created on demand. A region inserted and filled in the same tick usually isn't announced. Create it at load.
  • Double announcements. The framework's route announcer plus your own plus a focused heading: users hear the title three times.
  • Tab bars as ARIA tabs. Route navigation marked up as tablist confuses screen reader users and requires arrow keys that aren't implemented.
  • Disabling zoom with user-scalable=no or maximum-scale=1.
  • px root font size, which overrides the user's browser and OS text size preferences.
  • Sticky bars obscuring focus (WCAG 2.4.11). Add scroll-padding.
  • Gesture-only features: swipe to delete, drag to reorder, pull to refresh.
  • Auto-dismissing toasts with actions, which disappear before screen reader and magnifier users can reach them.
  • Theme colors tested only in light mode, producing low-contrast title bars and status bars in dark mode.
  • Relying on axe alone. Zero violations says nothing about focus management, announcements or keyboard alternatives.

Further reading

On this site

External references