Depth Scroll Grid
A two-column image grid where every card performs the same scroll choreography mirrored per column: cards surge up from below tilted 70 degrees back, sheared toward their column and blown out to black, snap flat and clear at screen center, then tip away as they leave. Rotation, skew, translation, blur, brightness, contrast, and scaleY all map from one scroll progress; the perspective prop scales both depth and tilt.
GridReactMotionTailwind CSS
CSSTailwind
Manual
Create a file and paste the following code into it.
depth-scroll-grid.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
"use client";
import { type CSSProperties, type RefObject, useRef } from "react";
import { motion, useScroll, useTransform, type MotionValue } from "motion/react";
import { cn } from "@/lib/cn";
/* ------------------------------------------------------------------ */
/* DepthScrollGrid */
/* ------------------------------------------------------------------ */
export interface DepthImage {
src: string;
alt?: string;
}
interface DepthScrollGridProps {
images: DepthImage[];
/** Perspective depth in px — also scales how hard cards tilt [Optional, default: 1000] */
perspective?: number;
maxWidth?: string;
gap?: string;
/** Scrollable ancestor driving the animation [Optional, default: viewport] */
scrollContainerRef?: RefObject<HTMLElement | null>;
className?: string;
}
const DEFAULT_PERSPECTIVE = 1000;
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
function useFilterTransform(
blur: MotionValue<number>,
brightness: MotionValue<number>,
contrast: MotionValue<number>,
) {
return useTransform(
[blur, brightness, contrast],
([b, br, c]: number[]) => `blur(${b}px) brightness(${br}%) contrast(${c}%)`,
);
}
/**
* One card's scroll choreography: it rises from below tilted 70° back,
* sheared toward its column and blown out to black, resolves flat and clear
* at screen center, then exits tipping forward — every channel (rotate,
* skew, translate, blur, brightness, contrast, scaleY) mapped from the same
* scroll progress.
*/
function DepthCard({
src,
alt,
isLeft,
perspective,
dynamicsScale,
scrollContainerRef,
}: {
src: string;
alt: string;
isLeft: boolean;
perspective: number;
dynamicsScale: number;
scrollContainerRef?: RefObject<HTMLElement | null>;
}) {
const ref = useRef<HTMLElement | null>(null);
const { scrollYProgress } = useScroll({
target: ref,
container: scrollContainerRef,
offset: ["start end", "end start"],
});
const tiltScale = 0.6 + dynamicsScale * 0.4;
const pct = (value: number) => `${value * dynamicsScale}%`;
const rotateX = useTransform(scrollYProgress, [0, 0.5, 1], [70 * tiltScale, 0, -50 * tiltScale]);
const rotateZ = useTransform(
scrollYProgress,
[0, 0.5, 1],
isLeft ? [5 * tiltScale, 0, -1 * tiltScale] : [-5 * tiltScale, 0, 1 * tiltScale],
);
const x = useTransform(
scrollYProgress,
[0, 0.5, 0.7, 1],
isLeft ? [pct(-60), "0%", "0%", pct(-10)] : [pct(40), "0%", "0%", pct(10)],
);
const skewX = useTransform(
scrollYProgress,
[0, 0.5, 1],
isLeft ? [-5 * tiltScale, 0, 5 * tiltScale] : [5 * tiltScale, 0, -5 * tiltScale],
);
const y = useTransform(scrollYProgress, [0, 0.5, 1], [pct(40), "0%", pct(-10)]);
const blur = useTransform(scrollYProgress, [0, 0.5, 1], [7, 0, 4]);
const brightness = useTransform(scrollYProgress, [0, 0.5, 1], [0, 100, 0]);
const contrast = useTransform(scrollYProgress, [0, 0.5, 1], [180, 110, 180]);
const scaleY = useTransform(scrollYProgress, [0, 0.5, 1], [1.8, 1, 1.1]);
const filter = useFilterTransform(blur, brightness, contrast);
return (
<motion.figure
ref={ref}
className="relative z-10 m-0"
style={{ perspective: `${perspective}px`, willChange: "transform" } as CSSProperties}
>
<motion.div
className="relative aspect-[1/1.2] w-full overflow-hidden rounded-sm"
style={{ y, x, rotateX, rotateZ, skewX, filter, scaleY }}
>
<motion.div
className="absolute inset-0 h-full w-full bg-cover bg-center"
style={{ backgroundImage: `url(${src})` }}
role="img"
aria-label={alt}
/>
</motion.div>
</motion.figure>
);
}
/**
* A two-column image grid where every card performs the same scroll
* choreography mirrored per column — cards surge up from a dark blur,
* snap into focus mid-viewport, and tip away as they leave. Perspective
* controls both the 3D depth and how hard everything tilts.
* @param {DepthImage[]} images - Grid images [Required]
* @param {RefObject} scrollContainerRef - Scroll ancestor [Optional, default: viewport]
*/
export function DepthScrollGrid({
images,
perspective = DEFAULT_PERSPECTIVE,
maxWidth = "760px",
gap = "1.5rem",
scrollContainerRef,
className,
}: DepthScrollGridProps) {
const dynamicsScale = clamp(perspective / DEFAULT_PERSPECTIVE, 0.35, 1.2);
return (
<div className={cn("relative w-full", className)}>
<section
className="mx-auto grid grid-cols-2"
style={{ maxWidth, gap }}
aria-label="Scrolling image gallery"
>
{images.map((img, i) => (
<DepthCard
key={img.src}
src={img.src}
alt={img.alt ?? ""}
isLeft={i % 2 === 0}
perspective={perspective}
dynamicsScale={dynamicsScale}
scrollContainerRef={scrollContainerRef}
/>
))}
</section>
</div>
);
}
Update the import paths to match your project setup.
Similar components
Install via CLI
Resource details
PublishedJuly 17, 2026
CategoryGrid
ReactMotionTailwind CSS
Install via CLI
Resource details
PublishedJuly 17, 2026
CategoryGrid
ReactMotionTailwind CSS