Skip to content
Facade UI
Colour mode
GitHub (opens in a new tab)

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-visible gets 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.

Outline level is explicit
<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 use alt="" deliberately, because the company or person's name is already text beside them — announcing both is noise, not information.
  • Icons are aria-hidden unless they carry meaning on their own. FeatureIcon applies 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:

  • Stat puts the label before the value in the DOM — a definition list requires it, and a figure without its label is meaningless read aloud.
  • PricingTier renders "$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.
  • Testimonial renders 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-a11y in 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.