CSS perspective: make 3D transforms actually look 3D

rotateY on its own only makes a box narrower. Add perspective and the near edge grows, the far edge shrinks, and the same rotation suddenly has depth.

The CSS perspective property gives 3D transforms depth. Put it on a parent, for example perspective: 600px, then rotate a child with rotateY() or push it with translateZ(). Without it, a 3D rotation is drawn flat: the element just gets narrower or shorter.

Try it first. Change the perspective, move the viewer left and right, and switch between the two ways of writing it.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CSS perspective lab</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .stage {
    height: 190px; display: flex; justify-content: center; align-items: center; gap: 14px;
    background: #fff; border-radius: 12px; border: 1px solid #e1e4ea;
    perspective: 400px;          /* depth for the children */
    perspective-origin: 50% 50%; /* where the viewer stands */
  }
  .card {
    width: 84px; height: 110px; border-radius: 10px;
    display: grid; place-items: center; font-weight: 700; color: #fff;
    background: linear-gradient(135deg, #2563eb, #7c3aed);
    transform: rotateY(40deg);
  }
  .controls { display: grid; grid-template-columns: auto 1fr auto; gap: 8px 10px; align-items: center; margin-top: 12px; font-size: 14px; }
  .controls output { font-family: ui-monospace, Consolas, monospace; min-width: 52px; text-align: right; }
  input[type=range] { width: 100%; }
  .mode { display: flex; gap: 6px 14px; margin-top: 10px; font-size: 14px; flex-wrap: wrap; }
  pre { margin: 10px 0 0; padding: 10px 12px; background: #1d2330; color: #e5e7eb; border-radius: 8px; font-size: 12.5px; white-space: pre-wrap; }
  .off { opacity: .45; }
</style>
</head>
<body>
<div class="stage" id="stage">
  <div class="card">1</div><div class="card">2</div><div class="card">3</div>
</div>

<div class="controls">
  <label for="p">perspective</label><input id="p" type="range" min="100" max="1500" step="10" value="400"><output id="pOut">400px</output>
  <label for="o" id="oLabel">origin x</label><input id="o" type="range" min="0" max="100" value="50"><output id="oOut">50%</output>
  <label for="r">rotateY</label><input id="r" type="range" min="-80" max="80" value="40"><output id="rOut">40deg</output>
</div>
<div class="mode">
  <label><input type="radio" name="mode" value="parent" checked> perspective on the parent</label>
  <label><input type="radio" name="mode" value="fn"> perspective() on each card</label>
</div>
<pre id="code"></pre>

<script>
  const stage = document.getElementById('stage');
  const cards = document.querySelectorAll('.card');
  const p = document.getElementById('p'), o = document.getElementById('o'), r = document.getElementById('r');

  function update() {
    const mode = document.querySelector('[name=mode]:checked').value;
    const persp = p.value + 'px', rot = 'rotateY(' + r.value + 'deg)';
    if (mode === 'parent') {
      // one shared viewer for all three cards
      stage.style.perspective = persp;
      stage.style.perspectiveOrigin = o.value + '% 50%';
      cards.forEach(c => c.style.transform = rot);
      document.getElementById('code').textContent =
        '.stage { perspective: ' + persp + '; perspective-origin: ' + o.value + '% 50%; }\n.card  { transform: ' + rot + '; }';
    } else {
      // every card gets its own viewer, straight in front of it
      stage.style.perspective = 'none';
      cards.forEach(c => c.style.transform = 'perspective(' + persp + ') ' + rot);
      document.getElementById('code').textContent =
        '.stage { /* no perspective */ }\n.card  { transform: perspective(' + persp + ') ' + rot + '; }';
    }
    // perspective-origin belongs to the parent property only
    o.disabled = mode !== 'parent';
    document.getElementById('oLabel').classList.toggle('off', o.disabled);
    document.getElementById('pOut').textContent = persp;
    document.getElementById('oOut').textContent = o.value + '%';
    document.getElementById('rOut').textContent = r.value + 'deg';
  }
  document.querySelectorAll('input').forEach(i => i.addEventListener('input', update));
  update();
</script>
</body>
</html>
Three cards with the same rotateY. Watch how they differ with the parent property and match with the function.

The number is a distance. It says how far the viewer sits from the screen, so a smaller number means a closer viewer and a stronger effect.

perspective vs perspective(): parent or element

There are two ways to add depth, and they are not interchangeable.

The property gives all children one vanishing point. The function gives each element its own.
The property gives all children one vanishing point. The function gives each element its own.
perspective: 600px transform: perspective(600px) ...
Goes on The parent The element itself
Affects Every 3D child Only that element
Viewer One shared vanishing point One per element
perspective-origin Works Ignored
Good for Scenes, grids, cubes A single card or button

Two traps come up often. The property does nothing for the element that has it; it only affects that element's children. And the function must come before the rotation in the list. rotateY(40deg) perspective(600px) shows no depth at all.

Smaller values mean stronger 3D

The same rotateY(50deg) looks very different at three distances.

150px distorts, 600px looks natural, 3000px is nearly flat.
150px distorts, 600px looks natural, 3000px is nearly flat.

On cards the size of the ones above, values from 400px to 1000px give clear depth without heavy distortion.

Below 300px the near edge swells fast, which can suit a dramatic entrance but is hard to read. There is no single correct number; pick one by eye with the slider.

perspective-origin moves the viewer. The default is 50% 50%, the center of the parent. perspective-origin: 0% 50% puts the viewer at the left edge, so everything is seen from the left. It only works together with the perspective property on the same element.

The 3D functions: rotateX, rotateY, translateZ

The x axis runs left to right, y runs top to bottom, and z points out of the screen at you.

Function What it does
rotateX(a) Tips the element forward or back, like a flap
rotateY(a) Turns it left or right, like a door
rotateZ(a) Spins it flat on the screen, same as rotate()
translateZ(d) Moves it toward you (positive) or away (negative)
rotate3d(x, y, z, a) Turns around any axis you describe

translateZ only changes size when there is perspective. With perspective: 400px on the parent, translateZ(100px) makes an element look bigger because it is closer to the viewer. The basic 2D functions and why their order matters are covered in CSS transform.

transform-style: preserve-3d for nested 3D

By default each element flattens its children into its own plane. That is fine for one card, but a cube is six faces inside one box, and the box itself turns.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CSS 3D cube</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .scene {
    height: 230px; display: grid; place-items: center;
    background: #fff; border-radius: 12px; border: 1px solid #e1e4ea;
    perspective: 600px;
    touch-action: none; cursor: grab; /* drag to turn, also with a finger */
  }
  .cube {
    position: relative; width: 120px; height: 120px;
    transform-style: preserve-3d; /* keep the faces in 3D */
    transform: rotateX(-25deg) rotateY(35deg);
  }
  .face {
    position: absolute; inset: 0; border-radius: 6px;
    display: grid; place-items: center; font-weight: 700; font-size: 18px; color: #fff;
    border: 2px solid rgba(255, 255, 255, .6);
  }
  /* each face turns to its side, then moves out by half the cube size */
  .front  { background: rgba(37, 99, 235, .8);  transform: translateZ(60px); }
  .back   { background: rgba(124, 58, 237, .8); transform: rotateY(180deg) translateZ(60px); }
  .right  { background: rgba(5, 150, 105, .8);  transform: rotateY(90deg) translateZ(60px); }
  .left   { background: rgba(234, 88, 12, .8);  transform: rotateY(-90deg) translateZ(60px); }
  .top    { background: rgba(219, 39, 119, .8); transform: rotateX(90deg) translateZ(60px); }
  .bottom { background: rgba(71, 85, 105, .8);  transform: rotateX(-90deg) translateZ(60px); }

  .cube.hide-back .face { backface-visibility: hidden; }
  .cube.flat { transform-style: flat; }
  .cube.clip { overflow: hidden; }

  .controls { display: grid; grid-template-columns: auto 1fr auto; gap: 8px 10px; align-items: center; margin-top: 12px; font-size: 14px; }
  .controls output { font-family: ui-monospace, Consolas, monospace; min-width: 48px; text-align: right; }
  input[type=range] { width: 100%; }
  .toggles { display: grid; gap: 6px; margin-top: 10px; font-size: 14px; }
  code { font-family: ui-monospace, Consolas, monospace; font-size: 13px; }
</style>
</head>
<body>
<div class="scene" id="scene">
  <div class="cube" id="cube">
    <div class="face front">front</div><div class="face back">back</div>
    <div class="face right">right</div><div class="face left">left</div>
    <div class="face top">top</div><div class="face bottom">bottom</div>
  </div>
</div>

<div class="controls">
  <label for="rx">rotateX</label><input id="rx" type="range" min="-180" max="180" value="-25"><output id="rxOut">-25deg</output>
  <label for="ry">rotateY</label><input id="ry" type="range" min="-180" max="180" value="35"><output id="ryOut">35deg</output>
</div>
<div class="toggles">
  <label><input type="checkbox" id="preserve" checked> <code>transform-style: preserve-3d</code> on the cube</label>
  <label><input type="checkbox" id="hide"> <code>backface-visibility: hidden</code> on the faces</label>
  <label><input type="checkbox" id="clip"> <code>overflow: hidden</code> on the cube</label>
</div>

<script>
  const cube = document.getElementById('cube');
  const rx = document.getElementById('rx'), ry = document.getElementById('ry');

  function update() {
    cube.style.transform = 'rotateX(' + rx.value + 'deg) rotateY(' + ry.value + 'deg)';
    document.getElementById('rxOut').textContent = rx.value + 'deg';
    document.getElementById('ryOut').textContent = ry.value + 'deg';
    cube.classList.toggle('flat', !document.getElementById('preserve').checked);
    cube.classList.toggle('hide-back', document.getElementById('hide').checked);
    cube.classList.toggle('clip', document.getElementById('clip').checked);
  }
  document.querySelectorAll('input').forEach(i => i.addEventListener('input', update));

  // drag on the scene to turn the cube
  const scene = document.getElementById('scene');
  let lastX = 0, lastY = 0;
  const wrap = v => ((Math.round(v) + 540) % 360) - 180; // keep angles in -180..180
  scene.addEventListener('pointerdown', e => {
    lastX = e.clientX; lastY = e.clientY;
    scene.setPointerCapture(e.pointerId);
  });
  scene.addEventListener('pointermove', e => {
    if (!scene.hasPointerCapture(e.pointerId)) return;
    ry.value = wrap(+ry.value + (e.clientX - lastX) * 0.6);
    rx.value = wrap(+rx.value - (e.clientY - lastY) * 0.6);
    lastX = e.clientX; lastY = e.clientY;
    update();
  });
  update();
</script>
</body>
</html>
Drag the cube or use the sliders. Untick preserve-3d, or tick overflow: hidden, and the faces collapse.

transform-style: preserve-3d on the cube keeps its faces in the same 3D space as the cube. Each face then gets a rotation to point at its side and a translateZ of half the cube size to move out to it:

.scene { perspective: 600px; }
.cube  { transform-style: preserve-3d; transform: rotateX(-25deg) rotateY(35deg); }
.front { transform: translateZ(60px); }
.right { transform: rotateY(90deg) translateZ(60px); }
.back  { transform: rotateY(180deg) translateZ(60px); }

Inside a preserve-3d context, the element nearer the viewer is drawn in front. z-index does not reorder it. If a panel should sit in front, give it a larger translateZ. CSS z-index explains how stacking works everywhere else.

backface-visibility: hidden for flip cards

Every element has a back. When it turns more than 90 degrees away, the browser shows that back, mirrored, unless you tell it not to.

backface-visibility: hidden hides an element whenever its back faces the viewer. In the cube demo, tick it and the see-through faces stop showing the ones behind them. A flip card uses the same rule:

.flip  { perspective: 700px; }
.inner { transform-style: preserve-3d; transition: transform .6s; }
.flip.flipped .inner { transform: rotateY(180deg); }
.side  { position: absolute; inset: 0; backface-visibility: hidden; }
.back  { transform: rotateY(180deg); }

The back face starts turned 180 degrees, so it is hidden. When .inner turns another 180, the two cancel and the back reads the right way round. CSS transform has a single hover flip; the finished example below adds taps, keys and reduced motion.

What flattens 3D

preserve-3d is a request, not a guarantee. Some properties need to paint an element and its children together as one flat image, and CSS calls them grouping properties. When one is on the preserve-3d element, the browser treats it as flat.

The same cube with and without overflow: hidden. The list was checked in Chromium.
The same cube with and without overflow: hidden. The list was checked in Chromium.
  • overflow set to anything other than visible
  • opacity below 1, even 0.99
  • any filter, including blur(0)
  • clip-path, mask-image
  • mix-blend-mode other than normal, and isolation: isolate

The fix is to move the effect.

Put the opacity or filter on a wrapper outside the 3D scene, or on each face inside it. Faces with no 3D children of their own can use any of these safely. The CSS overflow guide covers the clipping values.

A finished example: tilt card and flip cards

The top card leans toward a mouse or pen. The three below flip on click, tap, or Enter.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tilt card and flip cards</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #eef1f6; color: #1d2330; }
  h2 { font-size: 15px; margin: 0 0 10px; color: #4b5563; font-weight: 600; }

  /* ---- 1. Tilt card ---- */
  .tilt-wrap { perspective: 800px; position: relative; display: grid; place-items: center; padding: 6px 0 16px; }
  .tilt {
    width: min(300px, 100%); padding: 18px; border-radius: 16px; box-sizing: border-box;
    background: #fff; box-shadow: 0 12px 30px rgba(15, 23, 42, .15);
    transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
    transition: transform .4s ease;
    position: relative; overflow: hidden; /* fine here: the card has no 3D children */
  }
  .tilt.moving { transition: transform .08s linear; }
  .tilt .img {
    height: 110px; border-radius: 10px;
    background: radial-gradient(circle at 30% 35%, #fde68a 0 18%, transparent 19%),
                linear-gradient(135deg, #0ea5e9, #6366f1);
  }
  .tilt .shine {
    position: absolute; inset: 0; pointer-events: none;
    background: radial-gradient(circle at var(--mx, 50%) var(--my, 50%), rgba(255,255,255,.45), transparent 55%);
    opacity: 0; transition: opacity .3s;
  }
  .tilt.moving .shine { opacity: 1; }
  .tilt b { display: block; margin-top: 12px; font-size: 17px; }
  .tilt span { color: #6b7280; font-size: 14px; }

  /* ---- 2. Flip cards ---- */
  .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
  .flip {
    perspective: 700px; height: 130px;
    border: 0; padding: 0; background: none; font: inherit; cursor: pointer;
  }
  .flip .inner {
    display: block;
    position: relative; height: 100%;
    transform-style: preserve-3d;   /* both faces stay in 3D */
    transition: transform .6s;
  }
  .flip.flipped .inner { transform: rotateY(180deg); }
  .flip .side {
    position: absolute; inset: 0; border-radius: 12px;
    display: grid; place-items: center; padding: 8px; box-sizing: border-box;
    backface-visibility: hidden;    /* hide the face pointing away */
    color: #fff; font-weight: 700; font-size: 15px;
  }
  .flip .front { background: linear-gradient(135deg, #2563eb, #7c3aed); }
  .flip .back  { background: #0f5132; transform: rotateY(180deg); font-weight: 500; font-size: 13px; }
  .flip:focus-visible { outline: 3px solid #f59e0b; outline-offset: 3px; border-radius: 12px; }

  /* people who ask for less motion get instant changes */
  @media (prefers-reduced-motion: reduce) {
    .flip .inner, .tilt { transition: none; }
  }
</style>
</head>
<body>
<h2>Tilt card (mouse or pen)</h2>
<div class="tilt-wrap">
  <div class="tilt" id="tilt">
    <div class="img"></div>
    <b>Trail Camera X2</b>
    <span>Weatherproof, 4 month battery</span>
    <div class="shine"></div>
  </div>
</div>

<h2>Flip cards (click, tap or Enter)</h2>
<div class="grid">
  <button class="flip" aria-pressed="false"><span class="inner"><span class="side front">Plan A</span><span class="side back">1 user, 5 pages</span></span></button>
  <button class="flip" aria-pressed="false"><span class="inner"><span class="side front">Plan B</span><span class="side back">5 users, 50 pages</span></span></button>
  <button class="flip" aria-pressed="false"><span class="inner"><span class="side front">Plan C</span><span class="side back">Unlimited pages</span></span></button>
</div>

<script>
  // ---- Tilt ----
  const tilt = document.getElementById('tilt');
  const wrap = tilt.parentElement; // listen on the flat wrapper, not the moving card
  const lessMotion = matchMedia('(prefers-reduced-motion: reduce)');
  const MAX = 12; // degrees

  wrap.addEventListener('pointermove', (e) => {
    if (e.pointerType === 'touch' || lessMotion.matches) return; // fingers scroll; reduced motion stays flat
    // measure the card's untilted box: its tilted rect changes every frame
    const w = wrap.getBoundingClientRect();
    const x = (e.clientX - w.left - tilt.offsetLeft) / tilt.offsetWidth;  // 0 left, 1 right
    const y = (e.clientY - w.top - tilt.offsetTop) / tilt.offsetHeight;   // 0 top, 1 bottom
    if (x < 0 || x > 1 || y < 0 || y > 1) return reset();
    tilt.style.setProperty('--ry', ((x - 0.5) * 2 * MAX).toFixed(1) + 'deg');
    tilt.style.setProperty('--rx', ((0.5 - y) * 2 * MAX).toFixed(1) + 'deg');
    tilt.style.setProperty('--mx', (x * 100) + '%');
    tilt.style.setProperty('--my', (y * 100) + '%');
    tilt.classList.add('moving');
  });
  function reset() {
    tilt.classList.remove('moving');
    tilt.style.setProperty('--rx', '0deg');
    tilt.style.setProperty('--ry', '0deg');
  }
  wrap.addEventListener('pointerleave', reset);
  wrap.addEventListener('pointercancel', reset);

  // ---- Flip ----
  document.querySelectorAll('.flip').forEach((btn) => {
    btn.addEventListener('click', () => {
      const on = btn.classList.toggle('flipped');
      btn.setAttribute('aria-pressed', on);
    });
  });
</script>
</body>
</html>
The tilt follows a mouse or pen, not a finger. With reduced motion turned on, the card stays flat and the flips are instant.
  1. Measure a flat box. The listener sits on the wrapper, and the position comes from the card's untransformed offsetLeft and offsetWidth. The tilted card's own rectangle changes on every frame, which makes the tilt twitch.
  2. Ignore touch. A finger on a phone is usually scrolling. The handler returns early when pointerType is touch, and pointercancel resets the card.
  3. Respect reduced motion. matchMedia('(prefers-reduced-motion: reduce)') skips the tilt, and a media query removes the flip transition, so the answer still appears.
  4. Use buttons. Each flip card is a <button> with aria-pressed, so keyboards and screen readers can flip it too.

When it does not work

What you see Cause Fix
rotateY only makes the box narrower No perspective, or it is on the rotated element itself Put perspective on the parent
perspective() has no effect It comes after the rotation Write perspective() first
Cube faces collapse into lines Missing transform-style: preserve-3d Add it to the element that holds the faces
preserve-3d is set and faces are still flat overflow, opacity, filter or clip-path on that element Move the effect to a wrapper or to the faces
Mirrored back face shows through Faces keep their backs visible backface-visibility: hidden on each face
Flip card shows the wrong side Back face lacks its own rotateY(180deg) Turn the back face 180 degrees from the start
Tilt jumps or sticks on a phone Touch scroll sends moves, then cancels Skip pointerType touch, reset on pointercancel
Tilt twitches near the edges Measuring the tilted rectangle Measure the untilted box or its wrapper
Text looks soft while it turns The browser may draw the moving layer as an image Let the card come back to no transform at rest

3D is hard to judge from a screenshot. The depth only shows when something turns, and a still image of a cube is just a few shapes. An .html attachment may open as plain code on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can drag the cube and flip the cards themselves. If you change the code later, the same link shows the new version.

Questions people ask

What is the difference between perspective and perspective() in CSS?

The perspective property goes on a parent and gives all its children one shared viewer, so children at different positions look slightly different. The perspective() function goes inside an element's own transform and gives that element its own viewer. For a single element the two look the same.

Does a smaller perspective value mean more 3D?

Yes. The value is the distance between the viewer and the screen. A small value such as 150px puts the viewer very close, so near parts grow a lot and far parts shrink a lot. A large value such as 3000px looks almost flat.

Why are my nested 3D elements flat?

The parent that holds them needs transform-style: preserve-3d. Even with it, overflow other than visible, opacity below 1, a filter, clip-path, a mask, mix-blend-mode or isolation: isolate on that parent force it back to flat.

How do I stop the back of a flip card showing through?

Put backface-visibility: hidden on both faces and give the back face its own rotateY(180deg). The flipping wrapper also needs transform-style: preserve-3d, or the faces are flattened before the browser can tell which way each one points.

Does z-index work inside a 3D scene?

Inside a preserve-3d parent, the element nearer the viewer along the z axis is drawn in front, whatever the z-index says. Move things forward with translateZ instead. Outside 3D, z-index works as usual.

Keep reading