Skip to content

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