How an Accessibility SaaS Broke Its Own Landing (and How We Fixed It)
The Phantom Content Bug: Architecting CSS Reveals for prefers-reduced-motion Compliance
Current Situation Analysis
Marketing pages and modern UI frameworks frequently employ "reveal" animations to guide user attention. Elements enter the viewport with a fade or slide effect, creating a polished experience. However, a pervasive architectural flaw exists in how these reveals are implemented, causing content to vanish entirely for users who enable prefers-reduced-motion.
This issue is systematically overlooked because it masquerades as a cosmetic regression rather than a functional outage. Developers often assume that respecting reduced motion preferences simply means disabling the animation, leaving the content in its final state. In reality, if the visible state is encoded as the destination of a transition, disabling the transition prevents the element from ever reaching that state. The result is content that is technically present in the DOM but visually invisible, effectively acting as display: none for a specific user segment.
This violates the spirit of WCAG 2.3.3 (Animation from Interactions, AAA), which mandates that users can disable animations. More critically, it creates an accessibility barrier where the remediation (disabling motion) causes a total loss of information. For users with vestibular disorders, migraines, or ADHD, the browser's respect for their preference should not result in a blank page.
WOW Moment: Key Findings
The core insight is that opacity-driven reveals are inherently unsafe when combined with reduced motion preferences. The browser honors the preference by skipping the transition; if opacity is the mechanism for visibility, skipping the transition locks the element in an invisible state.
The following comparison demonstrates why transform-only approaches are superior for reveal patterns.
| Implementation Strategy | Visibility under prefers-reduced-motion |
Animation Quality (Motion Enabled) | CSS Complexity | Risk of Phantom Content |
|---|---|---|---|---|
| Opacity Start State | Invisible (Bug) | Smooth Fade | Low | Critical |
| Transform Only | Visible (Instant) | Smooth Slide | Low | None |
| Media Query Override | Visible (Instant) | Smooth Fade | Medium | Low (if MQ missed) |
Why this matters: The Transform-Only strategy provides a "free" accessibility fix. By decoupling visibility from animation, you ensure content is always rendered, while still delivering motion to users who want it. This eliminates the need for conditional CSS blocks and reduces the cognitive load of maintaining separate states for motion preferences.
Core Solution
The fix requires rethinking how you define the "hidden" and "visible" states. The error occurs when opacity: 0 is the initial state and opacity: 1 is the target of a transition. When the browser disables the transition, the element remains at opacity: 0.
Strategy 1: Transform-Only Reveals (Recommended)
For scroll-triggered reveals, slide-ins, or entry animations, animate only transform. Opacity should remain at its default value of 1. This allows the browser to skip the transform when reduced motion is active, leaving the element visible at its final position.
Implementation:
// Safe: Visibility is independent of animation
.card-entry {
/* Opacity defaults to 1. No opacity transition. */
transform: translateY(1.5rem);
transition: transform 400ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card-entry.is-rendered {
transform: translateY(0);
}
Architecture Rationale:
- Decoupling: Visibility is static; motion is dynamic. This aligns with the browser's contract for reduced motion.
- Performance:
transformis composited and does not trigger layout or paint, maintaining 60fps performance. - Simplicity: No media queries required. The browser handles the preference automatically.
Strategy 2: Media Query Guard (For Genuine Fades)
Some patterns require opacity changes, such as cross-fading between two images or a modal overlay that must fade in. In these cases, you must explicitly override the opacity under reduced motion.
Implementation:
.modal-overlay {
opacity: 0;
transition: opacity 300ms ease;
pointer-events: none;
}
.modal-overlay.is-open {
opacity: 1;
pointer-events: auto;
}
/* Explicit override for reduced motion */
@media (prefers-reduced-motion: reduce) {
.modal-overlay.is-open {
opacity: 1;
transition: none;
}
}
Architecture Rationale:
- Necessity: Use this only when opacity is the sole mechanism for visibility (e.g., overlays).
- Maintenance: Requires vigilance to ensure every opacity transition has a corresponding media query rule.
Strategy 3: Clip-Path Alternatives
For cases where you need a reveal that doesn't involve movement, clip-path can be used. Like transform, it is safe under reduced motion if the base state is visible.
.reveal-clip {
clip-path: inset(0 0 100% 0); /* Hidden by clipping */
transition: clip-path 500ms ease;
}
.reveal-clip.is-visible {
clip-path: inset(0 0 0 0);
}
Note: Ensure the clipped area does not cause layout shifts. clip-path is generally safe but verify browser support for your target audience.
Pitfall Guide
1. The Opacity Zero Trap
Explanation: Initializing an element with opacity: 0 and relying on a transition to opacity: 1.
Impact: Content is invisible when prefers-reduced-motion is active.
Fix: Use transform, scale, or clip-path for the animation. Keep opacity at 1.
2. The Skeleton Fade-Out
Explanation: A common pattern where a skeleton loader fades out (opacity: 1 to 0) and real content fades in (opacity: 0 to 1).
Impact: Under reduced motion, the skeleton may disappear, but the real content remains at opacity: 0, resulting in empty space.
Fix: Ensure real content uses transform-based reveals or has a media query forcing opacity: 1. Alternatively, swap display properties rather than fading.
3. Staggered Delays with Opacity
Explanation: Using transition-delay for staggered card reveals combined with opacity.
Impact: The delay mechanism may still apply, or the opacity state remains locked. Users see nothing or delayed nothing.
Fix: Apply stagger delays to transform only. Ensure base opacity is 1.
4. False Positive Grep Results
Explanation: Searching for opacity: 0 and assuming all instances are bugs.
Impact: Wasted effort fixing elements that are intentionally hidden (e.g., display: none alternatives or off-screen content).
Fix: Contextual analysis is required. Only flag opacity: 0 when it is paired with a transition or animation intended to make the element visible.
5. Library Assumptions
Explanation: Assuming animation libraries (Framer Motion, GSAP, Vue Transitions) handle reduced motion automatically.
Impact: Many libraries require explicit configuration to respect prefers-reduced-motion. Default behavior may still animate opacity.
Fix: Audit library configurations. Enable reduced motion support explicitly in library settings or wrap animations in media query checks.
6. The "Transition None" Fallacy
Explanation: Adding transition: none under reduced motion without fixing the opacity state.
Impact: If opacity: 0 is the start state, transition: none simply means the element stays at opacity: 0 instantly. The content remains invisible.
Fix: You must also set opacity: 1 in the media query, or better yet, avoid opacity animations entirely.
7. CI Blindness
Explanation: Relying solely on manual testing or standard accessibility scanners.
Impact: Automated scanners like axe-core do not emulate user preferences like prefers-reduced-motion. The bug persists in production.
Fix: Implement Playwright tests that emulate reduced motion and assert visibility.
Production Bundle
Action Checklist
- Audit Codebase: Run a grep for
opacity: 0combined withtransitionoranimation. Review each instance for visibility risks. - Refactor Reveals: Convert opacity-driven reveals to transform-only animations where possible.
- Check Skeletons: Verify skeleton loader patterns do not leave real content invisible under reduced motion.
- Library Config: Audit third-party animation libraries for reduced motion support and enable it.
- Add CI Test: Implement a Playwright test to assert visibility under
reducedMotion: 'reduce'. - Review Modals: Ensure overlays and modals have explicit media query overrides if they rely on opacity.
- Monitor: Add logging or error tracking for accessibility-related user reports to catch regressions early.
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Scroll Reveal / Entry Animation | Transform Only | Zero conditional CSS; browser handles preference; high performance. | Low |
| Modal Overlay / Cross-fade | Media Query Override | Opacity is required for the visual effect; MQ ensures visibility. | Medium |
| Complex Choreography | JS Library + PMRM Config | Libraries offer granular control; must configure explicitly. | High |
| Toast Notifications | Transform Only | Toasts should appear instantly or slide; opacity fade is unnecessary risk. | Low |
Configuration Template
Playwright Test for Reduced Motion:
import { test, expect } from '@playwright/test';
test.describe('Reduced Motion Compliance', () => {
test('reveal elements remain visible under prefers-reduced-motion', async ({ page }) => {
// Emulate user preference
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/');
// Select elements that use reveal animations
const revealElements = page.locator('[data-reveal]');
const count = await revealElements.count();
for (let i = 0; i < count; i++) {
const element = revealElements.nth(i);
// Assert opacity is effectively 1
const opacity = await element.evaluate((el) => {
return getComputedStyle(el).opacity;
});
expect(Number(opacity)).toBeGreaterThan(0.99);
}
});
});
CSS Reset for Safety:
/* Global safety rule for reveal patterns */
[data-reveal] {
opacity: 1;
/* Ensure base state is visible */
}
[data-reveal].is-hidden {
/* Use transform for hiding if necessary, not opacity */
transform: translateY(1rem);
opacity: 1;
}
Quick Start Guide
- Scan: Run
grep -rE "opacity:\s*0.*transition" --include="*.css" --include="*.scss" .to identify potential offenders. - Fix: For each result, remove
opacityfrom the transition and ensure the element hasopacity: 1by default. Move animation totransform. - Test: Add the Playwright test snippet to your CI pipeline. Run it against your staging environment.
- Verify: Manually toggle
prefers-reduced-motionin your browser developer tools and scroll through the page to confirm content remains visible. - Commit: Merge the changes with the new test to prevent future regressions.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
