How to Implement Reduced Motion Accessibility with useReducedMotion() in Taste-Skill

Call useReducedMotion() from motion/react at the top of animated components to detect user preferences; when it returns true, skip GSAP initialization or set Motion's initial prop to false, ensuring all animations with MOTION_INTENSITY greater than 3 comply with the project's mandatory accessibility guardrails.

The taste-skill repository enforces strict accessibility standards requiring reduced-motion support for high-intensity animations. By leveraging the useReducedMotion() hook from the Motion library, developers can detect system-level motion preferences and conditionally disable animations that exceed intensity thresholds. This pattern ensures WCAG 2.1 AAA compliance while maintaining performance for users who prefer static interfaces.

Understanding the Accessibility Guardrails

The project mandates that any animation with MOTION_INTENSITY greater than 3 must respect the user's prefers-reduced-motion setting. According to the canonical skeletons defined in skills/taste-skill/SKILL.md, this is implemented by importing useReducedMotion from motion/react and using it as a circuit breaker before initializing animation libraries like GSAP or Motion.

The CHANGELOG.md at line 74 records this decision, confirming the policy is enforced across the codebase.

Guarding GSAP Animations

For GSAP-based components like Sticky-Stack and Horizontal-Pan, the hook prevents expensive timeline initialization when users prefer reduced motion.

Sticky-Stack Implementation

The Sticky-Stack component implements a pin-and-scale carousel that must skip all ScrollTrigger logic when motion is reduced.

"use client";
import { useRef, useEffect } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useReducedMotion } from "motion/react";

gsap.registerPlugin(ScrollTrigger);

export function StickyStack({ cards }: { cards: React.ReactNode[] }) {
  const ref = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();               // ← detect preference

  useEffect(() => {
    if (reduce || !ref.current) return;            // ← bail out early
    const ctx = gsap.context(() => {
      const cardEls = gsap.utils.toArray<HTMLElement>(".stack-card");
      cardEls.forEach((card, i) => {
        if (i === cardEls.length - 1) return;
        ScrollTrigger.create({
          trigger: card,
          start: "top top",
          endTrigger: cardEls.at(-1),
          end: "top top",
          pin: true,
          pinSpacing: false,
        });
        gsap.to(card, {
          scale: 0.92,
          opacity: 0.55,
          ease: "none",
          scrollTrigger: {
            trigger: cardEls[i + 1],
            start: "top bottom",
            end: "top top",
            scrub: true,
          },
        });
      });
    }, ref);
    return () => ctx.revert();
  }, [reduce]);                                    // re‑run if system setting changes

  return (
    <div ref={ref} className="relative">
      {cards.map((card, i) => (
        <div
          key={i}
          className="stack-card sticky top-0 min-h-[100dvh] flex items-center justify-center"
        >
          {card}
        </div>
      ))}
    </div>
  );
}

Horizontal-Pan Implementation

Similarly, the Horizontal-Pan component uses the hook to disable vertical-scroll-driven horizontal travel.

"use client";
import { useRef, useEffect } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useReducedMotion } from "motion/react";

gsap.registerPlugin(ScrollTrigger);

export function HorizontalPan({ children }: { children: React.ReactNode }) {
  const wrap = useRef<HTMLDivElement>(null);
  const track = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();

  useEffect(() => {
    if (reduce || !wrap.current || !track.current) return;
    const ctx = gsap.context(() => {
      const distance = track.current!.scrollWidth - window.innerWidth;
      gsap.to(track.current, {
        x: -distance,
        ease: "none",
        scrollTrigger: {
          trigger: wrap.current,
          start: "top top",
          end: () => `+=${distance}`,
          pin: true,
          scrub: 1,
          invalidateOnRefresh: true,
        },
      });
    }, wrap);
    return () => ctx.revert();
  }, [reduce]);

  return (
    <section ref={wrap} className="relative overflow-hidden">
      <div ref={track} className="flex h-[100dvh] items-center">
        {children}
      </div>
    </section>
  );
}

Handling Motion Component Animations

For components using the Motion library directly, set the initial prop to false when useReducedMotion() returns true.

Reveal-Stagger with Static Fallback

The Reveal-Stagger component demonstrates how to render content instantly when motion is reduced.

"use client";
import { motion, useReducedMotion } from "motion/react";

export function RevealStagger({ items }: { items: string[] }) {
  const reduce = useReducedMotion();

  return (
    <ul className="grid gap-6">
      {items.map((item, i) => (
        <motion.li
          key={item}
          initial={reduce ? false : { opacity: 0, y: 24 }}   // ← static when reduced
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, amount: 0.3 }}
          transition={{
            duration: 0.6,
            delay: i * 0.06,
            ease: [0.16, 1, 0.3, 1],
          }}
        >
          {item}
        </motion.li>
      ))}
    </ul>
  );
}

Complementing with CSS Media Queries

While useReducedMotion() handles JavaScript animations, pair it with CSS for pure-CSS transitions.

/* Enable animations only when the user allows motion */
@media (prefers-reduced-motion: no-preference) {
  .animate-fade-in {
    animation: fadeIn 0.6s ease-out forwards;
  }
}

/* Explicitly disable any residual animation */
@media (prefers-reduced-motion: reduce) {
  .animate-fade-in {
    animation: none;
  }
}

Summary

  • Import useReducedMotion from motion/react in any component with MOTION_INTENSITY > 3.
  • Call the hook at component top: const reduce = useReducedMotion().
  • Short-circuit GSAP initialization with if (reduce) return; to prevent ScrollTrigger and timeline setup.
  • Set Motion's initial prop to false when reduced motion is preferred.
  • Reference the canonical skeletons in skills/taste-skill/SKILL.md for implementation patterns.
  • Add CSS media queries as a fallback for non-JavaScript animations.

Frequently Asked Questions

How does useReducedMotion() detect user preferences?

The hook queries the prefers-reduced-motion media feature from the user's operating system or browser settings. It returns true when the user has selected reduced motion in accessibility settings, allowing components to respond immediately without waiting for CSS media queries to apply.

Where is the reduced-motion guardrail defined in taste-skill?

The mandatory rule requiring reduced-motion support for animations with intensity greater than 3 is documented in skills/taste-skill/SKILL.md under the reduced-motion section. The specification also appears in CHANGELOG.md at line 74, confirming this policy is enforced across all skill implementations.

Can I use useReducedMotion() with animation libraries other than GSAP?

Yes. While the taste-skill examples demonstrate GSAP and Motion integration, the hook works with any animation library. Simply check the reduce boolean before initializing animations in Framer Motion, React Spring, or custom requestAnimationFrame loops.

What happens if I forget to implement the reduced-motion check?

Animations with MOTION_INTENSITY > 3 that lack useReducedMotion() guards violate the project's accessibility standards and WCAG 2.1 AAA guidelines. The component will animate regardless of user preferences, potentially causing accessibility issues for users with vestibular disorders or motion sensitivity.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →