Accessibility 17 min read

What it took to retrofit accessibility onto a shipped app

WAVE, axe, NVDA, VoiceOver, 400% zoom and a codebase scanner, run against an app that already thought it was accessible. What each one caught.

Illustration headed 'Web Accessibility — inclusive design, equal access for everyone'. Four people stand around a large laptop: a woman with a walking cane, a man in a wheelchair using a laptop, a man pointing at the screen, and a woman wearing dark glasses with a guide dog. Badges around them read Keyboard Friendly, Screen Reader Support, High Contrast, Closed Captions, Keyboard Navigation, and Readable Text.

When i tried to navigate my web app using keyboard then -> it started with header first, then every item in the sidebar, then finally the main layout. The order is sensible. Nothing about it is wrong, which is exactly why it survived: it looks correct in a screenshot and it looks correct when you tab through it once.

Then you change route and do the whole walk again. And again on the next one. There was no skip link anywhere in the app, so the cost of the navigation was charged to a keyboard user before every single task, and the ordinary case of using the product for an hour meant paying it dozens of times. A mouse user never sees this. It never showed up in a ticket.

Keep tabbing and something worse shows up. The header has a row of buttons and the notification button is not among the stops. It never receives focus, so there is no keystroke that opens it, so a keyboard user does not have a degraded notifications experience. They have none. Every alert the product sends is addressed to someone who can hold a mouse.

The rest of the app looked fine, because it mostly was fine. Alt text existed. Most inputs had labels. Someone had clearly cared at some point, which turned out to be the thing that made the audit harder rather than easier.

One screen didn’t tell me about the next

The app already had accessibility work in it. How much of it depended on which page you opened. Landmarks were there on some pages and missing on others. Some controls were real <button> elements and some were divs with click handlers. Some headings were paragraphs styled to look like headings, so a page could have a visual hierarchy and no outline underneath it.

That changed how I tested. Tabbing through one screen tells you about that screen. I’d been checking a page or two and assuming the rest of the app was in the same condition. It wasn’t.

Maybe an app with no accessibility work at all is easier to survey, since every page fails the same way. What I can say is that, check every page, and every state inside a page. A screen you didn’t open and an error you never triggered aren’t covered by a clean result on everything else.

The audit was five passes, not one scan

Each pass answers a question the others don’t ask, so the list below is a sequence rather than a menu.

Automated checks in the browser: WAVE and axe DevTools. These read whatever is rendered at the moment you run them, so a page has to be walked state by state rather than opened once. Creating a project, the failure message when it doesn’t go through, the success message when it does, a modal open, the left menu expanded, the right menu expanded. Every one of those is a separate check, because a view you never rendered is a view the tool never examined.

This is also where the heading outline came apart. Pages that read as well organized turned out to be a single <h1> followed by nothing, with the hierarchy underneath it made of paragraphs in a large font.

Contrast, measured: the WebAIM contrast checker. Level AA wants 4.5:1 on normal text and 3:1 on large text, where large means 18 point (typically 24px) or 14 point bold (typically 18.66px). SC 1.4.11 adds 3:1 for user interface components. That last one is the one people miss: everyone checks the label text and nobody checks the 1px input outline sitting underneath it.

This is the part you cannot decide by looking. #767676 on white measures 4.54:1 and passes. #777777 on white measures 4.48:1 and fails. One hex digit apart, and no eye tells them apart. Paste both into the checker and you get the same two numbers.

Screen reader: NVDA on Windows, VoiceOver on macOS. Reading the markup won’t tell you what gets announced. You have to listen to it.

On each control I was listening for three things: does it say what it is, what it’s called, and what state it’s in. A button that announces as “button” with no name is easy to miss in a code review and obvious the moment you hear it. So is a field whose label sits right beside it on screen but isn’t tied to it, so nothing gets read out at all.

Neither tool costs anything. NVDA is a free download on Windows, and VoiceOver is already on macOS behind Cmd+F5. You need a handful of keystrokes to move through a page and not much more than that to start finding things.

Zoom and rotation. SC 1.4.10 (Reflow), Level AA, asks for content to work at a width equivalent to 320 CSS pixels without scrolling in two directions. A 1280px window at 400% zoom puts you exactly there. It comes with an exception for content that needs a two-dimensional layout to make sense, and data tables are the example the spec itself gives.

Our tables had horizontal scroll. That exception would have covered them, but we decided not to leave the scroll there, so we changed the table layout at the widths where it showed up. We did all of it in CSS, and we held to one rule across the app: no horizontal scroll anywhere. This was one of the bigger pieces of the project, because the app has a lot of tables, each with its own structure, and each one needed a design of its own.

SC 1.3.4 (Orientation), also Level AA, says content mustn’t be locked to portrait or landscape unless that orientation is essential. Chrome DevTools’ device toolbar rotates the viewport to check it. Neither of these tests needs anything installed, and neither had been run against this app.

Try it

This page reflows, so you can run the test here before running it on your own app. Zoom to 400% and watch two things change.

  • The contents list on the left moves to the top and turns into a block you open and close.
  • The header keeps its logo and drops the words next to it.

Nothing scrolls sideways and nothing is lost. The contents are still reachable and the logo still goes home. That is what reflow asks for. The page is not shrunk to fit the width, it is laid out again for the width it has.

Your viewport right now

CSS pixels wide

Zoom in until this reads 320 or less.

For orientation, open this page on your phone and rotate it. If you would rather not, use the device toolbar in Chrome DevTools and rotate the viewport there. The page works in both directions, and that is the whole test.

Scripted: Playwright driving axe-core. A script walks the main routes and reports whatever axe finds. We ran it from a laptop, and later through a Bitbucket pipeline. It doesn’t find anything new. What it does is catch a fixed problem coming back when someone edits a component months later.

All of this together still isn’t a full audit. Deque measured how much automated testing catches, across about 13,000 pages and 300,000 issues: their tools found 57.38% of the issues. Count by WCAG criteria covered instead of by number of issues and it’s lower, around 20-30%. Deque sells the tooling, so those are the favorable numbers. Everything else has to be found by a person.

We went page by page, and the first page was the slowest

We picked a page, ran WAVE on it, and fixed what it reported. Then the rest of the passes on that same page: listen to it with a screen reader, check the contrast values, zoom to 400%, rotate it. The page wasn’t done until all of that was done, and only then did we move to the next one.

The first page took a long time. Every issue on it was new, and each one needed a decision about how to fix it before anything could be written.

After that it sped up. The same problems came back on page after page, and by then we knew what to do with them. Most of the later work was recognizing something we had already solved and applying the same fix again.

The other half of the plan was reading the source

The codebase is large. Going page by page covers the pages you walk, and it still doesn’t tell you that every piece of markup in the repo has been looked at. A component can render one way in the flow you tested and another way in a branch you didn’t happen to hit. To know the whole codebase had been checked, something had to read the whole codebase.

So the plan had a second pass in it from the start. Instead of working through pages, work through the source. The tool for that was eslint-plugin-jsx-a11y, which reads the JSX as an AST and flags the accessibility problems that are visible in markup. No browser, no route, no login. It found issues on screens I’d never once opened.

The linter has the opposite limitation. It reads every file, but it only reads code. It can see an <img> with no alt and a click handler on a <div>. It can’t see that the alt text says “image”, that the tab order is wrong, that the focus ring disappears against a dark background, or that two buttons ended up with the same name. Those only show up when the page runs. It also only reads JSX, so an HTML template isn’t checked at all.

So the two passes cover each other. The page work found what only shows up when the app is running. The linter covered the code the page work hadn’t gone through. Neither one would have been enough on its own.

What the fixes came down to

In a lot of places the accessible version had less code in it than the thing it replaced. Not everywhere, but often enough to notice.

Semantic HTML. <div class="header"> became <header>. The nav became <nav>, the main region became <main>, and once <main> existed the skip link had somewhere to point. Styled paragraphs became <h2> and <h3> in an order that matches the visual hierarchy, which mostly meant deleting a font-size class and letting the tag carry it. Landmarks aren’t decoration. NVDA and VoiceOver both let a user jump between them, and a page with one <main> is navigable in a way a page of nested divs is not.

Keyboard navigation. Every <div> with a click handler that was really a button became a <button>, which deleted the role, the tabindex, and the keydown handler someone had added to imitate what the tag does for free. I wrote about what exactly you give up with a clickable div separately. The short version: several lines of JavaScript to approximate a behavior the platform ships, and the approximation is still wrong on Space.

The notification button from the top of this post was one of these. So was the profile button at the bottom of the side menu, and the task names in the main content. All three worked with a mouse and none of them worked with a keyboard.

Below are two copies of that layout. A header, a left nav with the profile control at the bottom, and a main area with a list of tasks. The first is built the way ours was. The second is the same thing with real elements. They look the same, so tab through both and count.

Try it

Put the mouse down. Press Tab until focus enters the first panel, then keep going and count where it stops. Do the same in the second panel. Both panels are the same layout with the same styling.

Before

Tasks

Open tasks

  • Rename the export job
  • Archive Q3 uploads
  • Check failed webhooks

The notification control, the profile control and the task names are divs and spans with click handlers. Tab never reaches them, and a screen reader is not told they are controls.

Tab stops:

Focus: outside this panel

After

Skip to tasks
Tasks
Overview Reports

Open tasks

The same controls are buttons, so they sit in the tab order at their place in the markup. The skip link is first and stays hidden until it takes focus.

Tab stops:

Focus: outside this panel

Tab order. Where the sequence was wrong rather than long, the fix was almost never a tabindex. Tab order is DOM order, so a focus ring that jumps around the screen is telling you the source is in a different order than the layout. Three causes accounted for nearly all of ours.

Positive tabindex values. Any element with tabindex="1" or higher is visited before every element with tabindex="0", in ascending order, across the entire document. One of them left in a header quietly reorders every page it appears on, and it doesn’t stop at the component boundary. Deleting them was the single biggest improvement to the tab sequence.

CSS that reorders what the DOM did not. flex-direction: row-reverse, the order property, and grid placement all move elements visually and change nothing about focus, so the ring appears to jump backwards. The flexbox spec says this outright: the section is titled “Reordering and Accessibility”, and it holds that order is for visual reordering only, with style sheets that use it for logical reordering called non-conforming. Where we’d used it as one, the fix was to reorder the markup and let CSS do less.

Hidden things that were still focusable. An offscreen menu positioned at left: -9999px keeps every link inside it in the tab order, so a keyboard user tabs into content they can’t see and the ring disappears for as long as it takes to get back out. display: none, hidden, or inert removes it properly. Moving something offscreen does not.

The test costs nothing: press Tab, watch the ring, check it goes where your eye goes. SC 2.4.3 asks for an order that preserves meaning and operability, which is a lower bar than “matches the visual layout” but in practice they’re the same thing.

Focus management. The rules I ended up applying everywhere: never remove an outline without replacing it, use :focus-visible so mouse users don’t see rings they didn’t ask for, and move focus deliberately whenever content appears or disappears.

Modals are where all of that lands at once. Four things have to happen:

  • Move focus into the modal when it opens, to the heading, the first interactive element, or whatever suits that dialog.
  • Trap focus inside it. Tab and Shift+Tab cycle through the focusable elements in the modal and don’t wander into the page behind it.
  • Close on Escape, where closing is appropriate.
  • Return focus to the element that opened it once it closes.

The last one is the step that gets skipped, and skipping it drops the user back at the top of the document with no idea where they are.

The skip link sits first in the tab order and stays hidden until it’s focused. One Tab press on a fresh page brings it up. Ignore it and it disappears again, so a mouse user never sees it at all.

Forms. Every input got a real <label for>. Placeholder text is not a label; it vanishes when the user types, and at that point the field has no name for anyone using a screen reader. Required fields got required rather than an asterisk in a span. Error messages got wired to their input with aria-describedby and the input got aria-invalid="true", so the error is announced when focus lands on the field instead of sitting there visually adjacent and semantically unrelated.

Errors and status messages. The toast component rendered into an empty container with no live region, so it was silent to a screen reader. Validation summaries had the same problem. The fix is role="alert" for errors and aria-live="polite" for the ordinary case, with the container present in the DOM before the message goes into it. A live region injected at the same moment as its content is not reliably announced, and that’s the bug I hit twice before it stuck.

Color contrast. The WAVE extension reports the failures, and its panel lets you adjust the color until the ratio passes, so you leave with the value you need. Then you go and set it. One color, then the next one. None of this is hard. There’s just a lot of it, and all of it is by hand.

ARIA was a smaller part of the work than I expected

I went in thinking ARIA would be most of the job. It wasn’t, and some of the ARIA work was taking attributes out rather than putting them in.

The rule I’d hand to anyone doing this: reach for the HTML element first, and add ARIA only when there’s no element for what you’re building. Those cases are real. HTML has nothing for a tabs widget, and nothing that does what a modern combobox does. Outside of them, there’s usually an element that already carries the role and the behavior, and using it beats describing it.

The tables took longer than everything else in this section

Orientation was the small one. A lock came off and the screens worked in both directions.

Reflow was the opposite. Outside the tables it was ordinary responsive work: fixed pixel widths became max-width, and containers were allowed to wrap. The tables were the real job. A table that scrolls sideways is allowed to under the spec, but we didn’t want to ship that, so each one needed a layout that holds up at narrow widths without the scroll.

That’s a design question before it’s a CSS question, and the answer isn’t the same for every table. The app has a lot of them, each with its own columns and its own structure, so the work was one table at a time. All of it in CSS, with no change to the markup, which is the part that made it possible at all.

Ten issues at a time was the only pace we could review

Once the codebase was scanned it produced a list of a11y issues. Finding issues in the codebase became easy, and fixing those manually across the codebase would take weeks of work.

So we used AI to fix them. The next problem was reviewing what it changed. If AI fixes everything in one go the diff is too big to review, and every change in it looks correct.

So we asked it to take ten issues, fix those, and stop. We verify them, then it continues with the next ten. Ten changes is small enough to actually read.

AI was fast at this. The a11y rules are documented on the internet and so are the fixes for them, so it already knew what to do with most of the issues on the list. Those were done as soon as it read them.

It also changed code in a few places in a way that would have introduced a bug. That is the reason we kept the set at ten. Ten changes we can read properly before it moves on.

What I actually think accessibility is

Fixing or adding accessibility to your application isn’t about checking the a11y rule boxes. You have to think about whether your application is accessible to all kinds of people. Is it easy to access. Accessibility is all about this. Are people able to access your application properly or not.

If you go to the WCAG website there are a lot of rules, and not every rule is for your website. If you sit down to check the rules, you think of checking all of them, and you try to introduce a rule your app doesn’t need. If instead you think about the relation between your app and all kinds of people, then you will understand the problem, and you will find the solution in the a11y rules.

Also, when you are improving accessibility you need to consider the UX. There can be cases where you have provided a way to access your app, but how user friendly it is matters a lot too. UX will not be in the a11y guidelines. It still matters for your product.

Where it landed

A tool score does not decide whether your app is accessible. Tools like WAVE can report a page as clean with a 10 out of 10 score, and based on that we cannot say all the users can access the app. It does not say the app is navigable by keyboard, by a screen reader, or by anything else. So along with such tools, manual testing is necessary.

So the result is the change, not the number. The app is now navigable end to end with the keyboard, and the focus ring is visible at every stop. There is no horizontal scroll anywhere in the app. None of that was true when we started.

For the list of what to fix we used the VPAT, in its WCAG edition. It is a free template that turns the accessibility standards into criteria you can test against, and for each one you record where your product stands: supports, partially supports, does not support, or not applicable. That last option is the important one. Not every criterion applies to every application, and the form expects you to say which ones don’t.

We went through the form and made the changes our app needed to be compliant.