Accessibility
The target is WCAG 2.2 AA. What follows is the part that matters: how each claim is actually checked, because an accessibility statement nobody verifies is just a wish.
Contrast
scripts/check-contrast.ts parses the real token values out of globals.css and themes.css, resolves all six theme scopes, and checks every pair a section can put on screen. Text pairs must clear 4.5:1 and the focus ring 3:1. It runs in CI and fails the build.
Hairline borders are reported but not enforced. A divider is decorative; a control whose only boundary is a border is not, and those carry their own stronger colour.
Keyboard and focus
- Every interactive element is reachable and operable by keyboard.
- Focus is always visible:
:focus-visiblegets a 2px ring with a 2px offset, defined once in the base layer. - Every button size is at least 44×44 CSS px, which clears WCAG 2.5.5 rather than the 24px AA floor.
- Scrollable code blocks are focusable, so keyboard users can scroll them at all.
Structure
Sections never hard-code a heading level. Each takes headingLevel, and visual size is a separate prop — the same hero is an h1 on a landing page and an h2 inside a longer one, with no change in appearance.
<HeroSplit headingLevel={1} ... /> {/* landing page */}
<HeroSplit headingLevel={2} ... /> {/* inside an existing page */}A <section> is only a landmark once it has an accessible name, so Section pairs aria-labelledby with the id SectionHeader puts on the heading. An unnamed band renders as a div instead of adding a nameless region to the landmark list.
Motion
FacadeMotionProvider sets reducedMotion="user", and the base layer disables CSS transitions under prefers-reduced-motion. Animations only ever touch opacity and transform, so they cannot shift layout or contribute to CLS. The one exception is Collapse, where the height change is the interaction itself and is always user-initiated.
Every section ships a static variant and a -motion variant. The static one is the default, and it renders identically with JavaScript disabled.
Images and icons
- Data-driven images take a required
alt. Logos and avatars usealt=""deliberately, because the company or person's name is already text beside them — announcing both is noise, not information. - Icons are
aria-hiddenunless they carry meaning on their own.FeatureIconapplies that for you rather than trusting each caller.
Things that read differently than they look
A few places deliberately separate the visual form from the spoken one:
Statputs the label before the value in the DOM — a definition list requires it, and a figure without its label is meaningless read aloud.PricingTierrenders "$29" visually and "29 dollars per month" to screen readers, and a not-included feature says so in words rather than relying on a grey cross.Testimonialrenders a rating as "Rated 5 out of 5", not as five separately announced star icons.
What is checked automatically
- Token contrast, across all six theme scopes.
eslint-plugin-jsx-a11yin strict mode across the whole registry.- Unit tests for the behaviours above, including reading order and accessible names.
- An axe-core scan of every section in light and dark, in Playwright.
Automated checks catch perhaps a third of real accessibility problems. The keyboard walkthrough on each section's page is the other part, and it is written by hand.