We sell accessibility audits. Last week, we shipped a marketing landing for our scanner. It used scroll-triggered reveals. Under prefers-reduced-motion the reveals never resolved — the content stayed invisible. Our own landing failed the test we sell. Here is the one-line CSS fix, the Playwright assertion that locks it in CI, and what to look at in your own codebase.
Same visual intent, two implementations. Only the right one is safe.
The bug
Modern marketing pages use reveal-on-scroll animations. The element starts invisible (or off-position), then animates into view when it enters the viewport. The canonical CSS looks like this:
.reveal {
opacity: 0;
transform: translateY(20px);
transition: opacity 600ms ease, transform 600ms ease;
}
.reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
An IntersectionObserver toggles .is-visible as the element scrolls in. Looks great.
Then someone with vestibular disorder, migraine sensitivity, or ADHD opens your site. Their OS-level setting is Reduce motion. Their browser exposes this via prefers-reduced-motion: reduce. Most animation libraries respect it correctly: when reduced motion is requested, transitions are disabled.
That is precisely the problem. With transitions disabled, the element never moves from its start state. opacity: 0 stays at opacity: 0. The reveal never resolves. Your visitor sees a blank page.
Why opacity-driven reveals are an accessibility bug, not a feature
It is tempting to think the browser is being "too literal." It is not. Reduced motion is a contract: do not animate. The browser honours it by not running the keyframe. The bug is in your CSS: you encoded the visible state as the end of an animation that you have just told the browser to skip.
WCAG 2.3.3 (Animation from Interactions, AAA) and the broader principle in WCAG 2.1 §2.3 are about avoiding harm. But here we are not even debating triggers — we are removing the visible content entirely. The reveal becomes a display: none for anyone with reduced motion enabled.
The transform-only fix
Stop animating opacity. Animate only transform:
.reveal {
transform: translateY(20px);
transition: transform 600ms ease;
}
.reveal.is-visible {
transform: translateY(0);
}
Now opacity stays at its default of 1. Under reduced motion, the browser skips the transform, the element appears at its final position immediately, content visible from first paint. With motion enabled, the translate plays as before. Same visual intent, no regression.
For the genuine case where you need opacity (a fade-in modal, a real cross-fade), wrap the rule:
@media (prefers-reduced-motion: reduce) {
.reveal { opacity: 1; transition: none; }
}
This says, plainly: under reduced motion, no transition, content visible. Two lines, no library needed.
Locking the regression out with Playwright
Code fixes are easy to lose during a refactor. A test makes the rule permanent. In Playwright:
import { test, expect } from '@playwright/test'
test('reveals remain visible under prefers-reduced-motion', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto('/')
const reveals = page.locator('.reveal')
const count = await reveals.count()
for (let i = 0; i getComputedStyle(el).opacity)
expect(Number(opacity)).toBeGreaterThan(0.99)
}
})
Wire it into your CI as a blocking job. Now no future commit can re-introduce the bug without the pipeline turning red.
Other places this pattern hides
- Hero text fade-in — same opacity issue. Replace with translateY or remove entirely.
-
Stagger reveals on cards — each card with
opacity: 0until a delay-based trigger fires. Same fix. - Skeleton loaders that fade out — if the real content fades in via opacity, under PMRM you get both layers stuck. Use display swap or set opacity:1 under PMRM.
- Off-canvas drawers with opacity transitions on inner content — drawer opens, content invisible.
- Toast notifications that animate in with opacity — the toast appears empty.
How to find every offender in your codebase
A blunt grep is usually enough:
grep -rE "opacity:\s*0" --include="*.css" --include="*.scss" --include="*.tsx" .
Anything that returns deserves a look. The fix is repetitive but mechanical.
If you ship motion at all, audit your codebase once and add the Playwright guard once. After that the rule self-enforces. Total cost: an hour. Cost of skipping it: any user with reduced motion enabled cannot see your landing page.
Run a free scan of your own landing
If you have not audited your own marketing site lately, our free WCAG scanner runs axe-core on any public URL, no signup, returns a triaged report in under a minute. It will not catch this specific bug (reduced-motion testing is not part of WCAG conformance criteria — axe-core does not emulate user preferences), but it will catch most other things. For the reduced-motion case specifically, run the Playwright test above. It is the cheapest insurance you can buy against an entire class of accessibility regressions.
Originally published on access-proof.com.
Top comments (0)