GodUIGodUI
129Follow on X

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.

01/03Two rotations fake a 3D wall
Viewport
overflow clip · [perspective:1200px]
Tile grid
rotateX(55deg) rotateZ(-45deg)
tsx
<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>
01

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.

02/03Per-column drift, never in sync
Column loop
y [0,∓40,0], ease [0.65,0,0.35,1]
Alternating direction
colIndex % 2 === 0 ? up-first : down-first
tsx
<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,}}>
02

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.

tsx
 
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;
}
03/03Result
03

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

tsx
 
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

3D MarqueeSS: Compositor-only
SscaleScale spring / press
SrotateRotation
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →