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-iconis eitheraria-hidden="true"or has anaria-label— never neither. placeholderis never the only label. Placeholders disappear on typing and fail contrast on most palettes.ion-itemwithbutton="true"gets an accessible name from its text; if the text is "View", addaria-label="View order 1042".ion-fab-buttonalways gets anaria-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
remand let--ion-font-family/ root font size scale. - Never set fixed
heighton anything containing text — usemin-heightand 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:
- 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.
- Android, TalkBack on. Repeat. Pay attention to the hardware/gesture back
behaviour and to
ion-selectand date pickers, which behave differently. - External keyboard on iPad or Android tablet. Tab through the flow. Focus must be visible at all times and must not escape open modals.
- Largest system text size + display zoom. Nothing clipped, nothing overlapping, no horizontal scroll.
- 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:
- Add the
<h1>+ focus-on-enter pattern to every page. - Sweep every
ion-iconand icon-only button for names. - Replace placeholder-only inputs with real labels.
- Fix the two or three contrast variables that fail globally.
- Add the axe Playwright job to CI so the fixes stay fixed.
- 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.