Overlapping images that drift at different speeds and tidy into a stack as you scroll, for editorial storytelling.
Customize
Usage
import { ParallaxStack } from "@/components/motionkit/parallax-stack";
<ParallaxStack
images={[
{ src: "/work/one.jpg", alt: "Brand identity for Northwind" },
{ src: "/work/two.jpg", alt: "Packaging for Lumen" },
{ src: "/work/three.jpg", alt: "Website for Arcadia" },
]}
/>Motion
The exact curves this component runs, plotted with Motion's own maths.
Drift and settle
Scroll position
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/parallax-stack.tsx.
"use client";
import { useRef } from "react";
import { motion, useReducedMotion, useScroll, useTransform, type MotionValue } from "motion/react";
export type StackImage = { src: string; alt: string };
type ParallaxStackProps = {
/** Up to five images. Extra images are ignored. */
images: StackImage[];
/** How far the images drift, in pixels. Deeper images drift more. */
strength?: number;
className?: string;
};
// where each image starts (scattered) and settles (tidied into an overlapping stack)
const slots = [
{ left: 4, top: 14, width: 42, depth: 0.4, rotate: -7, settle: { x: 14, rotate: -3 } },
{ left: 54, top: 4, width: 38, depth: 1, rotate: 6, settle: { x: -12, rotate: 3 } },
{ left: 30, top: 34, width: 40, depth: 1.6, rotate: -2, settle: { x: 0, rotate: 0 } },
{ left: 8, top: 60, width: 30, depth: 0.8, rotate: 9, settle: { x: 18, rotate: 4 } },
{ left: 64, top: 58, width: 30, depth: 1.3, rotate: -10, settle: { x: -20, rotate: -4 } },
];
export function ParallaxStack({ images, strength = 120, className = "" }: ParallaxStackProps) {
const ref = useRef<HTMLDivElement>(null);
// 0 as the stack enters from the bottom of the screen, 1 as it leaves at the top
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] });
const reduceMotion = useReducedMotion();
return (
<div ref={ref} className={`relative aspect-[4/3] w-full ${className}`}>
{images.slice(0, slots.length).map((image, index) => (
<StackItem
key={image.src}
image={image}
slot={slots[index]}
progress={scrollYProgress}
strength={reduceMotion ? 0 : strength}
still={Boolean(reduceMotion)}
/>
))}
</div>
);
}
function StackItem({
image,
slot,
progress,
strength,
still,
}: {
image: StackImage;
slot: (typeof slots)[number];
progress: MotionValue<number>;
strength: number;
still: boolean;
}) {
// deeper images travel further, which is what reads as depth
const y = useTransform(progress, [0, 1], [strength * slot.depth, -strength * slot.depth]);
// images drift into a tidier stack as the section reaches the middle of the screen, then loosen again
const x = useTransform(progress, [0, 0.5, 1], [0, slot.settle.x, slot.settle.x * 0.4]);
const rotate = useTransform(progress, [0, 0.5, 1], [slot.rotate, slot.settle.rotate, slot.rotate * 0.6]);
const xPercent = useTransform(x, (v) => `${v}%`);
return (
<motion.figure
className="absolute overflow-hidden rounded-xl border border-white/10 shadow-2xl shadow-black/50"
style={{
left: `${slot.left}%`,
top: `${slot.top}%`,
width: `${slot.width}%`,
zIndex: Math.round(slot.depth * 10),
...(still ? { rotate: slot.settle.rotate } : { y, x: xPercent, rotate }),
}}
>
{/* eslint-disable-next-line @next/next/no-img-element -- framework-agnostic: works outside Next.js too */}
<img src={image.src} alt={image.alt} className="block aspect-[4/3] w-full object-cover" />
</motion.figure>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| images | { src, alt }[] | — | Up to five images. Extras are ignored. |
| strength | number | 120 | How far the images drift, in pixels. Deeper images drift more. |
Marquee
A seamless infinite scroller for logos and testimonials that pauses on hover.
Acme Supplies
Workshop tools, delivered next day across the UK.
Shop the rangeCompare Slider
Drag a handle across two layers to compare before and after, smoothly sprung.
Accordion
FAQ panels that open with a smooth, springy height and a turning chevron.