+1 (415) 843-4662

Accessible Ionic apps: WCAG 2.2, the European Accessibility Act, and an audit you can run this week

Two things changed the accessibility conversation for the mobile teams we work with. First, the European Accessibility Act became enforceable on 28 June 2025, which pulled consumer-facing mobile apps — banking, e-commerce, transport, ticketing, e-books — into scope for a lot of companies that previously treated accessibility as a website problem. Second, enterprise buyers started asking for an accessibility conformance statement in the RFP, and "we'll get to it after launch" stopped being an acceptable answer.

The good news for Ionic teams: you are building with web technology, so the entire mature web accessibility toolchain applies, and Ionic's components ship with sensible ARIA roles already. The bad news: a hybrid app has failure modes a website does not — routing that leaves the screen reader stranded, native font-scaling that your CSS ignores, modals and toasts that never announce themselves.

This is the working checklist we use on client audits, with the code.

What you actually have to meet

In practice everyone targets WCAG 2.2 level AA. The EAA points at the harmonised European standard EN 301 549, which for apps is essentially WCAG AA plus a few platform requirements. US teams selling to government or education land in the same place via Section 508 and ADA Title II. One target, many laws:

  • Perceivable: text alternatives, 4.5:1 contrast for body text, content that survives 200% text scaling.
  • Operable: everything reachable without a mouse, visible focus, no keyboard traps, touch targets around 44x44pt.
  • Understandable: labelled inputs, errors described in text and not by colour.
  • Robust: correct roles and names so assistive tech can drive the UI.

1. Announce and move focus on route changes

This is the number one finding in every Ionic audit we do. In a single-page app the screen reader has no idea a navigation happened; VoiceOver keeps reading the old page. Ionic keeps the previous page in the DOM during transitions, which makes it worse.

Give each page a real <h1> and move focus to it when the view enters.

// Angular: a directive you drop on every page's title
import { Directive, ElementRef, inject } from '@angular/core';
import { IonicSafeString } from '@ionic/angular';

@Directive({ selector: '[appPageTitle]', standalone: true })
export class PageTitleDirective {
  private el = inject(ElementRef<HTMLElement>);

  ionViewDidEnter() {
    const node = this.el.nativeElement as HTMLElement;
    node.setAttribute('tabindex', '-1');
    node.focus({ preventScroll: true });
  }
}

In Ionic React the same idea, driven by useIonViewDidEnter:

const headingRef = useRef<HTMLHeadingElement>(null);
useIonViewDidEnter(() => headingRef.current?.focus({ preventScroll: true }));

return (
  <IonPage>
    <IonHeader>
      <IonToolbar>
        <IonTitle>
          <h1 ref={headingRef} tabIndex={-1} className="page-h1">Orders</h1>
        </IonTitle>
      </IonToolbar>
    </IonHeader>
    ...
  </IonPage>
);

Style .page-h1 to inherit the toolbar typography so nothing changes visually. Also hide the outgoing page from assistive tech; Ionic sets aria-hidden on inactive views in current versions, but verify it in your app — a stale page that is still readable is a confusing failure.

2. Name your ion-components

Ionic renders correct roles; it cannot invent names. The recurring offenders:

<!-- Icon-only buttons: no text, therefore no name -->
<ion-button fill="clear" aria-label="Filter orders">
  <ion-icon slot="icon-only" name="filter-outline" aria-hidden="true"></ion-icon>
</ion-button>

<!-- Inputs: use the label API, not a floating placeholder -->
<ion-input label="Email address" labelPlacement="floating" type="email"
           autocomplete="email" required></ion-input>

<!-- Decorative icons must be hidden -->
<ion-icon name="chevron-forward" aria-hidden="true"></ion-icon>

<!-- Toggles and checkboxes need a visible or aria label -->
<ion-toggle aria-label="Email notifications"></ion-toggle>

Rules we enforce in review:

  • Every ion-icon is either aria-hidden="true" or has an aria-label — never neither.
  • placeholder is never the only label. Placeholders disappear on typing and fail contrast on most palettes.
  • ion-item with button="true" gets an accessible name from its text; if the text is "View", add aria-label="View order 1042".
  • ion-fab-button always gets an aria-label.

3. Make transient UI announce itself

Toasts, loading spinners and inline validation are invisible to a screen reader unless you put them in a live region.

// Angular service: one polite live region for the whole app
@Injectable({ providedIn: 'root' })
export class AnnouncerService {
  private region = (() => {
    const el = document.createElement('div');
    el.setAttribute('aria-live', 'polite');
    el.setAttribute('aria-atomic', 'true');
    el.className = 'sr-only';
    document.body.appendChild(el);
    return el;
  })();

  announce(message: string) {
    this.region.textContent = '';
    // the reset + timeout forces re-announcement of identical strings
    setTimeout(() => (this.region.textContent = message), 50);
  }
}
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
}

Call announce('Order saved') next to your toastController.create(), and announce the first form error after a failed submit (announce('3 fields need attention')) and move focus to the first invalid input.

For modals, let Ionic do the heavy lifting: ion-modal traps focus and applies aria-modal, but give it a name with aria-labelledby pointing at the modal's heading, and make sure the dismiss button is a real button with a label.

4. Respect the user's font size and motion settings

On iOS, Dynamic Type does not reach a WKWebView by default; on Android, users change font scale system-wide. If your layout is pinned in px with fixed heights, a 200% setting shreds it.

  • Set base typography in rem and let --ion-font-family / root font size scale.
  • Never set fixed height on anything containing text — use min-height and let it grow.
  • Test at 200% text and at the largest accessibility text size on device.

For iOS text scaling inside the webview, add the -webkit-text-size-adjust escape hatch and read the system setting via a plugin if the product requires full Dynamic Type parity:

html { -webkit-text-size-adjust: 100%; }

Also honour reduced motion — Ionic page transitions plus your own animations can trigger vestibular symptoms:

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

And in main.ts, drop Ionic's transitions when the OS asks:

const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

provideIonicAngular({
  animated: !reduce,
});

5. Contrast, dark mode and touch targets

Run contrast checks against both palettes. The usual Ionic failures are medium-coloured helper text on a light background and ion-note inside ion-item — both are commonly below 4.5:1 with default theme variables. Override the variable rather than patching components:

:root {
  --ion-color-medium: #5a6169;      /* passes AA on white */
}
@media (prefers-color-scheme: dark) {
  :root { --ion-color-medium: #9aa4ae; }
}

Touch targets: ion-button default size is fine; size="small" icon buttons, tight ion-chip rows and inline links inside paragraphs are not. Pad them to at least 44x44 CSS pixels.

6. Automate the boring 40%

Automated tools catch roughly a third to a half of real issues — missing names, contrast, duplicate ids, bad heading order. That is worth having in CI, as long as nobody claims it means the app is accessible.

npm i -D @axe-core/playwright
// tests/a11y.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

const routes = ['/tabs/orders', '/tabs/orders/1042', '/settings', '/login'];

for (const route of routes) {
  test(`no a11y violations on ${route}`, async ({ page }) => {
    await page.goto(route);
    await page.waitForSelector('.ion-page:not(.ion-page-hidden) h1');

    const results = await new AxeBuilder({ page })
      // only scan the visible page, not the cached previous view
      .include('.ion-page:not(.ion-page-hidden)')
      .withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
      .analyze();

    expect(results.violations).toEqual([]);
  });
}

The .ion-page-hidden exclusion matters: without it axe scans stale pages and you drown in false positives. If you already run the Playwright layer from our E2E setup, this is ten extra lines in the same job.

For Angular, add eslint-plugin-jsx-a11y (React/Vue) or the Angular template accessibility rules so obvious mistakes fail at lint time instead of in the audit.

7. Test on device, with the real screen readers

Nothing replaces this. Half an hour per release, one person, this script:

  1. iOS, VoiceOver on (triple-click side button to toggle). Swipe right through a whole flow: login → list → detail → form → submit. Everything focusable must speak a meaningful name and role, in the visual order.
  2. Android, TalkBack on. Repeat. Pay attention to the hardware/gesture back behaviour and to ion-select and date pickers, which behave differently.
  3. External keyboard on iPad or Android tablet. Tab through the flow. Focus must be visible at all times and must not escape open modals.
  4. Largest system text size + display zoom. Nothing clipped, nothing overlapping, no horizontal scroll.
  5. Dark mode + grayscale. Any information conveyed only by colour now disappears — that is the point of the check.

Record the run. A 3-minute screen recording with the screen reader audio is the most persuasive artefact you can put in front of a product owner who is not sure why this matters.

8. Write the accessibility statement

The EAA expects a published statement covering conformance level, known limitations, and a contact route for users who hit a barrier. Keep an ACCESSIBILITY.md in the repo listing: target (WCAG 2.2 AA), date of last audit, tools used, known gaps with owners and dates, and the feedback email. Procurement teams ask for exactly this, and having it ready has won our clients deals.

A realistic first sprint

If you are starting from zero, this order gets the biggest win per hour:

  1. Add the <h1> + focus-on-enter pattern to every page.
  2. Sweep every ion-icon and icon-only button for names.
  3. Replace placeholder-only inputs with real labels.
  4. Fix the two or three contrast variables that fail globally.
  5. Add the axe Playwright job to CI so the fixes stay fixed.
  6. Do one VoiceOver and one TalkBack pass and file what you find.

That is typically a week for a mid-sized app, and it clears most of the findings a formal audit will raise.


HybridMob has run Ionic accessibility audits and remediation for consumer and regulated apps since Ionic 1. If you need a WCAG 2.2 AA audit of an existing Ionic app, or senior Ionic developers who build accessible components from the start, get in touch and tell us about the app and your deadline.