← Back to Index

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

The 3D coordinate system

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 different translateZ values, 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:

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:

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.

/* 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 property vs perspective() function

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

rotateY(45deg)
perspective 600px
.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

y 50%
x 50%

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

Z: 0
Z: 60px
Z: 120px
.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.

Flattening gotcha

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.

Ad

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.

X 0deg
Y 0deg
Z 0deg
X:0 Y:0 Z:0
.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).

Z: 0px
translateZ 0px
.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.

What transform-origin does

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.

origin:
50% 50% 0px
origin-x 50%
rotateX 0deg
origin-y 50%
rotateY 45deg
origin-z 0px
rotateZ 0deg
.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

rotateY 0deg
FRONT
.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:

Front
Back

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:

Front
Back
Right
Left
Top
Bottom
X -20deg
Y 30deg
Z 0deg
.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); }
Why translateZ comes after rotate

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.

Back (Z: -200px)
Mid (Z: -100px)
Front (Z: 0)
perspective 400px
.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

Common Pitfalls

ProblemLikely cause
3D looks flatMissing perspective on parent
Children are flat despite transformsMissing transform-style: preserve-3d
preserve-3d ignoredAncestor has overflow: hidden, filter, or opacity < 1
Mirror image showingNeed backface-visibility: hidden
Card flip flickersBoth faces need position: absolute + same dimensions
Rotation seems off-centreCheck transform-origin (especially the Z value)

Quiz

Question 1

What does perspective: 300px on a parent element do?

Moves the element 300px along the Z-axis
Sets the viewer's distance from the z=0 plane to 300px, creating a strong 3D effect
Adds 300px of padding in the Z direction
Creates a 300px gap between front and back faces

Question 2

Why does a card flip need backface-visibility: hidden?

To improve animation performance
To prevent the card from being clickable when flipped
To hide each face when rotated away from the viewer, so only the facing side shows
To enable preserve-3d on child elements

Question 3

Which property forces flattening even when transform-style: preserve-3d is set?

display: flex
overflow: hidden
position: relative
z-index: 1