Lesson 8
3D Transforms - The Z-Axis
CSS gives you a full three-dimensional coordinate system. The X-axis runs left-right, the Y-axis runs top-bottom, and the Z-axis points straight out of the screen towards you. This lesson covers how to think in 3D, how the browser renders depth, and how to build real interfaces that use it.
The Mental Model
What "3D" actually means in CSS
In normal 2D CSS, every element is a flat rectangle in a flat plane. transform: rotate(45deg) spins it within that plane - like rotating a piece of paper on a desk. You never see the paper from the side because you're always looking straight down at it.
3D transforms break out of that flat plane. rotateX(45deg) tips the element towards you (like tilting the paper so you see it at an angle). rotateY(45deg) turns it like a revolving door. translateZ(100px) lifts it off the desk towards your face.
But here's the critical thing: the browser doesn't show 3D by default. Even if you apply rotateY(45deg), the browser projects the result back onto a flat 2D image - like taking a photograph of a rotated card. You lose the depth cues. To get actual perspective (where closer things look bigger and parallel lines converge), you need to explicitly set up a 3D rendering context.
The three layers of 3D in CSS
Every element lives on a flat 2D plane by default. When you apply 3D transforms, you're lifting that plane off the screen (positive Z) or pushing it behind the screen (negative Z), or rotating it around axes so you see it from an angle.
But here's the catch: the browser only shows 3D if you tell a parent to create a 3D rendering context. Without that, transforms are projected flat - like looking at a photograph of a 3D object rather than the object itself.
Two properties create that context:
perspective- the viewer's distance from the z=0 plane. Low values (100-300px) mean the viewer is close, producing dramatic foreshortening. High values (1000px+) mean the viewer is far away, producing a flatter, more subtle effect.transform-style: preserve-3d- tells the browser that children of this element actually exist in 3D space, rather than being flattened into a 2D image before rendering. Without it, even if children have differenttranslateZvalues, they'll be painted onto a single flat plane.
With both in place, the transform functions (rotateX, rotateY, translateZ, etc.) produce real visual depth - closer things look bigger, rotated surfaces converge toward a vanishing point, and elements at different Z positions visually overlap correctly.
The coordinate system
CSS uses a left-handed coordinate system anchored to the element:
- X-axis - positive goes right
- Y-axis - positive goes down (not up, like in maths - this matches CSS where top=0)
- Z-axis - positive comes towards the viewer (out of the screen)
When you rotate around an axis, the element spins like it's on a skewer aligned with that axis. rotateX = skewer horizontal through the element, it tips forward/backward. rotateY = skewer vertical through the element, it turns left/right like a door. rotateZ = skewer pointing at you, it spins like a clock hand (same as 2D rotate()).
What you need to set up
Two properties create the 3D rendering context:
perspectiveon a parent - establishes viewer distance (the "camera")transform-style: preserve-3don a container - lets children actually exist at different Z positions rather than being painted flat
Without perspective, there are no depth cues - a rotated element looks the same width whether it's near or far. Without preserve-3d, children at different Z positions get collapsed back onto one plane before rendering.
Perspective
Perspective defines the distance between the viewer and the z=0 plane. It controls how much "convergence" you see - how strongly parallel lines appear to meet at a vanishing point.
- Low values (50-300px) - extreme foreshortening, like holding something very close to your eye. Dramatic but can look distorted.
- Medium values (400-800px) - natural-looking depth. Good for most UI.
- High values (1000px+) - very subtle perspective. Close to an orthographic (flat) projection.
/* Applied to the PARENT that contains 3D-transformed children */
.scene {
perspective: 600px;
}
/* OR applied per-element via the transform function */
.element {
transform: perspective(600px) rotateY(45deg);
}perspective on a parent creates a shared vanishing point for all children - they all converge to the same point. The perspective() function inside transform gives each element its own vanishing point. For most UI work, you want the shared version on a parent.
Interactive: Perspective Distance
.scene {
perspective: 600px;
}
.box {
transform: rotateY(45deg);
}
The rotation stays at 45deg throughout. What changes is how dramatically you perceive the depth. Low values exaggerate the near/far difference; high values flatten it.
perspective-origin
By default the vanishing point is at the centre of the parent (50% 50%). perspective-origin lets you shift where that vanishing point sits - where parallel lines converge to. Think of it as moving the viewer's eye position left/right and up/down without changing their distance.
This is most visible when you have multiple children in the same perspective container - they all converge towards the same point, and moving that point changes how each child appears relative to the others.
.scene {
perspective: 500px;
perspective-origin: 25% 75%; /* vanishing point shifted left and down */
}
Interactive: Perspective Origin
The crosshair marks the vanishing point. All boxes converge toward it. The spatial layout of the sliders matches what they control.
.scene {
perspective: 500px;
perspective-origin: 50% 50%;
}
.box {
transform: rotateY(40deg);
}
transform-style: preserve-3d
By default, every element flattens its children back to 2D after applying its own transform. This is transform-style: flat (the default). To let children actually occupy different positions along the Z-axis, the parent needs:
.container {
transform-style: preserve-3d;
}Interactive: preserve-3d vs flat
.container {
transform-style: preserve-3d;
transform: rotateX(-15deg) rotateY(25deg);
}
.layer-1 { transform: translateZ(0); }
.layer-2 { transform: translateZ(60px); }
.layer-3 { transform: translateZ(120px); }
Toggle to see the difference. With flat, all three planes collapse onto each other. With preserve-3d, they stack in actual depth.
Several common properties force flattening even if you set preserve-3d: overflow: hidden, clip-path, opacity (values other than 1), filter, and mix-blend-mode. If your 3D looks broken, check for these on ancestor elements.
The 3D Transform Functions
Interactive: Rotation Playground
Each slider is oriented to match the axis it controls: X across the top, Y down the side, Z diagonally.
.box {
transform: rotateX(0deg) rotateY(0deg) rotateZ(0deg);
}
X (top) tips forward/back. Y (side) spins like a door. Z rotates like a clock hand.
/* Rotate around the X-axis (tips forward/backward) */
transform: rotateX(45deg);
/* Rotate around the Y-axis (spins like a revolving door) */
transform: rotateY(45deg);
/* Rotate around the Z-axis (same as 2D rotate) */
transform: rotateZ(45deg);
/* Arbitrary axis */
transform: rotate3d(1, 1, 0, 45deg);Interactive: TranslateZ
Move an element along the Z-axis. Positive values bring it closer (larger), negative values push it away (smaller).
.scene { perspective: 600px; }
.box { transform: translateZ(0px); }
The dashed lines converge to the vanishing point. The element isn't scaling - it's moving along the Z-axis. Closer = appears larger. Further = appears smaller.
/* Move along the Z-axis - towards (+) or away from (-) the viewer */
transform: translateZ(50px);
/* Combined X, Y, Z */
transform: translate3d(10px, 20px, 50px);Interactive: Transform Origin in 3D
Every transform (rotate, scale, skew) happens around a pivot point - the fixed point that doesn't move while everything else rotates or scales around it. By default that pivot is the centre of the element (50% 50%).
In 2D, transform-origin takes two values (X and Y). In 3D, it takes a third: the Z offset of the pivot. This is where it gets interesting - you can place the pivot point behind or in front of the element, turning a simple rotation into an orbiting motion.
Every rotation needs a fixed point - the point that stays still while everything else moves around it. By default that's the centre of the element. transform-origin lets you move that fixed point anywhere:
- 50% 50% (default) - rotates in place, like spinning a basketball on your finger
- 0% 50% - fixed point on the left edge, so it swings open like a door hinge
- 50% 50% -100px - fixed point is 100px behind the element. Now it swings around that distant point - the element moves through space rather than spinning in place
The first two values (X%, Y%) move the pivot across the element's face. The third value (Z px) pushes the pivot towards or away from the viewer. A pivot behind the element turns rotation into sweeping motion through space.
.box {
transform-origin: 50% 50% 0px;
transform: rotateX(0deg) rotateY(45deg) rotateZ(0deg);
}
Try: set origin-x to 0% (left edge hinge). Or set origin-z to -100px then rotateY - it orbits a distant point.
/* The third value is the Z offset of the transform origin */
transform-origin: 50% 50% -100px;
/* Rotating now orbits around that distant pivot */
transform: rotateY(90deg);backface-visibility
When you rotate an element more than 90 degrees, you see its back. By default CSS shows a mirror image of the front. To hide the back:
.card-face {
backface-visibility: hidden;
}Interactive: backface-visibility
.box {
transform: rotateY(0deg);
backface-visibility: visible;
}
Rotate past 90deg to see the back. Toggle the checkbox - with hidden, the element disappears when facing away.
Pattern: Card Flip
The classic use case for 3D transforms. Click or hover the card below:
Both faces use backface-visibility: hidden. The back face is pre-rotated 180deg.
<div class="flip-scene">
<div class="flip-card">
<div class="flip-card__face flip-card__face--front">Front</div>
<div class="flip-card__face flip-card__face--back">Back</div>
</div>
</div>/* 1. Perspective - establishes viewer distance */
.flip-scene {
perspective: 800px;
width: 250px;
height: 160px;
}
/* 2. preserve-3d - children exist in real 3D space */
.flip-card {
width: 100%;
height: 100%;
position: relative;
transform-style: preserve-3d;
transition: transform 0.6s ease;
}
/* 3. Trigger the flip */
.flip-scene:hover .flip-card {
transform: rotateY(180deg);
}
/* 4. Each face: stacked, back hidden */
.flip-card__face {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
}
/* 5. Back face pre-rotated 180deg */
.flip-card__face--back {
transform: rotateY(180deg);
}Pattern: CSS Cube
Six faces, each rotated into position then pushed out by half the cube size via translateZ. Drag the sliders to inspect it from any angle:
.cube {
transform: rotateX(-20deg) rotateY(30deg) rotateZ(0deg);
}
A full 3D cube built with 6 <div> elements and pure CSS transforms.
<div class="scene">
<div class="cube">
<div class="face face--front">Front</div>
<div class="face face--back">Back</div>
<div class="face face--right">Right</div>
<div class="face face--left">Left</div>
<div class="face face--top">Top</div>
<div class="face face--bottom">Bottom</div>
</div>
</div>/* 1. Perspective on the scene */
.scene {
perspective: 600px;
}
/* 2. preserve-3d on the cube - faces exist in real 3D */
.cube {
width: 200px;
height: 200px;
position: relative;
transform-style: preserve-3d;
}
/* 3. Each face: absolute, same size as cube */
.face {
position: absolute;
width: 200px;
height: 200px;
}
/* 4. Position each face: rotate into place, then push out */
.face--front { transform: translateZ(100px); }
.face--back { transform: rotateY(180deg) translateZ(100px); }
.face--right { transform: rotateY(90deg) translateZ(100px); }
.face--left { transform: rotateY(-90deg) translateZ(100px); }
.face--top { transform: rotateX(90deg) translateZ(100px); }
.face--bottom { transform: rotateX(-90deg) translateZ(100px); }Transform functions are applied right-to-left (each one transforms the element's local coordinate system). When you rotateY(90deg), the local Z-axis now points right in world space. Then translateZ(100px) pushes along that new local Z - to the right. That's how faces end up in their correct positions.
Pattern: Layered Depth (Parallax)
Place elements at different Z positions inside a perspective container. Elements further back (negative Z) appear smaller due to perspective - just like distant objects in real life.
.container { perspective: 400px; }
.back { transform: translateZ(-200px); }
.mid { transform: translateZ(-100px); }
.front { transform: translateZ(0); }
No scale compensation - you can clearly see how perspective shrinks distant layers. Low values = dramatic size difference. High values = nearly flat.
/* Container: perspective + scrollable */
.parallax-container {
perspective: 500px;
height: 100vh;
overflow-x: hidden;
overflow-y: auto;
transform-style: preserve-3d;
}
/* Layers at different Z depths - further back = appears smaller */
.layer--back { transform: translateZ(-200px); }
.layer--mid { transform: translateZ(-100px); }
.layer--front { transform: translateZ(0); }
/* Production tip: add scale() to counteract perspective shrink
so layers appear equal size at rest.
Formula: scale = 1 + (abs(Z) / perspective)
e.g. Z=-200px, perspective=500 -> scale(1.4) */Performance Notes
- Compositing: 3D-transformed elements get their own GPU layer. This is fast for animation but uses memory - don't create hundreds of layers.
- will-change: Use
will-change: transformon elements you know will animate. Remove when done. - Repaints: Changing
perspectiveorperspective-originrepaints all children. Animate child transforms instead. - Stacking context: Any 3D-transformed element creates a new stacking context, affecting z-index interactions.
Common Pitfalls
| Problem | Likely cause |
|---|---|
| 3D looks flat | Missing perspective on parent |
| Children are flat despite transforms | Missing transform-style: preserve-3d |
| preserve-3d ignored | Ancestor has overflow: hidden, filter, or opacity < 1 |
| Mirror image showing | Need backface-visibility: hidden |
| Card flip flickers | Both faces need position: absolute + same dimensions |
| Rotation seems off-centre | Check transform-origin (especially the Z value) |
Quiz
Question 1
What does perspective: 300px on a parent element do?
Question 2
Why does a card flip need backface-visibility: hidden?
Question 3
Which property forces flattening even when transform-style: preserve-3d is set?