Moro
Gallery
IFoundations
01Why motion exists in UI
02Timing & duration
03Easing
04Springs vs tweens
IIExpression
05Anticipation
06Follow-through & overlapping action
07Squash, stretch & impact
08Arcs
IIIEngineering
09Performance
10Layout animations & FLIP
11Interruptibility & interaction
12Accessibility & restraint
Tier IIIEngineering12

Accessibility & restraint

Respecting the users your animation could hurt

Motion can cause genuine physical harm. For some users with vestibular disorders, a page that auto-scrolls or zooms can trigger vertigo, nausea, and migraines. For many more, constant background animation is a cognitive tax that fragments attention. Building a motion system without thinking about these users isn't just inconsiderate; it's an accessibility failure that browsers now let users correct with a single setting. Respect it.

#The idea

Every major OS ships a reduce motion preference. On macOS and iOS it's under Accessibility, Display. On Windows it's in Settings, Ease of Access. Users who enable it are telling you: your animations are causing me a problem. The correct response is to disable or drastically simplify your motion. Not to ignore the preference and hope they get used to it.

The browser exposes this as a media query: @media (prefers-reduced-motion: reduce). JavaScript can read it via window.matchMedia('(prefers-reduced-motion: reduce)').matches. Motion provides the useReducedMotion() hook.

The right approach has three layers, because no single technique catches everything:

  • CSS layer: a global rule that near-disables all CSS transitions and animations when the preference is set. This catches every hover transition, every Tailwind transition-colors, every @keyframes animation.
  • JavaScript animation layer: Motion animations don't read CSS transition-duration, so they need their own guard. Use useReducedMotion() and pass { duration: 0 } when the user prefers reduced motion.
  • Timer layer: auto-playing animations (loops that fire every few seconds) aren't technically animations from the browser's perspective; they're setInterval calls that flip state. Even if the animations themselves are disabled, the timers keep firing. Gate them explicitly on useReducedMotion().

Beyond reduced-motion, there's a broader principle: restraint. The single best accessibility gift you can give users is to animate less. The frequency rule applies here. Motion users will see 100 times a day should be barely there or absent entirely.

Canonical example

Enable "Reduce Motion" in your OS settings right now. Refresh any modern web app. Good ones (Apple's own sites, Linear, Vercel's dashboard) go almost perfectly still; transitions collapse to instant. Bad ones continue animating as if nothing happened. The difference is a few lines of CSS and JavaScript most of the time. Moro itself implements all three layers, which is why demo auto-loops stop when reduce-motion is on.

#When it applies

  • Every animation you ship. This is a baseline, not a feature.
  • Especially: parallax, auto-scrolling, zoom effects, and any motion over 300ms. These are the biggest vestibular triggers.
  • High-frequency motion: hover states, button feedback. These get enforced by CSS alone if you use native transition-duration properties.

#When it doesn't

  • Never. There is no case where you should ignore prefers-reduced-motion.
  • The only permitted exception: a motion-focused teaching tool that explicitly asks the user show me motion examples. Moro is such a tool, and even Moro disables auto-loops under reduced motion and lets users explore demos on demand.

#Implementation

The CSS safety net. Add once, covers every CSS transition/animation.

app/globals.css
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Motion animations. Use the useReducedMotion() hook.

components/Card.tsx
import { motion, useReducedMotion } from "motion/react"
 
function Card() {
  const shouldReduceMotion = useReducedMotion()
  return (
    <motion.div
      initial={shouldReduceMotion ? false : { opacity: 0, y: 12 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: shouldReduceMotion ? 0 : 0.3 }}
    />
  )
}

Timers and auto-loops. Gate the effect itself.

components/LoopingDemo.tsx
function LoopingDemo() {
  const shouldReduceMotion = useReducedMotion()
  const [isAnimating, setIsAnimating] = useState(false)
 
  useEffect(() => {
    if (shouldReduceMotion) return  // don't start the loop
    const interval = setInterval(() => {
      setIsAnimating(true)
      setTimeout(() => setIsAnimating(false), 800)
    }, 2200)
    return () => clearInterval(interval)
  }, [shouldReduceMotion])
  // ...
}

#Craft notes

  • Don't just disable, simplify. Sometimes initial={false} (skip enter animation) plus instant snap is a better answer than nothing, because the user still perceives state changes.
  • Test with the OS setting enabled. Don't rely on a toggle in your own app. Flip the OS setting and use your site.
  • Touch devices trigger hover on first tap. Wrap hover-lift animations in @media (hover: hover) so they only fire on true pointer devices.
  • Frequency is accessibility. An animation users see 100 times a day should be nearly invisible. High-frequency motion is attention tax, and attention tax is its own form of exclusion.
  • useReducedMotion() returns null during SSR. Treat null as "unknown, don't animate yet" if you want to avoid hydration flashes.

#Exercises

  1. Enable reduce-motion and browse. Turn on Reduce Motion in your OS. Spend 15 minutes browsing major sites (Apple, Google, Linear, Figma, your own). Note which ones handle it gracefully and which don't.
  2. Audit your own site. With Reduce Motion enabled, find every animation that still plays at full speed. Those are bugs.
  3. Measure frequency. Count how many times a day you'll see a given animation. Anything above 50/day (the hover states, button presses, menu opens) should be under 150ms or disabled.

#Study this in the wild

  • Apple's own marketing sites. Enable Reduce Motion and reload a product page. Almost everything collapses to instant. This is the bar.
  • Moro itself. This library implements all three layers. Toggle Reduce Motion, demos stop looping, transitions collapse, but the content stays navigable.

#Further reading

  • MDN: prefers-reduced-motion.
  • Motion: useReducedMotion.
  • Emil Kowalski: Accessibility.
  • Val Head: Designing Safer Web Animation.
Lesson 11
Interruptibility & interaction

On this page

The ideaWhen it appliesWhen it doesn'tImplementationCraft notesExercisesStudy this in the wildFurther reading

Built by David Umoru