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
useReducedMotionfrommotion/reactin 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
initialprop tofalsewhen reduced motion is preferred. - Reference the canonical skeletons in
skills/taste-skill/SKILL.mdfor 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →