Accessibility arrives at mature Ember apps the same way security reviews do: through someone else's deadline. A public-sector or enterprise buyer sends a VPAT request. Legal reads the European Accessibility Act and asks which products are in scope. A complaint lands. The app has been shipping for ten years without an accessibility program, and someone needs an answer in weeks.
The good news is that an Ember app is a reasonable place to do this work. The router is centralized, templates are real files you can lint, and the test layer renders actual DOM in a real browser. The bad news is the usual bad news: some of your markup is produced by addons you no longer control, and some of it is ten years old.
Here is the order we work in.
Start by scoping, not by scanning
Running an automated scanner across the whole app on day one produces a few thousand findings and no plan. Automated tooling catches roughly a third of WCAG issues, and it catches them repeated once per render, which makes the number look like a verdict on the codebase rather than a work queue.
Scope first. Which flows does the buyer, the regulation, or the complaint actually cover? Sign-in, the main task flow, checkout or submission, account settings, and whatever screens are named in the contract. Get those to conformance and you have something true to say in writing. The reporting dashboard nobody outside the company can reach can follow later.
Then decide the target explicitly: WCAG 2.2 Level AA is the version most current procurement language points at. 2.2 adds a handful of criteria over 2.1 — focus appearance and not obscuring the focused element among them — that tend to bite sticky headers and modal layouts, which mature apps have plenty of.
Fix route transitions before anything else
This is the single-page-app defect, and Ember has a specific, well-trodden fix for it.
When a user activates a link in a server-rendered site, the browser loads a new document: focus resets, the screen reader announces the new page title, and the user is oriented. When Ember transitions routes, none of that happens. The URL changes, the DOM changes, and focus stays wherever it was — often on a link that no longer exists. A screen reader user gets no signal that anything happened at all.
The conventional fix is ember-a11y-refocus, which puts a navigation-narrator element in the application template and moves focus to it after each transition, announcing the new context. Install it, put the component in application.hbs, and give it a skipTo target so keyboard users can jump straight to main content:
{{!-- app/templates/application.hbs --}}
<NavigationNarrator @skipTo="#main" @skipText="Skip to main content" />
<Header />
<main id="main" tabindex="-1">
{{outlet}}
</main>
Two details matter in older apps. First, if your app still transitions with transitionTo from inside components that also manipulate focus, you will get fights; let the narrator own post-transition focus and remove the ad-hoc calls. Second, set document titles per route (ember-page-title if you do not already) — the announcement is worth much less if every page is called "Dashboard".
The same principle applies inside the app: after an action that replaces a region — opening a modal, expanding a wizard step, revealing a validation summary — move focus deliberately and return it when the region closes. Modals are where most mature apps fail hardest, because the modal was usually implemented before anyone thought about focus traps.
Turn the cheap rules on in lint
ember-template-lint ships a set of accessibility rules, and recommended already includes several. Enabling them across a decade of templates will produce a wall of errors, so do not enable them across a decade of templates at once. Turn on one rule, fix it everywhere, commit, move to the next:
// .template-lintrc.js
module.exports = {
extends: 'recommended',
rules: {
'require-input-label': 'error',
'no-invalid-interactive': 'error',
'require-valid-alt-text': 'error',
'no-positive-tabindex': 'error',
'no-autofocus-attribute': 'error',
},
};
The ordering that works: labels first (require-input-label), because unlabeled inputs are both the most common finding and the most damaging; then no-invalid-interactive, which surfaces every <div> with a click handler — these are real bugs, not style nits, and each one is a keyboard user who cannot use that control; then alt text and tabindex hygiene.
Resist the urge to bulk-silence with inline disables. A disable comment is a permanent, invisible exception. A tracked list of files pending a rule is a work queue. Use the lint config's per-rule file overrides for the backlog so the remaining work is visible in one place.
Put assertions in the test suite you already have
The point of automated checks is regression control, not discovery. ember-a11y-testing runs axe against rendered output inside your existing acceptance and integration tests, which means you get real DOM, real component state, and real interaction — the three things a crawler cannot give you.
// tests/acceptance/invoices-test.js
import { a11yAudit } from 'ember-a11y-testing/test-support';
test('invoice list is accessible', async function (assert) {
await visit('/invoices');
await a11yAudit();
assert.ok(true, 'no axe violations');
await click('[data-test-invoice-row="1"]');
await a11yAudit();
assert.ok(true, 'detail drawer has no axe violations');
});
Audit after each meaningful state change, not once per test file. The drawer, the open modal, the error state, and the loading substate are all separate DOM realities.
For apps with a large backlog, the workable pattern is a ratchet: audit only the routes on a known-clean list, and add routes to that list as they are remediated. New work is held to the standard immediately; old work is retired on a schedule. A single suite-wide audit that has been skipped for a year protects nothing.
The addons that own your markup
This is the part specific to long-lived apps, and it is usually the largest line item.
In a ten-year-old Ember app, a meaningful share of the rendered DOM comes from addons: a date picker chosen in 2016, a select component, a modal library, a table addon, a Bootstrap wrapper. You cannot fix their output in your templates, and many of them are unmaintained — the same addons that already showed up on your upgrade risk list.
Triage each one:
- Configurable. Some components expose the ARIA attributes or label hooks you need and were simply never wired up. Cheapest possible fix; check first.
- Patchable upstream. Maintained addon, clear defect, small PR. Worth doing — it also reduces your long-term diff.
- Replaceable. An unmaintained widget with a modern, accessible equivalent, or a native HTML element that does the job. A
<select>styled properly beats a custom listbox nobody will maintain. This is the same replacement work you would do for addon health anyway; accessibility just decides the order. - Wrappable. When replacement is genuinely out of budget, a wrapper component can add labels, roles, and keyboard handling around the addon's output. It is a patch, not a fix, and it should be written down as such.
There is a useful coincidence here. The abandoned-addon list from an assessment and the accessibility-blocker list overlap heavily, because the addons that stopped being maintained are the ones that never caught up with accessible-widget practice. If you are already planning addon replacement for upgrade reasons, sequence it so the accessibility-blocking ones go first and one budget pays for two problems.
Manual testing is not optional
Automated tools do not evaluate whether your focus order makes sense, whether an error message is useful, whether a custom widget behaves as a keyboard user expects, or whether a heading structure describes the page. Budget for manual passes on the scoped flows: keyboard-only navigation end to end, then a screen reader pass on at least one desktop and one mobile combination.
If conformance is contractual, plan for an external audit. An internal team can get an app to "we found no violations"; a buyer generally wants a report that someone else signed. Doing the work above first means you are paying an auditor to confirm your remediation rather than to write your backlog for you.
What this usually costs
For a mature app with no prior program, on a handful of scoped flows: the route-transition and focus work is days, not weeks, and it is the change users notice most. Lint remediation is steady, parallelizable, low-risk work measured in weeks of partial time. Addon triage and replacement dominates the estimate and has the widest range, because it depends entirely on which widgets you inherited. Manual remediation of the scoped flows sits somewhere in between.
The important scheduling point: none of it needs to wait for an upgrade, and none of it is wasted if you later migrate off Ember. Focus management, labeled inputs, and sane heading structure are framework-independent, and the widgets you replace are replaced in favor of things that will still be standard in whatever renders those screens next.
If you are facing a procurement deadline or a regulatory date on an Ember app, an assessment can tell you where you stand against WCAG 2.2 AA on the flows that matter, which findings are addon-owned, and what the remediation actually costs. Tell us your Ember version and the deadline you are working against.