accessible-animation
Back to list

iart-ai/accessible-animation

Accessible Animation

GitHub

Tiered reduced-motion patterns for CSS, GSAP, Framer Motion, Lenis, and other web animation systems.

2K installsAccessibilityMotionFrontend0 demos

Real-world examples not captured yet

This skill shows a gallery of live HTML demos (CDN libraries welcome — GSAP, Three.js, etc.) rendered inline via srcdoc.

Skill markdown
# Accessible Animation (Reduced Motion, Tiered)

Honor `prefers-reduced-motion: reduce` as a *graceful degradation*, not a kill switch. The OS-level setting (macOS: Settings > Accessibility > Display > Reduce motion; Windows: Settings > Accessibility > Visual effects > Animation effects; iOS/Android equivalents) signals that *vestibular-triggering* motion causes nausea, dizziness, or migraines. The correct response is to remove the dangerous motion while keeping orientation cues.

## When to use

Use when adding or auditing animation in a web UI, when a user reports motion sickness or WCAG findings, or when integrating animation libraries (GSAP, Framer Motion, Lenis smooth scroll, Anime.js, CSS keyframes) into an accessible product.

## The core principle: tier motion, do not nuke it

Removing *all* animation is a common over-correction. An instant state change with no transition can be more disorienting (elements teleport) and removes useful affordances (focus rings, loading spinners that communicate progress). Sort every animation into three tiers:

- **Tier 1 — Remove entirely (vestibular triggers).** Parallax; large-area slides/translates across the viewport; scale/zoom of large elements; 3D rotation and spin; continuous auto-playing carousels and marquees; smooth-scroll hijacking (Lenis/Locomotive); scroll-jacked pinned scenes; motion-path animation; anything that moves a large portion of the screen or implies depth/self-motion.
- **Tier 2 — Soften / shorten.** Replace movement with a short opacity fade (≤200ms). Reduce distance and duration. Keep the *fact* of a transition (so elements don't teleport) but strip the displacement. Replace a 600ms slide-in-from-left with a 150ms cross-fade.
- **Tier 3 — Always keep.** Opacity fades, color/background transitions, focus-ring transitions, loading indicators, and motion that is *essential* to meaning (WCAG allows essential animation). These rarely trigger vestibular responses because they imply no self-motion.

## CSS layer: media-query gating

Write motion as the default, then override under the reduce query. Prefer a global safety net plus targeted overrides.

```css
/* Global safety net: neutralize runaway motion but DO NOT set 0s blindly,
   which can break JS that waits for transitionend/animationend. Use 0.01ms. */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;   /* stop infinite spins/marquees */
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;          /* kill smooth scroll */
  }
}
```

The global net is a backstop. Layer *intentional, tiered* overrides on top so Tier 3 motion survives:

```css
.card {
  transition: transform 400ms ease, opacity 400ms ease, background-color 200ms ease;
}

@media (prefers-reduced-motion: reduce) {
  .card {
    /* Tier 2: drop the transform (movement), keep opacity + color (Tier 3) */
    transition: opacity 150ms ease, background-color 150ms ease;
  }
  .parallax-layer { transform: none !important; }        /* Tier 1: remove */
  .hero-zoom      { animation: none !important; }        /* Tier 1: remove */
}
```

Use the *positive* query `(prefers-reduced-motion: no-preference)` to opt motion IN, which is the safest pattern for elaborate effects:

```css
.hero { opacity: 1; } /* visible, static by default */
@media (prefers-reduced-motion: no-preference) {
  .hero { animation: zoom-in 1.2s ease both; } /* only animate when allowed */
}
```

## JS layer: matchMedia gating

CSS cannot gate JS-driven animation (GSAP timelines, Framer's `animate`, canvas/WebGL, Lenis). Read the same signal via `matchMedia` and react to live changes (users toggle the OS setting without reloading).

```js
const REDUCE_QUERY = '(prefers-reduced-motion: reduce)';

export function prefersReducedMotion() {
  return typeof window !== 'undefined'
    && window.matchMedia
    && window.matchMedia(REDUCE_QUERY).matches;
}

// Live updates: listen so toggling the OS setting takes effect immediately.
export function onReducedMotionChange(cb) {
  const mq = window.matchMedia(REDUCE_QUERY);
  const handler = (e) => cb(e.matches);
  mq.addEventListener('change', handler);          // modern API
  return () => mq.removeEventListener('change', handler);
}
```

### Library gating, applied as one reusable layer

```js
// GSAP — use matchMedia(): GSAP reverts conditional setups on change.
import gsap from 'gsap';
const mm = gsap.matchMedia();
mm.add({
  motionOK: '(prefers-reduced-motion: no-preference)',
  motionReduce: '(prefers-reduced-motion: reduce)',
}, (ctx) => {
  const { motionOK } = ctx.conditions;
  if (motionOK) {
    gsap.from('.hero', { y: 80, opacity: 0, duration: 1 });   // Tier 1 move
  } else {
    gsap.from('.hero', { opacity: 0, duration: 0.15 });        // Tier 2 fade
  }
});

// Lenis smooth scroll — do not instantiate at all when reduced.
import Lenis from 'lenis';
let lenis = null;
if (!pre

…

More from iart-ai