Build games
with no code

Hero background · attraction build

One tilted plane, not twenty tilted cards

Every card lies flat on a single plane that is itself rotated in 3D. They all share one orientation, so the perspective distortion is consistent — parallel edges converge on the same vanishing point, and the skew grows the further a card sits from the centre. Tilting each card independently gives you roughly the same angles and reads as confetti; the shared plane is what makes it read as a space.

A card's position is stored as a point (u, v) on that plane, then rotated into world space before it is drawn. The headline is not on the plane, so it stays square to the screen while everything around it turns. The plane also sits slightly back from z = 0 — without that, the tilt swings one whole side of it toward the camera and it crops the headline.

Reach

Each card measures its own distance to the cursor and gets an influence value that smoothly falls off to zero at the edge of the radius. Nothing outside the radius moves at all.

Pull

Inside that radius the card slides along the vector toward the cursor — move up, they rise toward you. It also gains translateZ, so it lifts off the plane and grows as it comes forward.

Settle

Influence is computed from each card's resting position, never its pulled one. That keeps the force a pure function of where the cursor is, so cards can't chase themselves into a feedback wobble.

// a point on the plane, rotated into world space
const wx =  u * cosY + v * sinX * sinY;
const wy =  v * cosX;
const wz = -u * sinY + v * sinX * cosY + floatOff + planeZ;

card.style.transform =
  `translate3d(${wx}px, ${wy}px, ${wz}px)
   rotateY(${ay}rad) rotateX(${ax}rad)`;   // every card, same angles

// how close is the cursor, 1 at the centre → 0 at the edge of reach
const dx = cursorX - restX, dy = cursorY - restY;
let f = Math.max(0, 1 - Math.hypot(dx, dy) / reach);
f = f * f * (3 - 2 * f);                    // smoothstep, no hard rim

// slide toward the cursor and lift toward the camera
const pullX = dx * f * 0.3 * strength;
const pullY = dy * f * 0.3 * strength;
const lift  = f * 110 * strength;

card.style.transform =
  `translate3d(${restX + pullX}px, ${restY + pullY}px, ${baseZ + lift}px)
   rotate(${tiltInPlane}deg)`;     // rotate() is 2D — the card stays flat

Cluster is the default: a hallway in one-point perspective. Fourteen flat panels, each a different image, hang down the two walls of a corridor that runs straight away from a 90° lens into the middle of the headline. Left-hand panels face right and right-hand panels face left, all at exactly the same 75°, and the right wall mirrors the left — a shared angle is the whole trick; vary it per panel and each one gets a vanishing point of its own. Every panel travels slowly down the corridor at one steady speed, so by plain perspective it runs along a ray into the vanishing point, shrinking as it goes, and dissolves as it gets close; nothing is scaled by hand. Big near walls, mid walls and small deep ones are layered on each side, so they slide at different rates. The pointer slides the camera sideways rather than turning it, which keeps the vanishing point still, and a drag pushes it along the corridor. Depth is carried by scale, overlap and keystone — no shadow, blur or dimming, and square corners — and panels dissolve as they close on the vanishing point.

Globe mode runs the identical force over a different geometry. Every card sits on the inside of a sphere whose centre is the camera itself, each one turned to face that centre — which is what makes the distortion mirror about the middle, cards on the left angling one way and cards on the right the other. A plane can't do that; it has a single vanishing point, so it can only skew everything the same way.

// the sphere is centred ON the camera, which CSS puts at z = PERSP
card.style.transform =
  `translateZ(${PERSP}px)
   rotateY(${-az}rad) rotateX(${el}rad)
   translateZ(${-R}px)`;

// which lands at camera + R * direction, already facing home, and puts
// it on screen at PERSP * tan(az) — angle alone. R is then free to
// control apparent size and nothing else.

Church is the same sphere with the roles reversed: the tiles are fixed to its wall and the camera turns. In Globe the field slides across the screen and each tile's angle is read back from where it landed, which keeps the composition tidy but means a half-screen mouse move rotates the view by only about nine degrees. Here the same move turns your head twenty-five, so a tile visibly swings from face-on to oblique as it crosses the frame — which is the thing that makes it feel like a room you are standing in rather than a pattern in front of you. A wider lens, a shared radius, a few degrees of roll per tile, and a slow idle wander finish it. The wall repeats every 180°, so a full drag round never finds it empty. Tilt is the lens width here; Spread is how far apart the tiles are pinned.

Because screen position falls out of the angle and size falls out of the radius, the two are independent: Spread opens the globe wider without changing how big the cards read, and every card's stored x, y is simply reinterpreted as an angle around the viewer. A card dead ahead sits behind the headline; one out at the edge swings in front of it.

Hold the mouse down and the whole plane comes with you. The drag sets a target the render loop eases toward, so it tracks your hand at 0.38 while held and coasts on released velocity afterwards, decaying 10% a frame until it settles. The cursor attraction stands down for the duration — both forces answer to the same pointer, and running them together just makes the cards fight the drag. Touch is left alone so the page still scrolls on a phone.

The headline turns with the globe, over-driven well past the globe's own rotation so the type reads as an object in the space rather than a flat layer laid over it. Only the headline turns — the mark above it and the button below stay square, which keeps the turn legible as a deliberate move rather than the whole page sliding. It needs transform-style: preserve-3d on its container, or the stage's perspective never reaches it and the rotation flattens into a squash.

Cards sink into the gradient as they approach the bottom. That fade is a white veil layered over the stage, not a mask inside it: a mask forces the stage to flatten, which would collapse the 3D sorting between the cards and the headline. Fading each card by its own centre was the other option and it's worse — a card straddling the boundary should dissolve across itself, and whole-card opacity deletes the bottom row outright instead. The veil is invisible against a background that is already white down there; it only takes the cards.

One requestAnimationFrame loop, nineteen nodes, no libraries. Blur is quantised to whole pixels on an inner element so the transformed node stays a clean 3D participant, and reduced-motion visitors get the same composition held still.