Parallax Layers

Multi-depth parallax scroll effect — foreground, midground, and background layers move at different speeds for a 2.5D feel

parallaxscrolllayersdepthframer-motion
Install dependencies
$npm install framer-motion
Preview

Source Code

"use client";

import {
  createContext,
  useContext,
  useRef,
  type ReactNode,
  type RefObject,
} from "react";
import {
  motion,
  useScroll,
  useTransform,
  type MotionValue,
} from "framer-motion";

interface ParallaxContextValue {
  scrollYProgress: MotionValue<number>;
}

const ParallaxContext = createContext<ParallaxContextValue | null>(null);

interface ParallaxLayersProps {
  height?: string;
  className?: string;
  /** Optional scrollable ancestor to track scroll progress against. Defaults to window. */
  container?: RefObject<HTMLElement | null>;
  children: ReactNode;
}

export function ParallaxLayers({
  height = "100vh",
  className = "",
  container,
  children,
}: ParallaxLayersProps) {
  const ref = useRef<HTMLDivElement>(null);
  const { scrollYProgress } = useScroll({
    target: ref,
    container,
    offset: ["start end", "end start"],
  });

  return (
    <ParallaxContext.Provider value={{ scrollYProgress }}>
      <div
        ref={ref}
        style={{ height }}
        className={`relative overflow-hidden ${className}`}
      >
        {children}
      </div>
    </ParallaxContext.Provider>
  );
}

interface ParallaxLayerProps {
  speed?: number;
  className?: string;
  children: ReactNode;
}

export function ParallaxLayer({
  speed = 0.5,
  className = "",
  children,
}: ParallaxLayerProps) {
  const ctx = useContext(ParallaxContext);
  if (!ctx) {
    throw new Error("ParallaxLayer must be used inside <ParallaxLayers>");
  }

  const y = useTransform(ctx.scrollYProgress, [0, 1], [speed * 300, -speed * 300]);

  return (
    <motion.div
      style={{ y }}
      className={`absolute inset-0 will-change-transform ${className}`}
    >
      {children}
    </motion.div>
  );
}

export default ParallaxLayers;