Accessibility exhibit
The same site, twice
This demo follows the W3C WAI Before and After Demonstration pattern: one clinic website built twice. The before-version carries 12 realistic barriers, the kind real audits find every week. The after-version fixes every one, and a CI gate keeps them fixed.
- 8 of 12 machine-detectable (axe-core)
- 4 of 12 need human judgment
- WCAG 2.2 A/AA
Audit ledgers and rescan diffDraft accessibility conformance report
Compare them live
Before (12 seeded barriers)
After (remediated)
Both frames load the real pages. Tab into the before-frame to feel the missing focus indicators; try the same walk in the after-frame.
The 12 barriers, one by one
Each barrier below is real on the before-pages right now. The numbering matches the audit ledgers and the seeded-violation checklist.
V1. Form inputs without labels
WCAG 3.3.2 Labels or Instructions (also 1.3.1, 4.1.2)Caught by axe: select-name (the placeholder-only text inputs PASS automated checks; catching them takes a human)What is broken
Every input on the contact form is labeled only by placeholder text, which vanishes on entry and is not a programmatic label.
<input name="phone" placeholder="Phone" />The fix applied
Every field gets a visible <label> tied to the input with htmlFor/id; hints use aria-describedby.
<label htmlFor="field-phone">Phone number</label> <input id="field-phone" name="phone" type="tel" autoComplete="tel" aria-describedby="hint-phone" />Who hits this barrier
Screen reader users hear “edit text” with no clue what to type; voice-control users cannot name the field; everyone loses the hint once the placeholder disappears.
See it broken (V1 on the before-site)See it fixed (V1 on the after-site)
V2. Clickable divs posing as buttons
WCAG 2.1.1 Keyboard (also 4.1.2 Name, Role, Value)Human judgment requiredWhat is broken
Service cards navigate with a mouse onClick on a <div>: no role, no tabindex, no keyboard handler.
<div onClick={() => router.push("/before/services#slug")} className="cursor-pointer">…</div>The fix applied
Cards are real links: the whole affordance is an <a> (Next <Link>), natively focusable and operable.
<Link href="/services#slug">Orthopedic rehabilitation</Link>Who hits this barrier
Keyboard and switch users cannot reach the cards at all; screen readers announce them as plain text, not as something operable.
See it broken (V2 on the before-site)See it fixed (V2 on the after-site)
V3. Text contrast below 4.5:1
WCAG 1.4.3 Contrast (Minimum)Caught by axe: color-contrastWhat is broken
Hero headings render at roughly 2.2:1 and footer links at roughly 2.4:1 against their backgrounds.
<p className="text-[#b6bcc9]">Feel better, move better</p>The fix applied
Every text/background pair comes from design-tokens.json, where each pair is unit-tested to pass 4.5:1 (AA).
/* tokens: light text #0F172A on #FFFFFF = 17.85:1, tested in CI */ <h1 className="text-fg">A stronger stride starts here.</h1>Who hits this barrier
Low-vision users, older users, and anyone on a phone in sunlight simply cannot read the hero or the footer links.
See it broken (V3 on the before-site)See it fixed (V3 on the after-site)
V4. Focus indicator removed globally
WCAG 2.4.7 Focus VisibleHuman judgment requiredWhat is broken
One CSS rule, *:focus { outline: none }, applied to the whole before-variant with no replacement styling.
.before-scope *:focus { outline: none; }The fix applied
A global 3px high-contrast :focus-visible outline (token-colored, 7.9:1) with offset, never removed without a replacement.
:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }Who hits this barrier
Sighted keyboard users lose their place instantly: there is no way to see which element will activate on Enter.
See it broken (V4 on the before-site)See it fixed (V4 on the after-site)
V5. Missing and junk alt text
WCAG 1.1.1 Non-text ContentCaught by axe: image-altWhat is broken
Two provider images have no alt attribute; a third carries alt=“IMG_4821.JPG”.
<img src="/before/provider-1.svg" /> <img src="/before/provider-3.svg" alt="IMG_4821.JPG" />The fix applied
Decorative imagery is marked decorative (empty alt or aria-hidden); meaning lives in adjacent text, monograms are hidden from the tree.
<div aria-hidden="true" class="…monogram…">MO</div> <h2>Maren Oduya, PT, DPT, OCS</h2>Who hits this barrier
Screen reader users hear the raw filename or nothing at all where a staff photo should be described, or should be silent.
See it broken (V5 on the before-site)See it fixed (V5 on the after-site)
V6. Broken heading hierarchy
WCAG 1.3.1 Info and Relationships (heading structure)Caught by axe: heading-orderWhat is broken
Every section heading on the before-variant is an <h4> directly under the page <h1>.
<h1>…</h1> <h4 className="text-2xl">Our services</h4>The fix applied
A strict h1 > h2 > h3 outline; sections are labeled by their headings via aria-labelledby.
<h1>A stronger stride starts here.</h1> <h2 id="services-heading">Care built around how you move</h2> <h3>Orthopedic rehabilitation</h3>Who hits this barrier
Screen reader users navigating by headings get a skeleton that jumps from h1 to h4: sections seem missing and the outline is meaningless.
See it broken (V6 on the before-site)See it fixed (V6 on the after-site)
V7. Keyboard trap in a popup
WCAG 2.1.2 No Keyboard TrapCaught by axe: aria-dialog-name (the trap itself is a manual finding)What is broken
A newsletter popup opens on its own, swallows Tab and Shift+Tab, ignores Escape, has a mouse-only close, and no accessible name.
function trap(e) { if (e.key === "Tab") e.preventDefault(); } <div role="dialog">…<span onClick={close}>×</span></div>The fix applied
The after-site has no self-opening popup at all. Its one dialog-like moment (the form success panel) moves focus in, is named by its heading, and never traps.
/* No modal. Success panel: */ <h2 ref={successRef} tabIndex={-1}>Request received…</h2>Who hits this barrier
Keyboard users are stuck: Tab is swallowed, Escape does nothing, and the only close control is mouse-only. The session ends with the browser tab.
See it broken (V7 on the before-site)See it fixed (V7 on the after-site)
V8. Auto-playing motion that ignores reduced-motion
WCAG 2.2.2 Pause, Stop, Hide (also 2.3.3 Animation from Interactions)Caught by axe: button-name (carousel controls; the motion itself is manual)What is broken
The hero carousel auto-advances forever, overrides the reduced-motion kill switch with !important, has no pause control, and its arrows are unnamed icon buttons.
.before-carousel-track { animation: … infinite !important; } <button><svg>…</svg></button>The fix applied
No carousel. The hero animates once (a line-draw), fully gated behind prefers-reduced-motion; under reduced motion nothing moves.
@media (prefers-reduced-motion: no-preference) { .stride-path .stride-line { animation: stride-draw 1.4s ease-out both; } }Who hits this barrier
Vestibular-disorder users get motion they asked the OS to suppress; screen reader users hear unnamed buttons; nobody can pause the carousel.
See it broken (V8 on the before-site)See it fixed (V8 on the after-site)
V9. Touch targets under 24px
WCAG 2.5.8 Target Size (Minimum)Caught by axe: target-size + link-nameWhat is broken
Footer social links are 16x16px icon-only anchors with no accessible name.
<a href="…" className="block h-4 w-4"><svg>…</svg></a>The fix applied
All interactive targets are at least 44px (tokens.touchTarget); icon-only controls carry accessible names or are dropped.
/* 44px minimum enforced via min-h-11 utilities on every control */ <Link className="inline-flex min-h-11 items-center …">Services</Link>Who hits this barrier
Users with tremor or limited dexterity, and anyone on a bumpy commute, cannot reliably hit 16px icon links; screen readers announce them as nothing.
See it broken (V9 on the before-site)See it fixed (V9 on the after-site)
V10. Errors shown by color alone
WCAG 3.3.1 Error Identification (also 1.4.1 Use of Color)Human judgment requiredWhat is broken
Invalid fields get a red border. No text, no aria-invalid, no focus move, no announcement.
className={bad.has("phone") ? "border-[#dc2626] border-2" : "border"}The fix applied
An error summary (role=alert) receives focus and links each problem to its field; every field gets a text error via aria-describedby and aria-invalid.
<div ref={summaryRef} tabIndex={-1} role="alert">…There are 2 problems…</div> <input aria-invalid aria-describedby="error-phone" /> <p id="error-phone">Error: Enter a phone number…</p>Who hits this barrier
Blind users hear nothing happen after submitting; colorblind users see nothing change; nobody is told what to fix.
See it broken (V10 on the before-site)See it fixed (V10 on the after-site)
V11. Sticky header hides focused elements
WCAG 2.4.11 Focus Not Obscured (Minimum)Human judgment requiredWhat is broken
The before-header is position: sticky with no scroll-padding or scroll-margin anywhere, so focused elements scroll underneath it.
<header className="sticky top-0 z-40 …">The fix applied
The after-header is not sticky; were it sticky, scroll-padding-top would reserve its height so focused elements stay visible.
/* non-sticky header; sticky pattern requires: */ html { scroll-padding-top: 5rem; }Who hits this barrier
Keyboard users tabbing backward watch their focus disappear under the sticky header: the focused link is on screen but invisible.
See it broken (V11 on the before-site)See it fixed (V11 on the after-site)
V12. Redundant entry
WCAG 3.3.7 Redundant EntryCaught by axe: label (the confirm fields are pseudo-labeled: visible text, no association)What is broken
The form re-asks for the name and phone number the user just typed, behind visible pseudo-labels that are not programmatically associated.
<p>Confirm your name</p> <input name="nameConfirm" type="text" />The fix applied
Ask once. The after-form collects each fact exactly one time and says so.
/* one name field, one phone field; copy: */ <p>Tell us once. We will not ask you to repeat any of this when we call back.</p>Who hits this barrier
Users with cognitive or motor disabilities pay double for every field; on mobile everyone does.
See it broken (V12 on the before-site)See it fixed (V12 on the after-site)