Anatomy of the 3D Marquee
A wall of flat images that reads as a tilted 3D plane from two CSS transforms, with per-column drift durations tuned so the grid never repeats in visible sync.
↓Scroll to step through it
Two rotations fake a 3D wall
ThreeDMarquee looks like it needs a 3D scene graph: a tilted grid of
images drifting at different speeds with real depth. It is two CSS
transforms on a flat grid of <img> tags, plus a duration offset per column
that keeps the drift from looking mechanical.
perspective: 1200px on the outer container gives the child's
rotation its depth. Without it, rotateX/rotateZ would
flatten to a 2D skew. rotateX(55deg) tips the grid back into the isometric
angle, rotateZ(-45deg) spins it into the diamond orientation, and
preserve-3d keeps the grid's own children (the columns) sharing that same
3D space instead of each being flattened into the parent's plane individually.
The w-[140%] scale-110 wrapper is overscan: a tilted rectangle's
corners project outside its own bounding box, so the grid is rendered oversize
and re-scaled down to guarantee it still covers every corner of the visible
container after rotation.
Per-column drift, never in sync
Every column loops the same shape (up-and-back or down-and-back), but
duration: 10 + colIndex gives column 0 a 10s cycle, column 1 an 11s cycle,
column 2 a 12s cycle, and so on. Four columns with four different periods
means their relative phase keeps sliding rather than repeating, so the wall
never visibly "resets" all at once the way it would if every column shared
one duration. ease: [0.65, 0, 0.35, 1] is a symmetric in-out curve, with the
same shape decelerating into the turnaround as accelerating out of it. That
keeps a repeat: Infinity bob from having a visible seam at the loop
point.
images is a flat array, and the component doesn't inspect its contents.
toColumns chunks it into columns sequential groups of
Math.ceil(length / columns) each, and the Math.min clamp
absorbs the remainder into the last column instead of ever producing a
columns + 1'th bucket when the count doesn't divide evenly.
function toColumns<T>(items: T[], count: number): T[][] {
const columns: T[][] = Array.from({ length: count }, () => []);
const perColumn = Math.ceil(items.length / count);
items.forEach((item, i) => {
columns[Math.min(count - 1, Math.floor(i / perColumn))].push(item);
});
return columns;
}The result
Two transforms and one overscan wrapper turn a flat grid into a tilted plane, and a duration offset per column keeps the drift from visibly repeating.
- Viewport
- overflow clip · [perspective:1200px]
- Tile grid
- rotateX(55deg) rotateZ(-45deg)
<div className="relative ... [perspective:1200px]"><div className="w-[140%] scale-110"> <div className="grid size-full origin-center gap-4 [transform:rotateX(55deg)_rotateZ(-45deg)]" style={{ transformStyle: "preserve-3d" }} > {/* columns of <img> */} </div></div></div>Anatomy of the 3D Marquee
A wall of flat images that reads as a tilted 3D plane from two CSS transforms, with per-column drift durations tuned so the grid never repeats in visible sync.
- Viewport
- overflow clip · [perspective:1200px]
- Tile grid
- rotateX(55deg) rotateZ(-45deg)
<div className="relative ... [perspective:1200px]"><div className="w-[140%] scale-110"> <div className="grid size-full origin-center gap-4 [transform:rotateX(55deg)_rotateZ(-45deg)]" style={{ transformStyle: "preserve-3d" }} > {/* columns of <img> */} </div></div></div>Two rotations fake a 3D wall
ThreeDMarquee looks like it needs a 3D scene graph: a tilted grid of
images drifting at different speeds with real depth. It is two CSS
transforms on a flat grid of <img> tags, plus a duration offset per column
that keeps the drift from looking mechanical.
perspective: 1200px on the outer container gives the child's
rotation its depth. Without it, rotateX/rotateZ would
flatten to a 2D skew. rotateX(55deg) tips the grid back into the isometric
angle, rotateZ(-45deg) spins it into the diamond orientation, and
preserve-3d keeps the grid's own children (the columns) sharing that same
3D space instead of each being flattened into the parent's plane individually.
The w-[140%] scale-110 wrapper is overscan: a tilted rectangle's
corners project outside its own bounding box, so the grid is rendered oversize
and re-scaled down to guarantee it still covers every corner of the visible
container after rotation.
- Column loop
- y [0,∓40,0], ease [0.65,0,0.35,1]
- Alternating direction
- colIndex % 2 === 0 ? up-first : down-first
<motion.divanimate={{ y: colIndex % 2 === 0 ? [0, -40, 0] : [0, 40, 0] }}transition={{ duration: 10 + colIndex, ease: [0.65, 0, 0.35, 1], repeat: Number.POSITIVE_INFINITY,}}>Per-column drift, never in sync
Every column loops the same shape (up-and-back or down-and-back), but
duration: 10 + colIndex gives column 0 a 10s cycle, column 1 an 11s cycle,
column 2 a 12s cycle, and so on. Four columns with four different periods
means their relative phase keeps sliding rather than repeating, so the wall
never visibly "resets" all at once the way it would if every column shared
one duration. ease: [0.65, 0, 0.35, 1] is a symmetric in-out curve, with the
same shape decelerating into the turnaround as accelerating out of it. That
keeps a repeat: Infinity bob from having a visible seam at the loop
point.
images is a flat array, and the component doesn't inspect its contents.
toColumns chunks it into columns sequential groups of
Math.ceil(length / columns) each, and the Math.min clamp
absorbs the remainder into the last column instead of ever producing a
columns + 1'th bucket when the count doesn't divide evenly.
function toColumns<T>(items: T[], count: number): T[][] {
const columns: T[][] = Array.from({ length: count }, () => []);
const perColumn = Math.ceil(items.length / count);
items.forEach((item, i) => {
columns[Math.min(count - 1, Math.floor(i / perColumn))].push(item);
});
return columns;
}The result
Two transforms and one overscan wrapper turn a flat grid into a tilted plane, and a duration offset per column keeps the drift from visibly repeating.
Accessibility
animate={reduce ? undefined : { y: ... }}
<img alt="" draggable={false} />Under prefers-reduced-motion, animate is undefined outright. Framer
never registers the loop, so the grid holds still instead of
playing a zero-duration version of the drift. Every tile's alt="" marks the
grid as decorative (it's a showcase background, not content to be read
aloud), and draggable={false} stops the browser's native image-drag
affordance from fighting the tilted layout.
Motion Score
scaleScale spring / pressrotateRotation