A tiny course, and you're holding the camera. Drag to orbit it, flick to send it spinning, or jump to a preset angle. Flatten it to see the painted version of the same scene, or switch to wireframe to see that every piece is just a box.
drag to move the camera
camera
render
.course { transform-style: preserve-3d; }   /* the plane everything sits on */
.block  { position: absolute; transform-style: preserve-3d; }

/* every piece is the same block. only the numbers change. */
.ground { --w: 250px; --h: 32px; --d: 160px; --y:  62px; }
.step   { --w:  50px; --h: 30px; --d:  50px; --x: -56px; --y: 31px; --z: -42px; }
.crate  { --w:  46px; --h: 46px; --d:  46px; --x: -16px; --y: 23px; --z:  38px; }
.pipe   { --w:  52px; --h: 74px; --d:  52px; --x:  84px; --y:  9px; --z:  -8px; }
.star   { --w:  36px; --h: 36px; --d:  36px; --x: -16px; --y: -94px; --z: 38px; }

Super Mario 64 was 3D from the first second, the moment you walked out into the castle grounds and could run in any direction you liked. That part I took for granted. What I actually had to learn was the camera, and the moment it clicked, I was losing a penguin race. The slide down Cool, Cool Mountain, a penguin pulling ahead and a camera I couldn’t hold steady. I kept steering it like a flat platformer and kept sliding off the edge. Then somewhere around the tenth try it flipped: this was a real place I was moving a camera through, not a sprite I was pushing along a track. That silly race was the tutorial I actually needed. It taught me the new controls by making me want to win.

A few years earlier, the Mario I grew up on was flat. Super Mario Bros 3 had gorgeous depth and every bit of it was painted on: hills that drifted slower to feel far away, blocks that were flat pictures stacked in front of other flat pictures. It even opened on a stage with a curtain, telling you outright it was a flat thing pretending to be deep. There’s not one real 3D object in it, and it didn’t need one.

The leap from that painted stage to a world you move a camera through is the same leap CSS lets you make, and it’s smaller than it looks. The course at the top of the page is the far end of it: real blocks, real depth, and you holding the camera. This guide starts flat and builds up to it, one property at a time. You don’t need to have touched 3D before.

When depth was painted on

Before we step into 3D, it’s worth feeling the flat version, because you already reach for it every day. Depth on a 2D plane is faked with two tricks: things farther away are drawn behind and move less when the view shifts. That second one, near things moving more than far things, is parallax, and it’s the whole illusion in a side-scroller. Drag the scene below. The hills barely budge, the clouds drift a little, the ground moves with you.

Roughly the Super Mario Bros 3 trick: drag left and right. The far hills barely move, the ground keeps pace with you, and that speed difference is the only depth in the scene.
drag to scroll
.scene { overflow: hidden; }
.layer { position: absolute; inset: 0; }

/* one scroll value, three speeds. that ratio is the entire sense of depth. */
.hills  { transform: translateX(calc(var(--scroll) * 0.15)); }  /* far, barely moves */
.clouds { transform: translateX(calc(var(--scroll) * 0.45)); }  /* middle */
.ground { transform: translateX(calc(var(--scroll) * 1));    }  /* near, moves with you */

The same trick runs through most interface work: hero sections with drifting layers, sticky headers that shrink, a modal lifting above a dimmed page. They all lean on that one reading of space, near things shifting more than far ones. But it has a hard limit. Every layer is still a flat picture facing straight at you, so the moment the scene needs to turn, or you want to look at a block from the side, the illusion has nowhere to go. That’s the wall Super Mario 64 had to get over, and it’s where a real perspective comes in.

Press start on real 3D

Rotate a flat element in CSS with rotateY(45deg) and, by default, nothing exciting happens. It just gets narrower, like a door seen from very far away. There’s no sense that one edge is nearer than the other, because you haven’t told the browser where you’re standing. That’s the whole difference between painted depth and real depth: a point of view.

perspective gives it one. It goes on the parent, the scene, not the thing that turns, and it’s the distance from your eye to the screen. Small numbers put your face right against the glass, so near edges balloon and far ones race away. Large numbers are a long lens from across the room, almost flat. It’s the camera’s zoom, and it’s the first half of holding the camera. For interface work I keep it between 800 and 1200; I only drop under 400 when I actually want the fisheye, funhouse look. The corridor below never moves, not one block. Drag the distance and watch the same scene go from a plunging tunnel to something almost flat:

A corridor of blocks, all at fixed positions. Only the scene's perspective changes: pull it low and you get a fisheye tunnel, push it high and the whole thing flattens out.
700px
/* nothing in the corridor moves. the only thing that changes is the lens. */
.scene { perspective: 700px; }   /* smaller = wider angle = more dramatic convergence */

.crate { transform: translate3d(-74px, 34px,  40px); }
.pipe  { transform: translate3d( 76px, 24px, -40px); }
/* ...and so on, marching away down the negative Z axis */

You’re the camera

Zoom is only half of it. The other half is where the camera stands, and CSS calls that perspective-origin. It also lives on the scene, and it moves the vanishing point around: push it left and you see the scene as if you had stepped to the left, so the right sides of things come into view. This is the exact thing the yellow buttons did in Super Mario 64, walk the camera around and get a look at the far side of a block without moving the block at all. It’s the control I was missing in that penguin race: keep the world still, move the camera instead.

Watch what it does to a scene with things at different depths. The screen is the pivot: blocks sitting in front of it slide one way as you move, blocks behind it slide the other, and the further from that plane they sit, the further they travel. That separation between near and far is the same cue the side-scroller was faking with three scroll speeds, except here nobody had to pick the speeds. Drag the camera:

Not one block moves here. You do. Dragging slides perspective-origin, where the camera stands. The blocks in front of the screen swing one way, the ones behind it swing the other, and the further out they sit the more they travel.
drag to move the camera
.scene {
  perspective: 700px;            /* how close the camera is */
  perspective-origin: 50% 44%;   /* where the camera stands: move this to look around */
}

Between those two, distance and position, you have a camera. Everything else in this guide is the stuff you point it at.

Keeping the world solid: preserve-3d

Here’s the property that separates a real scene from a painting, and it’s the one that has eaten more of my afternoons than anything else in CSS 3D. By default, the browser flattens a 3D-transformed element back onto its parent’s plane the instant it draws it. The children get pressed into a flat picture, so a block inside it can’t actually stand out in depth, it just gets painted on. transform-style: preserve-3d on the parent turns that off and keeps everything in one shared space.

The block below is turning with preserve-3d on, its six sides held apart in real depth. Toggle it off and they collapse onto a single plane, still turning, but now as one flat sticker. That flattened version is the Super Mario Bros 3 backdrop again: a picture of a 3D thing, not the thing.

A turning block. The toggle flips transform-style between preserve-3d, a real solid, and flat, a painting of one.
.block { transform-style: preserve-3d; }   /* sides keep their depth */
.block.flat { transform-style: flat; }     /* they collapse onto one plane */

The catch is that preserve-3d only reaches as far as the unbroken chain of parents that all have it. One flat parent anywhere up the tree presses everything below it into a picture. So when a scene renders flat and you’re sure the maths is right, the first thing to check is that chain: walk up from the flattened block until you find the parent that forgot its preserve-3d. It’s almost always that.

Everything is a block

Games are built from polygons. In CSS the everyday building block is the cuboid: six flat faces, each turned to point outward and pushed away from the centre by half the depth. Rather than hard-code a size, it pays to build the shape once and let three numbers decide how big it is, --w wide, --h tall, --d deep, so the same recipe gives you a crate, a long platform, a tall pipe. Grab the crate below and turn it, then pull the sliders to resize it:

One block, six faces placed by --w / --h / --d. Drag to turn it, click it and use the arrow keys, or resize it with the sliders.
drag to turn
.block {
  --w: 130px; --h: 130px; --d: 130px;   /* change these to reshape it */
  transform-style: preserve-3d;
}
.face { position: absolute; top: 50%; left: 50%; }

/* each face rides out by half of the dimension it sits across */
.front { width: var(--w); height: var(--h);
         transform: translate(-50%,-50%) translateZ(calc(var(--d) / 2)); }
.right { width: var(--d); height: var(--h);
         transform: translate(-50%,-50%) rotateY(90deg) translateZ(calc(var(--w) / 2)); }
.top   { width: var(--w); height: var(--d);
         transform: translate(-50%,-50%) rotateX(90deg) translateZ(calc(var(--h) / 2)); }
/* back / left / bottom are the same three, negated */

Two things to know while you turn it. The three rotations are rotateX (tip it forward, like nodding), rotateY (turn it side to side, like shaking your head), and rotateZ (spin it flat, the one you already know). And order matters in a transform: rotateY(90deg) translateZ(60px) turns the face first, then pushes it out along its own new direction, which is how each side lands facing outward. Swap the two and it goes somewhere else. Transforms read left to right, each one working in the space the last one left behind. I’ve written them backwards in nearly every 3D thing I’ve built, usually at the point where I’m certain the maths is fine.

The block has two more knobs worth knowing. backface-visibility: hidden on each face hides it the moment it turns away, so the far sides of a solid never bleed through the near ones as it spins; the crate here uses it. And transform-origin moves the pivot a rotation turns around: leave it at the centre and the block spins in place, set it to an edge and a rotateY swings like a door on a hinge.

Building a course

That course at the top of the page is nothing more than a handful of these blocks on one plane: a wide flat one for the ground with a green top, a couple of steps, a crate stack, a pipe, some coins and a star block floating above. You place each with its --x / --y / --z, all inside one preserve-3d scene, and then you turn the whole scene at once, which is the same as walking the camera around it.

.course { transform-style: preserve-3d; }

.ground { transform: translate3d(0, 62px, 0); }         /* wide and low */
.crate  { transform: translate3d(-16px, 23px, 38px); }  /* sitting on the ground */
.pipe   { transform: translate3d(84px, 9px, -8px); }
.star   { transform: translate3d(-16px, -94px, 38px); } /* floating over the crates */

/* move the camera by turning the whole course */
.course { transform: rotateX(-24deg) rotateY(var(--spin)); }

Scenes that look like a lot of work are usually this: a pile of blocks, each one trivial, arranged with some patience, and one rotation on the parent standing in for the camera.

Two buttons under that course are worth pressing before you move on. Wireframe strips the paint off and leaves the skeleton, which is the fastest way to stop seeing a game and start seeing boxes. Flatten it swaps the scene’s transform-style to flat and the whole level collapses onto a single plane: same blocks, same positions, all the depth gone. That’s the Super Mario Bros 3 version of the same course, one property away from the Mario 64 one, which is the entire argument of this article in a single toggle.

Moving the camera without making anyone sick

Anyone who played Super Mario 64 remembers fighting that camera. The cloud holding it meant well. The lesson carries over: a moving camera does a lot of work, and it’s easy to overdo.

Three habits cover almost all of it. Animate transform and opacity and nothing else, because those two move on the GPU without redoing layout, while top or width reflow the page every frame. Put will-change: transform on the few things that actually move, which also cures a flicker where the browser keeps promoting and demoting a layer as a value crosses a threshold: the perspective block on this page strobed every time the slider passed a certain distance until I pinned it. And gate ambient motion behind prefers-reduced-motion, because a slow orbit is pleasant for most people and genuinely sickening for some. Every demo here does. I went further into easing and timing in The Art of CSS Motion.

.block {
  transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
  will-change: transform;   /* not on everything, a layer each costs memory */
}
@media (prefers-reduced-motion: reduce) {
  .block { transition: none; animation: none; }
}

The rest of the toolbox

You’ve used most of it by hand already. Here’s the rest, including the long forms behind the shorthands you’ve been typing.

  • translate3d(x, y, z) moves on all three axes at once; translateZ(n) is just translate3d(0, 0, n).
  • rotate3d(x, y, z, angle) turns around any axis you give as a vector, so you aren’t stuck with the three cardinal ones. rotateY(45deg) is rotate3d(0, 1, 0, 45deg).
  • scale3d(x, y, z) and scaleZ(n) scale in depth, which only shows up inside a preserve-3d group.
  • matrix3d(…) is the 4x4 matrix all of the above compile down to. You’ll never write one by hand (I never have), but it’s what a JavaScript animation library emits.

translate, rotate and scale are also properties in their own right now, not only functions inside transform, and because each is separate you can change one without restating the others. That’s why the coins on the course spin where they stand instead of being flung across the level. Move the sliders below, then flip the syntax and watch the block not budge:

A live transform. Set an axis, an angle, a depth and a scale, then switch between one transform and the separate properties. The block never moves when you flip, because both spell the same thing.
axis
write it as
/* the same result, two ways to write it */
.a { transform: translateZ(40px) rotateY(38deg) scale(1.1); }

.b { translate: 0 0 40px;   /* each one is its own property, so you can */
     rotate: y 38deg;       /* animate or change any of them on its own */
     scale: 1.1; }

Letting everyone play

3D is a visual effect, so nothing that matters can live only in the depth. If a control is reachable only by dragging, or meaning depends on which side of a block faces the viewer, someone will miss it. Everything on this page still works without seeing the 3D.

So every stage here takes the keyboard as well as the pointer: a tabindex, a role, an aria-label, and a focus ring loud enough to survive being drawn on a turning object.

<div class="scene" tabindex="0" role="group"
     aria-label="3D scene, arrow keys move the camera">…</div>

Tab to the block below, or tap the buttons, and watch the readout:

One block, three ways in: the arrow keys, the on-screen buttons, or a drag. Tab to it and the focus ring shows up. Nothing here needs a mouse.
tab here, then use the arrow keys
waiting for input
// one function, and every input calls it. the keyboard is not a fallback
// bolted on afterwards, it is the same path the drag and the buttons take.
function step(key) {
  if (key === 'ArrowLeft')  ry -= 12;
  if (key === 'ArrowRight') ry += 12;
  if (key === 'ArrowUp')    rx -= 12;
  if (key === 'ArrowDown')  rx += 12;
  rig.style.transform = `rotateX(${rx}deg) rotateY(${ry}deg)`;
}

stage.addEventListener('keydown', e => step(e.key));
padButton.addEventListener('click', e => step(e.target.dataset.k));

Two more habits worth keeping: drive any flip or reveal from real state, a checkbox’s :checked or an aria-pressed button, so assistive tech knows both sides exist, and honour prefers-reduced-motion at the system level rather than behind a toggle someone has to go find.

Where the illusion ends

CSS 3D is a real projective renderer bolted onto a 2D layout engine, and the seams show in specific, learnable places. Building the little course on this page walked me into two of them, so these aren’t hypothetical.

overflow flattens. Setting overflow: hidden, auto, or scroll on an element forces a flatten on everything inside it, which quietly kills preserve-3d. If a scene goes flat the moment you add a scroll container, this is why. Keep the clipping element out of the preserve-3d chain.

.scene { perspective: 800px; overflow: hidden; }            /* fine: perspective, not preserve-3d */
.group { transform-style: preserve-3d; overflow: hidden; }  /* trap: flattens everything inside */

The sorting is approximate, and z-index won’t save you. The browser orders whole elements by depth rather than working out pixel by pixel what sits in front of what, so something small next to something big can land on the wrong side of it. My spinning coins kept drawing over the pipe while sitting plainly behind it. Stacking order is a separate system from 3D position, so z-index only adds a second argument to the first. I moved the coins into open air and let the geometry be unambiguous, which is usually the real fix.

Blurs and glows get clipped to their layer. An outer box-shadow on one of those coins came out as a soft grey square instead of a halo, because the blur is painted into a rectangular compositing layer and cut off at its edges. Inset shadows were fine.

Support is the happy part. Everything here is Baseline, in every engine for over a decade, no prefixes. The one that still bites is Safari: WebKit flattens a preserve-3d element the moment it also carries overflow other than visible, a filter, a clip-path, a mask, or opacity below 1. If a scene collapses only there, that’s what to hunt for.

And it isn’t a game engine. No real lights, no shadows falling on other objects, no camera collision, no meshes, nothing culled when it leaves the screen. When a scene genuinely needs those, that’s where WebGL starts to pay for itself. Everything short of it, the crate, the pipe, the little course, is a handful of properties the browser has shipped for years.

The instruction booklet

Every cartridge came with a little paper manual you’d read in the car on the way home, usually before you’d played a second of the thing. So here’s yours: the whole system on one page, for when you’re back in six months.

PropertyGoes onDoes
perspectivethe scene (parent)how close the camera is; smaller is more extreme
perspective-originthe scene (parent)where the camera stands; move it to look around
transform-style: preserve-3devery level of the chainkeeps a real scene instead of a flat picture
transform (translateZ, rotateX/Y/Z)the blockplaces and turns it
transform-originthe blockthe pivot a rotation turns around
backface-visibility: hiddeneach facedrops a face once it turns away

And the checks when something looks wrong:

  • Flat when it should have depth? A preserve-3d is missing up the chain, or overflow / filter / clip-path is flattening it, most often in Safari.
  • Faces stacking in the wrong order? Order by translateZ inside one preserve-3d parent, not z-index.
  • Janky? Animate only transform and opacity, and put will-change: transform on the few things that move.
  • Uncomfortable to watch? Settle on a still frame under prefers-reduced-motion.

You started at the flat end, painted-on depth in a side-scroller, and you can now build the other end: a real scene with a camera you move. That’s the whole leap from Super Mario Bros 3 to Super Mario 64. Nintendo needed a new console for it. You need the six properties in that table, and the browser has had them for a decade.

They did call it an Entertainment System. Go and build something silly with it.