Interactive
ParallaxStage
Layers at different depths slide apart under the mouse and as the stage scrolls by, so a flat scene opens up. Touch users get the scroll part.
Download
- React
- Tailwind CSS v4
- motion
components/ui/parallax-stage.tsx
"use client";
import { useRef, type ReactNode } from "react";
import { motion, useMotionValue, useReducedMotion, useScroll, useSpring, useTransform, type MotionValue } from "motion/react";
export type ParallaxStageLayer = {
id: string;
content: ReactNode;
/** 0 stays still, 1 moves the full strength. Below 0 moves with the pointer, above 0 against it. */
depth: number;
/** Size the layer past the stage edge so moving never shows a gap, for example "-inset-10". */
className?: string;
};
type ParallaxStageProps = {
/** Back layer first. */
layers: ParallaxStageLayer[];
/** Pixels a layer of depth 1 moves from the middle. */
strength?: number;
/** Layers also move as the stage passes through the screen. This is what touch users see. */
scroll?: boolean;
className?: string;
};
type LayerProps = {
layer: ParallaxStageLayer;
strength: number;
x: MotionValue<number>;
y: MotionValue<number>;
page: MotionValue<number>;
};
const FOLLOW = { stiffness: 90, damping: 18 };
function Layer({ layer, strength, x, y, page }: LayerProps) {
const shiftX = useTransform(x, (v) => -v * layer.depth * strength);
const shiftY = useTransform([y, page], ([py, ps]: number[]) => -(py + ps) * layer.depth * strength);
return (
<motion.div className={`absolute inset-0 ${layer.className ?? ""}`} style={{ x: shiftX, y: shiftY }}>
{layer.content}
</motion.div>
);
}
/* ParallaxStage: layers at different depths slide apart under the mouse and as the stage scrolls by, so a flat scene opens up. */
export function ParallaxStage({ layers, strength = 40, scroll = true, className = "" }: ParallaxStageProps) {
const reduce = useReducedMotion();
const box = useRef<HTMLDivElement>(null);
const x = useSpring(0, FOLLOW);
const y = useSpring(0, FOLLOW);
const still = useMotionValue(0);
const { scrollYProgress } = useScroll({ target: box, offset: ["start end", "end start"] });
const passing = useTransform(scrollYProgress, (v) => (scroll ? v * 2 - 1 : 0));
const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (reduce || e.pointerType === "touch") return;
const r = e.currentTarget.getBoundingClientRect();
x.set(((e.clientX - r.left) / r.width) * 2 - 1);
y.set(((e.clientY - r.top) / r.height) * 2 - 1);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<div ref={box} onPointerMove={onMove} onPointerLeave={onLeave} className={`relative overflow-hidden ${className}`}>
{layers.map((layer) => (
<Layer key={layer.id} layer={layer} strength={strength} x={x} y={y} page={reduce ? still : passing} />
))}
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/parallax-stage.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/parallax-stage-demo.tsx
import { ParallaxStage } from "@/components/ui/parallax-stage"; const HILL = "M0 120 C 120 40 220 150 360 90 S 620 30 800 110 V 300 H 0 Z"; const NEAR = "M0 170 C 160 90 300 210 460 140 S 700 100 800 170 V 300 H 0 Z"; const LAYERS = [ { id: "sky", depth: 0.1, className: "-inset-8", content: <div className="h-full bg-gradient-to-b from-[#0b0a14] via-[#241a4d] to-[#7c3aed]" /> }, { id: "moon", depth: 0.25, content: <div className="absolute top-[14%] right-[18%] size-16 rounded-full bg-[#fde68a] shadow-[0_0_60px_#fde68a]" /> }, { id: "title", depth: 0.4, content: ( <div className="grid h-full place-items-center"> <p className="text-3xl font-semibold tracking-tight text-white">Into the hills</p> </div> ), }, { id: "hill", depth: 0.7, className: "-inset-x-12 top-auto -bottom-10 h-1/2", content: ( <svg viewBox="0 0 800 300" preserveAspectRatio="none" className="h-full w-full" aria-hidden> <path d={HILL} fill="#3b2a78" /> </svg> ), }, { id: "near", depth: 1, className: "-inset-x-16 top-auto -bottom-12 h-2/5", content: ( <svg viewBox="0 0 800 300" preserveAspectRatio="none" className="h-full w-full" aria-hidden> <path d={NEAR} fill="#120e26" /> </svg> ), }, ]; export default function ParallaxStageExample() { return <ParallaxStage layers={LAYERS} strength={24} className="h-full bg-[#0b0a14]" />; }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| layers | ParallaxStageLayer[] | — | Back layer first: id, content, depth and className. Depth 0 stays still, 1 moves the full strength, below 0 moves with the pointer. |
| strength | number | 40 | Pixels a layer of depth 1 moves from the middle. |
| scroll | boolean | true | Layers also move as the stage passes through the screen. |
| className | string | "" | On the stage. Set its height here. |