Accessibility
What I did, and what it cost
Most accessibility work on this site is subtraction. Nearly every interactive element uses a native HTML element instead of a JavaScript reimplementation, because the browser already ships correct keyboard handling, focus management and screen-reader semantics — and a custom version has to rebuild all three, usually incompletely.
Native elements over rebuilt ones
Each of these could have been a component from a library. The native element was chosen because it arrives correct.
Disclosure uses <details>
Case-study sections that expand and collapse are native disclosure elements. The name attribute makes a group exclusive, which is the only behaviour a component library would have added on top.
Instead of: A custom accordion has to implement aria-expanded, aria-controls, keyboard activation and focus order by hand, and ships JavaScript to do it.
src/components/blocks/Accordion.astroModals use <dialog> with showModal()
The image lightbox and the mobile menu are native dialogs. Focus trapping, Escape to close, inertness of the rest of the page and the backdrop all come from the browser.
Instead of: Hand-rolled modals routinely leak focus to the page behind them, which strands keyboard and screen-reader users outside the thing they just opened.
src/components/interactions/Lightbox.astroThe before/after comparison is an <input type="range">
Dragging the divider between two screenshots is a slider input. That makes it operable with arrow keys and announced with a position by screen readers, for free.
Instead of: A custom drag handle built on pointer events is mouse-only unless keyboard support is added deliberately, which it usually is not.
src/components/blocks/BeforeAfter.astroCarousels are CSS scroll-snap
The testimonial and image sliders are scrollable lists. Dragging, trackpad swipe, momentum and keyboard scrolling are the browser’s own. The arrows and dots are a convenience layer; without JavaScript it stays a scrollable list.
src/components/blocks/MediaSlider.astro
Motion
Motion-triggered vestibular symptoms are a real disability, not a preference. Every animation on the site is opt-out at the system level.
prefers-reduced-motion disables animation entirely
Scroll reveals, the counting figures, the activity chart bars and the background video all check the setting and do nothing when it is on. The video falls back to its poster frame.
src/components/interactions/ScrollReveal.astroContent is visible before the script runs
Scroll-revealed content starts visible and is only hidden once JavaScript has confirmed it can animate it. A failed or blocked script leaves the content readable rather than permanently invisible — the usual failure mode of scroll animations.
Counting figures contain their final value
Animated statistics have the real number in the HTML from the start. The animation replaces it only while running, so the correct value is present for screen readers and search engines regardless.
src/components/interactions/CountUp.astro
Targets and focus
Measured on a 390px viewport, which is where these problems actually bite.
Every control is at least 24×24 CSS pixels
Carousel dots are the interesting case: the visible mark is a 6px bar, but the button around it is 36px tall and at least 24px wide. Shrinking the target to match the graphic is the common mistake.
Instead of: A 6px dot is a 6px target, which is unusable with a thumb.
Focus is always visible
Every interactive element has a focus-visible ring. Where a control sits on a coloured surface, the ring carries an offset so it stays legible against it.
A skip link comes first in the tab order
The first thing a keyboard user reaches jumps past the navigation to the main content. It is visually hidden until focused, then fully visible.
src/layouts/BaseLayout.astro
Content and structure
Structure is what a screen reader navigates by, so it is enforced rather than hoped for.
Alt text is required by the CMS
The image type in Sanity makes alt text a required field. An image cannot be published without it, so the site cannot drift into missing descriptions as content grows.
src/sanity/schemas/objects/shared.tsOne h1 per page, no skipped levels
Heading order is checked on every build across every page and locale. A card heading was demoted from h3 to h2 because it followed the page h1 directly.
The activity chart has a table
The commits-per-day chart is decorative markup with aria-hidden, paired with a real table containing the same figures. Forty-nine bars serve some readers; a table serves the rest.
src/components/blocks/CadenceChart.astroEvery image carries width and height
Dimensions come from the asset metadata, including logos, whose width is derived from their own aspect ratio. That reserves the space before the image loads, so content does not jump as the page fills in.
Decorative images are marked as such
Images that repeat adjacent text — the dark-theme variant of a logo, an avatar next to a name — carry an empty alt so they are skipped rather than announced twice.
Colour and theme
Both themes are validated, not just the one the design was made in.
Contrast ratios are computed, never transcribed
The contrast tables in the case studies calculate their ratios from the actual hex values at build time using the WCAG relative-luminance formula. Change a colour and the verdict follows.
Instead of: A hand-written ratio stops being true the moment either colour changes, and an accessibility table that silently goes stale is worse than none.
src/components/blocks/ContrastChecker.astroThe theme applies before first paint
A blocking inline script sets the theme before the browser paints, so there is no flash of the wrong one. Light and dark logo variants swap with CSS rather than JavaScript, for the same reason.
Position is never signalled by colour alone
The active carousel dot changes width as well as colour, so it remains distinguishable without colour perception.
Checked on every build
Two checks run against the built output on every change. Neither replaces manual testing — they catch the class of problem that scales badly as a CMS-driven site grows, where one missing alt among hundreds of images is invisible by inspection.
- Missing alt attributes, missing width/height, heading-order jumps, missing landmarks, unnamed buttons, empty titles and descriptions, and unsafe target="_blank" — across every page and every locale.
- Touch targets under 24×24, measured in a real browser at a 390px viewport.
What these cannot see: colour contrast as rendered, focus order in practice, whether alt text is any good, or whether the page makes sense read aloud. Automated checks catch roughly a third of what matters. The rest is manual, and this page is not a claim that it is finished.
Found something wrong?
If any of this fails for you in practice, I would genuinely like to know — that is more useful than any audit score. The components themselves are documented on thedesign system page, and you can reach me throughthe contact form.