Accessible Animation
Framer Motion primitives that respect prefers-reduced-motion with staggered list entrances.
What Problem This Solves
You want polished animations but need to respect users who have reduced motion enabled in their OS settings.
Prerequisites
framer-motion
Files
use-reduced-motion.ts
ts
import { useEffect, useState } from "react";
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
export function useReducedMotion(): boolean {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(REDUCED_MOTION_QUERY).matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia(REDUCED_MOTION_QUERY);
const handleChange = (event: MediaQueryListEvent) => {
setPrefersReducedMotion(event.matches);
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
return prefersReducedMotion;
}stagger-children.tsx
tsx
import { type MotionProps, motion, type Variants } from "framer-motion";
import type { ComponentPropsWithRef, ElementType, ReactNode } from "react";
import { useReducedMotion } from "./use-reduced-motion";
type StaggerChildrenProps<T extends ElementType = "div"> = {
as?: T;
children: ReactNode;
delay?: number;
duration?: number;
staggerDelay?: number;
className?: string;
} & Omit<ComponentPropsWithRef<T>, "as" | "children" | "className">;
const motionComponents = {
div: motion.div,
section: motion.section,
article: motion.article,
ul: motion.ul,
ol: motion.ol,
} as const;
type MotionComponentKey = keyof typeof motionComponents;
export function StaggerChildren<T extends ElementType = "div">({
as,
children,
delay = 0,
duration = 0.28,
staggerDelay = 0.08,
className,
...props
}: StaggerChildrenProps<T>) {
const reducedMotion = useReducedMotion();
const containerVariants = {
hidden: reducedMotion ? { opacity: 1 } : { opacity: 0 },
visible: {
opacity: 1,
transition: reducedMotion
? { duration: 0 }
: { delayChildren: delay, staggerChildren: staggerDelay },
},
};
const childVariants: Variants = {
hidden: reducedMotion ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: reducedMotion ? { duration: 0 } : { duration, ease: [0.4, 0, 0.2, 1] },
},
};
const tag = (as as MotionComponentKey) || "div";
const Component = motionComponents[tag] || motion.div;
return (
<Component
animate="visible"
className={className}
initial={reducedMotion ? "visible" : "hidden"}
variants={containerVariants}
{...(props as MotionProps)}
>
{Array.isArray(children)
? children.map((child, index) => (
<motion.div key={index} variants={childVariants}>
{child}
</motion.div>
))
: children}
</Component>
);
}motion-button.tsx
tsx
import { motion } from "framer-motion";
import type { ComponentProps, PropsWithChildren } from "react";
import { useReducedMotion } from "./use-reduced-motion";
type MotionButtonProps = PropsWithChildren<
ComponentProps<"button"> & {
tapScale?: number;
hoverScale?: number;
}
>;
export function MotionButton({
children,
className,
tapScale = 0.95,
hoverScale = 1.02,
...buttonProps
}: MotionButtonProps) {
const prefersReducedMotion = useReducedMotion();
return (
<motion.div
className={className}
transition={{ duration: 0.1, ease: [0.4, 0, 0.2, 1] }}
whileHover={prefersReducedMotion ? undefined : { scale: hoverScale }}
whileTap={prefersReducedMotion ? undefined : { scale: tapScale }}
>
<button {...buttonProps}>{children}</button>
</motion.div>
);
}Usage
Staggered list entrance
tsx
import { StaggerChildren } from "./stagger-children";
export function UserList({ users }: { users: User[] }) {
return (
<StaggerChildren as="ul" staggerDelay={0.05}>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</StaggerChildren>
);
}Tactile button
tsx
import { MotionButton } from "./motion-button";
export function SubmitButton() {
return (
<MotionButton tapScale={0.9} hoverScale={1.05}>
Submit
</MotionButton>
);
}Check reduced motion preference
tsx
import { useReducedMotion } from "./use-reduced-motion";
export function AnimatedIcon() {
const reducedMotion = useReducedMotion();
return (
<motion.div
animate={reducedMotion ? {} : { rotate: 360 }}
transition={{ duration: 2, repeat: Infinity }}
>
<Spinner />
</motion.div>
);
}Key Principle
Never gate content behind animation. If prefers-reduced-motion: reduce is set:
- Content should still appear (no infinite
opacity: 0) - State changes should be instant (no transition delays)
- Decorative animations should be disabled
- Essential animations (like loading spinners) should be simplified