Skip to content

Accessibility

The position here is that accessibility claims need tests, not intentions. Everything below is swept across all 50 routes of the reference application on every run.

What is checked, and how

condition criterion how
Text contrast, light and dark 1.4.3 Colours read as pixels via canvas; each element measured against what its ancestors actually painted
Reflow 1.4.10 Full render at 1440 / 390 / 320 px; no sideways document scroll
Text-only enlargement 1.4.4 Root font size forced to 32px at a 1280px viewport
Keyboard traversal 2.1.2, 2.4.7 90 Tab presses per page; must wrap, and every stop must show a ring
Focus on load 2.4.3 document.activeElement must be the body
Pointer targets 2.5.8 24×24, with the label, pointer-events and spacing exceptions implemented
Forced colours Rendered with forced_colors: active; the meanings colour carried must survive
Reduced motion 2.3.3 Animations substituted, not removed

Plus static rules in the linter: missing accessible names, missing alt, skipped heading levels, duplicate ids, positive tabindex, a missing lang, and tables that will scroll the page.

The principle that does most of the work

State a person can perceive is an attribute, and the stylesheet keys off that attribute.

.ads-nav__link[aria-current] {  }
.ads-tab[aria-current]       {  }
.ads-btn[aria-pressed="true"]{  }
.ads-input[aria-invalid="true"] {  }

The highlight cannot exist without the semantics, so there is nothing for an author to remember and no lint rule needed to enforce it. This replaced ten kinds of "current item" that had been marked with .is-active and were, to a screen reader, ordinary links.

.is-* classes remain, but only for state nobody has to hear about — .ads-tabpanel.is-active means visible, and the tab is what says current.

Forced colours

Windows high contrast discards the palette, which is the point: the user has decided ours does not work for them. What has to survive is the meaning colour was carrying. In this system that is three things — which button is primary, where you are, and every edge drawn with a shadow — and they are marked with borders and outlines in Highlight, not with fills.

Do not invert the fill

background: Highlight; color: HighlightText is the usual advice and it makes labels vanish. When a browser is unsure what text sits on, it paints a solid Canvas backplate behind the glyphs; Highlight is not opaque in every implementation. Both true at once means white on white. A border cannot be hidden by a backplate.

forced-color-adjust: none is kept for the eight places where the colour is the information and there is nothing else to say it — a status dot, a progress fill, a sparkline, a legend swatch, and the controls we draw ourselves. That escape is a promise those carry their own contrast, which the contrast gate checks.

Reflow

The rule that catches most mistakes: no absolute length may act as a floor.

minmax(22rem, 1fr)              /* overflows a 20rem viewport */
minmax(min(22rem, 100%), 1fr)   /* does not */

min-width: 18rem                        /* overflows */
width: min(18rem, calc(100vw - 2rem))   /* does not */

And a media query's em is the initial font size, not the root's — so min-width: 62em is 992px whether or not the reader has doubled their text. A layout that switches behaviour at a breakpoint cannot see text-only zoom at all.

Known gaps

  • No screen reader has run over this. Everything above is what a browser can be asked about the DOM, which catches absent semantics and not wrong ones.
  • Region swaps do not announce. Advancing the wizard changes the step and says nothing; focus correctly stays on the button. Choosing between moving focus and adding a live region needs a real screen reader to judge.