Stagger

Orchestrate animations with staggered timing for smooth sequential motion.

Basic Animation
Variant Animation
View Animation

Code

"use client";

import {
  AnimatePresence,
  MotionConfig,
  type MotionProps,
  easeInOut,
  motion,
  useInView,
} from "motion/react";

import React, {
  Children,
  forwardRef,
  memo,
  useEffect,
  useRef,
  useState,
} from "react";

import { cn } from "@/lib/utils";

type MarginValue = `${number}${"px" | "%"}`;
type MarginType =
  | MarginValue
  | `${MarginValue} ${MarginValue}`
  | `${MarginValue} ${MarginValue} ${MarginValue}`
  | `${MarginValue} ${MarginValue} ${MarginValue} ${MarginValue}`;

interface StaggerVariants {
  container?: MotionProps["variants"];
  child?: MotionProps["variants"];
}

interface StaggerProps extends Omit<MotionProps, "variants"> {
  children: React.ReactNode;
  className?: string;
  staggerDelay?: number;
  duration?: number;
  staggerVariants?: StaggerVariants;
  trigger?: boolean;
  inView?: boolean;
  once?: boolean;
  margin?: MarginType;
}

const defaultContainerVariants = (delay = 0.1) => ({
  initial: {},
  animate: {
    transition: {
      staggerChildren: delay,
    },
  },
  exit: {
    transition: {
      staggerChildren: delay,
      staggerDirection: -1,
    },
  },
});

const defaultChildVariants = (duration = 0.6) => ({
  initial: { opacity: 0, y: 12, filter: "blur(4px)" },
  animate: {
    opacity: 1,
    y: 0,
    filter: "blur(0px)",
    transition: { ease: easeInOut, duration },
  },
  exit: {
    opacity: 0,
    y: -12,
    filter: "blur(4px)",
    transition: { ease: easeInOut, duration },
  },
});

const defaultSingleVariants = (duration = 0.6) => ({
  initial: { opacity: 0, filter: "blur(4px)" },
  animate: {
    opacity: 1,
    filter: "blur(0px)",
    transition: { ease: easeInOut, duration },
  },
  exit: {
    opacity: 0,
    filter: "blur(4px)",
    transition: { ease: easeInOut, duration },
  },
});

const Stagger = forwardRef<HTMLElement, StaggerProps>(
  (
    {
      children,
      className = "flex h-auto w-full grow flex-col",
      staggerDelay = 0.1,
      duration = 0.6,
      transition,
      staggerVariants,
      trigger,
      inView = false,
      once = true,
      margin = "0px",
      ...props
    },
    _ref,
  ) => {
    const localRef = useRef(null);
    const isInView = useInView(localRef, {
      once,
      margin: margin,
    });

    const [internalKey, setInternalKey] = useState(0);

    useEffect(() => {
      if (trigger !== undefined) {
        setInternalKey((prev) => prev + 1);
      }
    }, [trigger]);

    const childrenArray = Children.toArray(children);
    const hasMultiple = childrenArray.length > 1;

    const containerVariants =
      staggerVariants?.container ?? defaultContainerVariants(staggerDelay);
    const childVariants =
      staggerVariants?.child ?? defaultChildVariants(duration);
    const singleVariants = defaultSingleVariants(duration);

    const shouldUseAnimatePresence = trigger !== undefined;
    const animationState = inView
      ? isInView
        ? "animate"
        : "initial"
      : "animate";

    return (
      <MotionConfig transition={transition}>
        {shouldUseAnimatePresence ? (
          <AnimatePresence mode="wait">
            {(!inView || isInView) &&
              (hasMultiple ? (
                <motion.section
                  key={internalKey}
                  ref={localRef}
                  variants={containerVariants}
                  initial="initial"
                  animate="animate"
                  exit="exit"
                  className={cn(className)}
                  {...props}
                >
                  {childrenArray.map((child, index) => (
                    <motion.div
                      key={index}
                      custom={index}
                      variants={childVariants}
                    >
                      {child}
                    </motion.div>
                  ))}
                </motion.section>
              ) : (
                <motion.section
                  key={internalKey}
                  ref={localRef}
                  variants={singleVariants}
                  initial="initial"
                  animate="animate"
                  exit="exit"
                  className={cn(className)}
                  {...props}
                >
                  {children}
                </motion.section>
              ))}
          </AnimatePresence>
        ) : hasMultiple ? (
          <motion.section
            ref={localRef}
            variants={containerVariants}
            initial="initial"
            animate={animationState}
            className={cn(className)}
            {...props}
          >
            {childrenArray.map((child, index) => (
              <motion.div key={index} custom={index} variants={childVariants}>
                {child}
              </motion.div>
            ))}
          </motion.section>
        ) : (
          <motion.section
            ref={localRef}
            variants={singleVariants}
            initial="initial"
            animate={animationState}
            className={cn(className)}
            {...props}
          >
            {children}
          </motion.section>
        )}
      </MotionConfig>
    );
  },
);

Stagger.displayName = "Stagger";

export default memo(Stagger);