WCAG 2.3.3 Animation from Interactions: A Compliance Checklist
SC 2.3.3 Animation from Interactions says one thing, and says it broadly: “Motion animation triggered by interaction can be disabled, unless the animation is essential to the functionality or the information being conveyed.” The clause that matters for this site’s subject is invisible until you name it — scrolling is an interaction. Every reveal bound to animation-timeline: view(), every parallax layer, every scroll-linked scale or rotation is interaction-triggered motion under this criterion, and so is every view transition, because navigating is an interaction too. This article turns the criterion into a working checklist — inventory, classification, the prefers-reduced-motion disable path, and verification — as part of the WCAG 2.2 animation compliance criteria guide in the Accessibility & Inclusive Motion Standards section.
When to use this approach
SC 2.3.3 is a Level AAA criterion. Most legal accessibility floors — public-sector mandates, procurement requirements — stop at Level AA, so 2.3.3 conformance is formally optional in a way that SC 2.2.2 Pause, Stop, Hide is not. Treat that as a statement about audits, not about users: the harm 2.3.3 addresses — vertigo, nausea, and disorientation triggered in people with vestibular disorders by large or depth-simulating movement — is real at every conformance level. Three situations make the AAA target worth adopting as a hard requirement:
- Your interface is motion-forward. A site built on scroll-driven animations and view transitions has, by definition, a large 2.3.3 surface. The more motion carries your design, the more users need the off switch.
- The cost is already sunk. If you follow the prefers-reduced-motion implementation techniques this section recommends, you are most of the way to 2.3.3 conformance before you start. The checklist below mostly verifies coverage rather than demanding new architecture.
- You have received a motion complaint. One report of scroll-induced dizziness usually means many silent departures. 2.3.3 is the criterion that names the fix.
Scope the criterion precisely before auditing, because both words in “motion animation” are load-bearing:
- In scope: transform-based movement (translation, scaling, rotation, skew), parallax and other depth simulation, elements that slide, zoom, bounce, or morph in response to scroll, hover, focus, click, or navigation.
- Out of scope: animation without spatial displacement — opacity fades, color and background transitions, border and shadow changes. A cross-fade is animation but not motion animation, which is exactly why it is the standard replacement.
- Someone else’s problem: motion that starts without interaction. Auto-playing loops, tickers, and ambient background drift are governed by SC 2.2.2 and its five-second rule, covered in the Pause, Stop, Hide analysis. The two criteria partition the animation surface cleanly: 2.2.2 takes what starts by itself, 2.3.3 takes what the user starts.
Implementation
Step 1: Inventory every interaction-triggered animation
Start from the code, not from a visual walkthrough — scrubbing pages by eye misses conditional animations and effects gated behind breakpoints. Grep for the full modern surface:
// Search targets for the 2.3.3 inventory (run against src, not dist)
const auditPatterns = [
'animation-timeline', // scroll- and view-linked motion
'animation:', // shorthand declarations
'transition', // hover/focus micro-interactions
'view-transition-name', // elements participating in view transitions
'startViewTransition', // JS-initiated navigation animation
'.animate(', // Web Animations API call sites
'scroll-behavior', // programmatic smooth scrolling
];
For each hit, record four columns: the element, the trigger (scroll, hover, focus, click, navigation), whether the keyframes move anything spatially, and worst-case amplitude (how far, how large, how fast). The amplitude column pays off in Step 4, when you decide between removing motion and shrinking it.
Step 2: Separate motion animation from non-motion animation
2.3.3 does not require disabling animation; it requires disabling motion animation. Split the inventory:
/* MOTION — in scope for 2.3.3: spatial displacement */
@keyframes slide-up { from { transform: translateY(3rem); } to { transform: none; } }
@keyframes zoom-in { from { transform: scale(0.8); } to { transform: none; } }
@keyframes spin-badge { to { transform: rotate(360deg); } }
/* NOT motion — out of scope: no displacement, safe to keep */
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes tint-shift { from { background-color: #eef; } to { background-color: #fff; } }
Anything that mixes both — a reveal that fades and slides — is in scope for its transform component only. That distinction is the basis of the replace-don’t-remove strategy in Step 4: strip translateY, keep opacity.
Step 3: Judge essential exemptions — narrowly
The criterion exempts animation “essential to the functionality or the information being conveyed.” Legitimate claims are rare and specific:
- A drag ghost tracking the pointer — the motion is the interaction’s feedback.
- A pinch-zoom or map pan — the displacement is the information.
- A reading progress bar arguably qualifies, since its “motion” is a direct readout of scroll position — though simplifying it under reduced motion costs nothing.
Illegitimate claims are common and generic: “the parallax hero is essential to our brand,” “the zoom communicates hierarchy.” If the page still works and still informs with the element static, the motion is decorative. Write one sentence of rationale per exemption into the inventory; an undocumented exemption looks like an oversight under audit.
Step 4: Wire the CSS disable path
The normatively sanctioned disable mechanism is the OS-level motion preference surfaced by the prefers-reduced-motion media query — 2.3.3’s own understanding document points at exactly this technique. The CSS override patterns for prefers-reduced-motion cover the architecture in depth; the 2.3.3-critical details compressed into one block:
@supports (animation-timeline: view()) {
.reveal {
opacity: 0;
transform: translateY(2rem);
animation: reveal-slide linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
@keyframes reveal-slide {
to { opacity: 1; transform: none; }
}
/* The 2.3.3 disable path, shipped in the same guard as the motion */
@media (prefers-reduced-motion: reduce) {
.reveal {
animation: none; /* stop the keyframes... */
animation-timeline: auto; /* ...AND detach the scroll timeline */
opacity: 1; /* restore the readable end state */
transform: none;
}
}
}
/* Programmatic smooth scrolling is motion animation too */
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
Two rules embedded there are the ones audits most often find violated. First, animation: none does not reset animation-timeline — the longhand survives the shorthand, so a scroll timeline stays attached and re-engages the moment any later rule reapplies an animation. Always reset both. Second, restore the end state explicitly: if the element’s resting style is opacity: 0 awaiting a reveal, disabling the animation without restoring opacity: 1 conforms with 2.3.3 and fails everything else — reduced-motion users get blank pages.
Step 5: Guard JavaScript-triggered motion
CSS media queries cannot reach animations you start imperatively. Every element.animate() call and every document.startViewTransition() call needs the same condition, read through matchMedia — the full pattern, including reacting to live preference changes, is in detecting prefers-reduced-motion in JavaScript with matchMedia:
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
function navigateWithTransition(updateDOM) {
// Skipping the transition IS the 2.3.3 disable mechanism for navigation
if (reduceMotion.matches || !document.startViewTransition) {
updateDOM();
return;
}
document.startViewTransition(updateDOM);
}
reduceMotion.addEventListener('change', () => {
// Re-evaluate any long-lived WAAPI animations when the user flips the toggle
document.getAnimations()
.filter((a) => a.effect?.target?.dataset.motionOptional === 'true')
.forEach((a) => (reduceMotion.matches ? a.cancel() : a.play()));
});
Skipping startViewTransition() outright beats zeroing ::view-transition-* durations: near-zero durations still allocate snapshot layers and still fire the pseudo-element lifecycle, while the guard delivers an instant swap for free.
Step 6: Replace motion — do not just delete it
2.3.3 asks that motion can be disabled, not that feedback disappear. A slide-in reveal collapsed to nothing removes the affordance that new content arrived; a slide-in converted to a fade keeps the affordance and drops the displacement. The vestibular-safe motion design patterns catalog which motion categories trigger symptoms and which opacity- and color-based substitutes preserve intent:
@media (prefers-reduced-motion: reduce) {
.reveal {
animation-name: reveal-fade; /* swap the keyframes, keep the timing */
}
@keyframes reveal-fade {
from { opacity: 0; }
to { opacity: 1; }
}
}
Where motion carries genuine orientation value — spatial interfaces, step-by-step wizards — a proportional reduction can serve users better than a binary cut. The motion scaling approach drives amplitude and duration from custom properties so “reduce” means small and brief rather than gone; that still satisfies “can be disabled” when the residue contains no vestibular triggers.
Verification
Work through the checklist against a production build, not a dev server with unshipped flags:
- Emulate the preference. In Chrome DevTools, open the Rendering tab and set Emulate CSS media feature prefers-reduced-motion to
reduce. Scroll every page top to bottom, hover and focus interactive elements, and navigate between views. Nothing may translate, scale, rotate, or parallax except documented exemptions. - Interrogate the animation registry. With emulation active, run this while scrolling:
document.getAnimations()
.filter((a) => a.playState === 'running')
.map((a) => ({
target: a.effect?.target,
props: a.effect?.getKeyframes?.().flatMap((k) => Object.keys(k)),
}))
.filter((e) => e.props?.some((p) => p === 'transform' || p === 'translate'
|| p === 'scale' || p === 'rotate'));
The result should be empty, or contain only your exemption list. Opacity-only animations surviving here are fine — they are not motion.
- Test the real OS toggles. DevTools emulation validates your CSS, not the pipeline from operating system to browser. Flip the genuine settings — Reduce Motion on macOS and iOS, “Animation effects” on Windows, “Remove animations” on Android — on at least one device per platform you support. The cross-platform testing procedure for prefers-reduced-motion walks each toggle path.
- Confirm the live-change path. Flip the OS toggle while the page is open. The media query re-evaluates immediately; your
matchMediachange listeners must too. A page that requires reload to honor the preference technically conforms but fails users mid-vertigo, when they need it most. - Lock it into CI. Playwright’s
page.emulateMedia({ reducedMotion: 'reduce' })plus the registry query from item 2 makes the whole criterion a regression test: navigate, scroll viamouse.wheel, assert the filtered array is empty.
The condensed release-gate version:
Edge cases and gotchas
The shorthand trap, again. Because it is the highest-frequency failure, it bears repeating in checklist form: animation: none leaves animation-timeline attached. The pair must travel together in every override, or the timeline silently re-binds.
Content that exists only mid-animation. Entrance effects that start elements at opacity: 0 and rely on animation-fill-mode: both to reach visibility make the animation load-bearing. Structure styles so the unanimated state is the complete, readable one, and let the @supports block introduce the hidden starting pose — then the reduced-motion override only needs to unwind what the guard added.
Smooth scrolling and scripted scroll tours. scroll-behavior: smooth and scrollIntoView({ behavior: 'smooth' }) animate the viewport itself — the largest possible moving region. Both are interaction-triggered and both need the reduce fallback to instant jumps. A scripted tour that smooth-scrolls through scroll-linked keyframes compounds the problem: the script drives the timeline, and parallax layers built in pure CSS will play at full amplitude with the user’s hands nowhere near the wheel.
Hover transitions on transform. A transition: transform 150ms lift on cards is interaction-triggered motion in the criterion’s plain reading. Tiny, brief displacement is rarely a vestibular trigger — but the cheap, defensible move is to zero transform transitions under reduce along with everything else, via a duration token reset.
Third-party embeds. Iframed widgets and imported animation libraries do not read your CSS overrides. Where an embed animates on interaction and offers a static or reduced-motion mode, wire your matchMedia result into its configuration; where it offers nothing, the conforming options are replacing it or gating it behind a facade that loads on explicit click.
Misfiling 2.2.2 problems under 2.3.3. An auto-advancing carousel does not become conformant because you added a reduced-motion override — it starts without interaction, so it is a Pause, Stop, Hide problem requiring an on-page control available to all users, not only those with an OS preference set. Keep the two inventories separate.
Browser-specific notes
Chrome / Edge (Chromium). animation-timeline ships in Chrome 115 and Edge 115, so Chromium is where your scroll-linked 2.3.3 surface actually runs — make it the primary audit target. The Rendering-tab emulation, the Animations drawer, and getAnimations() reporting ScrollTimeline instances give you the full verification toolkit in one browser.
Firefox. Scroll-driven animations sit behind a pref (enabled in Nightly), so stable Firefox executes only your time-based and transition-based motion. Your 2.3.3 obligations there reduce to hover/focus transforms, WAAPI calls, and smooth scrolling — smaller surface, same checklist. Firefox honors prefers-reduced-motion from the OS setting on all desktop platforms.
Safari. Scroll-driven animation support is in active development and available in Technology Preview builds; treat stable Safari like stable Firefox for now. Same-document view transitions work from Safari 18, so the startViewTransition guard from Step 5 is load-bearing there today. Safari has honored prefers-reduced-motion since it first shipped the media query, wired to the macOS and iOS Reduce Motion toggles.
All engines. prefers-reduced-motion is universally supported in current browsers — the one 2.3.3 mechanism you can rely on without feature detection. The asymmetric animation support works in your favor: a browser that cannot run animation-timeline renders the static baseline and cannot fail the criterion, provided that baseline is the complete, readable state the gotchas above insist on.
Related
- Building Reduced-Motion Variants of View Transitions — crossfade substitutes that keep navigation feedback while satisfying the disable requirement
- Motion Preference Support Matrix: Browsers & Operating Systems — where the OS toggles behind
prefers-reduced-motionlive, per platform and engine - Building a Motion Intensity Toggle with CSS Custom Properties — an on-page control that layers user choice on top of the OS preference
- Understanding the CSS Scroll Timeline API — the
scroll()andview()semantics behind the timelines this checklist audits