Skip to content

Scroll animations that respect people: Motion, Embla, and reduced motion

Jaskaran Singh4 min read

Animation is the easiest part of a build to get wrong in a way that no test suite notices. A reveal that never resolves, a carousel that cannot be paused, a counter that lies about its final value — all of these pass every visual check and fail real people.

This is the three-layer contract this site uses for every moving element, and the specific implementation of each.

Motion is polish, not content

The first rule: content must be fully readable when the animation never runs. That single rule produces all the requirements that follow. If the animation never runs, the reveal must not hide anything, the carousel must show its first slide, and the counter must show its final number.

Everything below is a consequence of that rule.

The no-JS problem with scroll reveals

Motion's whileInView works by rendering the "hidden" state — opacity zero, translated down — as inline styles during server rendering, then animating to visible when the element enters the viewport. That is correct for users with JavaScript, and a blank page for users without it.

The fix is to scope the hidden state to JavaScript-enabled documents. An inline script marks the document, and one CSS rule unwinds the inline styles when the marker is absent:

tsx
// layout.tsx — runs before first paint
<script dangerouslySetInnerHTML={{ __html: 'document.documentElement.classList.add("js")' }} />
css
html:not(.js) [data-reveal] {
  opacity: 1 !important;
  transform: none !important;
}

The !important is not sloppy here — it is the mechanism. Motion's initial state is an inline style, and an author !important declaration is the only thing in the cascade that overrides one. The reveal component carries a data-reveal attribute, and the entire fallback is four lines of CSS.

prefers-reduced-motion: two failure modes

Reduced motion is not a preference for "less animation". For some users it prevents nausea and migraines, so the correct behaviour is to remove the animation, not shorten it.

The CSS layer catches anything declarative:

css
@media (prefers-reduced-motion: reduce) {
  [data-reveal] {
    opacity: 1 !important;
    transform: none !important;
  }
  .marquee-track {
    animation: none;
  }
}

The JavaScript layer catches behaviour that CSS cannot reach. Counters read the media query before deciding whether to animate at all:

tsx
useEffect(() => {
  if (!inView) return;
  // Reduced motion: the final value is already rendered — nothing to do.
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
  const controls = animate(0, value, { duration: 0.9, onUpdate: setDisplay });
  return () => controls.stop();
}, [inView, value]);

Note where the check lives: inside the effect, not during render. Reading a media query during render produces different output on the server and the client, which is a hydration mismatch. Effects run only in the browser, so this is always consistent.

Carousels need a pause control

WCAG 2.2.2 says any content that moves automatically for more than five seconds must be pausable. The testimonial carousel in this site auto-scrolls, so it has a pause button — and the button is not the only pause mechanism:

  • Hovering the carousel pauses it (stopOnMouseEnter)
  • Focusing anything inside pauses it (stopOnFocusIn)
  • The pause button toggles autoplay and reflects state through aria-pressed
  • Under reduced motion the autoplay plugin is stopped on mount and the pause button is hidden entirely — there is nothing to pause

That last point is easy to miss. A pause button for a carousel that does not move is noise, and hiding it under reduced motion is state-aware design rather than an oversight.

Marquees: CSS-only, with an escape hatch

The tech marquee is a pure CSS keyframe animation — the right tool for a continuous strip, and the cheapest. It plays a 36-second loop over a doubled list, and pauses on :hover, on :focus-within, and via its own control:

css
.marquee-track {
  animation: marquee 36s linear infinite;
}
.marquee-paused .marquee-track,
.marquee-track:hover,
.marquee-track:focus-within {
  animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
  .marquee-track {
    animation: none;
  }
}

The duplicated half of the list is aria-hidden, so screen readers hear each tool exactly once.

What automation catches, and what it misses

Axe-core — the engine behind most accessibility audits — does not test whether animation is pausable or whether reduced motion is honoured. It catches contrast, labels and landmark structure; motion is on the author.

So the motion contract is verified manually in the build script instead: the page is loaded with reducedMotion: "reduce" emulated, and the script asserts that the testimonial pause control is not visible. That is a two-line test for a requirement that no linter will ever enforce.

The rule I keep coming back to: if the animation is the only way to understand the content, it is not an animation — it is a bug.

More posts.