Guide

GSAP and prefers-reduced-motion: patterns that pass

The canonical way to make GSAP honor prefers-reduced-motion is gsap.matchMedia() with a "(prefers-reduced-motion: reduce)" condition. A CSS media query cannot reach a GSAP tween: the animation runs from JavaScript, so JavaScript has to branch on the preference. This guide shows the patterns that pass — the matchMedia setup with its cleanup semantics, a ScrollTrigger variant that keeps information and drops movement, and the CSS fallback rule that keeps content visible when motion is off. If the media feature itself is new to you, start with prefers-reduced-motion, explained; this post builds on its vocabulary.

Why doesn't the CSS media query cover GSAP?

A @media (prefers-reduced-motion) block governs CSS only; a GSAP tween runs from JavaScript and will animate straight through the user's preference unless the code checks it. That check matters for the same reason the media feature exists: for people with vestibular disorders, large movement can trigger genuine dizziness and nausea, and prefers-reduced-motion is the practical mechanism behind WCAG 2.3.3 Animation from Interactions (Level AAA), supporting WCAG 2.2.2 Pause, Stop, Hide (Level A). Unguarded motion is also the norm rather than the exception in generated code: in our static scan of 196 AI-generated production apps, 96.9% shipped motion with no reduced-motion guard — a WCAG 2.3.3 best-practice gap, documented in the State of Motion study. GSAP gives you a first-class way to close that gap.

What is the canonical gsap.matchMedia() pattern?

gsap.matchMedia() (GSAP 3.11+) runs animation setup only while a media query matches, and automatically reverts everything created inside the handler when it stops matching. That auto-revert is the reason it beats a hand-rolled window.matchMedia check: if someone flips the OS setting while the page is open — often because the page made them queasy — GSAP tears the old animations down and runs the matching branch, with no listener code on your side.

const mm = gsap.matchMedia();

mm.add(
  {
    // Named conditions — GSAP watches both media queries for you.
    reduceMotion: "(prefers-reduced-motion: reduce)",
    okMotion: "(prefers-reduced-motion: no-preference)"
  },
  (context) => {
    const { reduceMotion } = context.conditions;

    if (reduceMotion) {
      // Calm branch: settle elements into their end state. No movement.
      gsap.set(".card", { opacity: 1, y: 0 });
    } else {
      // Full branch: the entrance animation.
      gsap.from(".card", { opacity: 0, y: 24, duration: 0.6, ease: "power2.out" });
    }

    // Optional custom teardown. Every tween created in this handler is
    // reverted automatically when the matching condition changes.
    return () => {};
  }
);

// SPA / component teardown: revert everything this instance created.
// mm.revert();

Two details carry the weight here. First, the reduce branch uses gsap.set() to put elements into their finished state — the user gets the same content, minus the movement. Second, cleanup is layered: the function you return handles anything GSAP can't revert on its own, and mm.revert() is the single call for component unmount in a SPA. Don't nest gsap.context() inside a matchMedia handler; matchMedia creates a context internally.

How do you keep ScrollTrigger accessible under reduced motion?

Under reduced motion, a ScrollTrigger effect should ship a reduced variant — keep the reveal where it carries information, drop the movement — rather than being deleted outright. A scroll-triggered reveal often tells the reader "new content starts here"; removing it entirely removes that signal. What must go is the motion itself: the slide, the parallax, the zoom.

gsap.registerPlugin(ScrollTrigger);
const mm = gsap.matchMedia();

// Full variant: slide + fade as the section scrolls in.
mm.add("(prefers-reduced-motion: no-preference)", () => {
  gsap.from(".section", {
    y: 40, opacity: 0, duration: 0.6, ease: "power2.out",
    scrollTrigger: { trigger: ".section", start: "top 80%" }
  });
});

// Reduced variant: the reveal still signals "new content here",
// but nothing moves — opacity only, shorter duration.
mm.add("(prefers-reduced-motion: reduce)", () => {
  gsap.from(".section", {
    opacity: 0, duration: 0.3, ease: "none",
    scrollTrigger: { trigger: ".section", start: "top 80%" }
  });
});

Where the scroll-linked effect is pure decoration — a parallax background, a scrubbed hero — the reduce branch simply omits it: no mm.add handler, and the content sits in its final position. Both branches are reverted and re-run automatically when the preference changes, because ScrollTriggers created inside a matchMedia handler are covered by the same cleanup semantics as tweens.

What is the CSS fallback rule?

The CSS fallback rule is: motion off, content visible — no element may depend on JavaScript animation to become visible, and content must never be hidden with display: none as the reduced state. In CSS, ship the finished layout as the default and treat motion as a layer on top:

/* Content is visible by default; motion is layered on top by JS. */
.card { opacity: 1; }

/* Safety net for any CSS motion that ships alongside the GSAP code. */
@media (prefers-reduced-motion: reduce) {
  .card { animation: none; transition: none; }
}

This pairs cleanly with gsap.from(): the tween applies its start state at runtime, so the no-JS default stays visible. What must never ship is CSS that parks content at opacity: 0 waiting for a script to reveal it — if the script fails or is blocked, the content is gone for everyone, reduced motion or not.

What does "reduce" actually mean?

"Reduce" means less motion, not a frozen page — and it never means less information. Swap large movement — parallax, zooms, sweeping transitions — for a gentle opacity fade, and let genuinely essential motion, such as a real progress indicator, keep running; decoration does not get that pass. This is the same vocabulary as our prefers-reduced-motion guide, which also covers the four ways to test the preference on a real page. For the full verification process across a page's motion — criteria, patterns, and checks in order — see how to verify web motion accessibility.

Scope note: MotionSpec's static scan does not read GSAP.

The free motion check reads a page's linked CSS files and inline style blocks — nothing else. Animation driven by JavaScript — including GSAP, the Web Animations API and scroll libraries — is not covered, so on a GSAP-heavy site a clean result means "clean on the CSS we could read", not "GSAP verified". For GSAP motion, the patterns in this guide are the verification path; for motion you generate, the MotionSpec compiler ships the reduced-motion branch by construction — see the MCP docs.

FAQ

Does the CSS prefers-reduced-motion media query cover GSAP?

No. A @media (prefers-reduced-motion) block only governs CSS. GSAP animates from JavaScript, so it has to check the preference itself — gsap.matchMedia() is the built-in way to do that.

What is the canonical GSAP pattern for prefers-reduced-motion?

gsap.matchMedia() with a '(prefers-reduced-motion: reduce)' condition. Everything created inside a matching handler is reverted automatically when the condition stops matching, so the page reacts if someone flips the setting without reloading.

Should ScrollTrigger animations be removed entirely under reduced motion?

Not always. Where a scroll-triggered reveal carries information, keep it and drop the movement — an opacity-only variant with a short duration. Purely decorative effects such as parallax can simply be omitted; the content stays visible in place.

Can the free motion check detect unguarded GSAP animations?

No. The scan reads static CSS only, so animation driven by JavaScript — including GSAP, the Web Animations API and scroll libraries — is not covered. A clean result means clean on the CSS it could read. For GSAP, use the patterns in this guide; for generated motion, the MotionSpec compiler ships the reduced-motion branch by construction.

Check the CSS half of your motion in ten seconds.

The free motion check reads static CSS only — GSAP stays outside its scope — but every CSS animation and transition on the page is scanned against WCAG 2.2.2 and 2.3.3, with a fix for each finding. No signup, nothing stored.

Run the free motion check

Generating web UI at scale? See the Design Partner Program.

MotionSpec verifies and compiles UI animation for AI-generated web apps — it does not generate AI video.