3D in the browser with plain CSS, from a cube to a product viewer

A browser can show real 3D without a canvas or a library. Six divs, transform-style: preserve-3d and a few lines of pointer code give you a cube you can grab and turn.

You can show 3D in the browser with plain CSS. Put a few elements inside a parent that has perspective, keep them in one 3D space with transform-style: preserve-3d, and turn each one with rotateX(), rotateY() and translateZ(). No canvas, no library, no plugin.

Try it first. Drag the cube with a mouse, or with a finger on a phone.

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; height: 100vh; display: grid; place-items: center;
    font-family: system-ui, sans-serif; background: #f4f5f7;
  }
  .scene {
    width: 260px; height: 260px; display: grid; place-items: center;
    perspective: 700px;              /* the viewer, 700px from the screen */
    cursor: grab; touch-action: none; user-select: none;
  }
  .cube {
    position: relative; width: 120px; height: 120px;
    transform-style: preserve-3d;    /* keep the faces in 3D */
    transform: rotateX(-20deg) rotateY(30deg);
  }
  .face {
    position: absolute; inset: 0; display: grid; place-items: center;
    border-radius: 6px; color: #fff; font-weight: 700;
    border: 2px solid rgba(255, 255, 255, .6);
  }
  /* turn each face toward its side, then push it out by half the size */
  .front  { background: #2563eb; transform: translateZ(60px); }
  .back   { background: #7c3aed; transform: rotateY(180deg) translateZ(60px); }
  .right  { background: #059669; transform: rotateY(90deg) translateZ(60px); }
  .left   { background: #ea580c; transform: rotateY(-90deg) translateZ(60px); }
  .top    { background: #db2777; transform: rotateX(90deg) translateZ(60px); }
  .bottom { background: #475569; transform: rotateX(-90deg) translateZ(60px); }
  p { position: fixed; bottom: 8px; margin: 0; font-size: 13px; color: #555; }
</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>
<p>Drag to rotate</p>

<script>
  const scene = document.getElementById('scene');
  const cube = document.getElementById('cube');
  let rx = -20, ry = 30;   // current angles in degrees
  let lastX = 0, lastY = 0;

  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 += (e.clientX - lastX) * 0.5;   // 2px of drag = 1 degree
    rx -= (e.clientY - lastY) * 0.5;
    rx = Math.max(-90, Math.min(90, rx));  // never tip over the top
    lastX = e.clientX; lastY = e.clientY;
    cube.style.transform = `rotateX(${rx}deg) rotateY(${ry}deg)`;
  });
</script>
</body>
</html>
Six divs and about 20 lines of JavaScript. Drag to turn it; the tilt stops at straight up and straight down.

This page builds up to a small product viewer. For how perspective itself works, and what flattens a 3D scene, see CSS perspective.

Six divs make a cube

A CSS cube is six flat squares. Each one starts in the middle of the cube, turns to face its side, and then moves out along its own z axis by half the cube size.

<div class="scene">
  <div class="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>
.scene { perspective: 700px; }
.cube  { position: relative; width: 120px; height: 120px;
         transform-style: preserve-3d; }
.face  { position: absolute; inset: 0; }
.front  { transform: translateZ(60px); }
.back   { transform: rotateY(180deg) translateZ(60px); }
.right  { transform: rotateY(90deg) translateZ(60px); }
.left   { transform: rotateY(-90deg) translateZ(60px); }
.top    { transform: rotateX(90deg) translateZ(60px); }
.bottom { transform: rotateX(-90deg) translateZ(60px); }

The order inside each face matters. Rotate first, then translateZ, so the face moves out in the direction it now points. Swap them and every face turns in place and then moves toward you, so all six end up crossing each other at the front.

A box that is not a cube

Real products are rarely cubes. A box has a width, a height and a depth, and each pair of faces uses a different pair of those sizes.

Front and back are W x H, the sides D x H, top and bottom W x D. Each moves out by half the size it stands across.
Front and back are W x H, the sides D x H, top and bottom W x D. Each moves out by half the size it stands across.

CSS custom properties keep the numbers in one place. The side faces are narrower than the box, so they are centered on it first:

.box   { --w: 200px; --h: 140px; --d: 80px;
         width: var(--w); height: var(--h); }
.front { width: var(--w); height: var(--h);
         transform: translateZ(calc(var(--d) / 2)); }
.right { width: var(--d); height: var(--h);
         left: calc((var(--w) - var(--d)) / 2);
         transform: rotateY(90deg) translateZ(calc(var(--w) / 2)); }

Change the three numbers and the whole box resizes. The same pattern builds a book, a phone, a room or a shelf.

Drag to rotate: pixels become degrees

Turning the cube with the pointer takes three pointer events, the same ones that move a card in a draggable div. The difference is what the movement changes. Instead of moving the element, each pixel of movement becomes a fraction of a degree.

Only the movement since the last event counts. Half a degree per pixel, and the tilt stays between -90 and 90.
Only the movement since the last event counts. Half a degree per pixel, and the tilt stays between -90 and 90.
let rx = -20, ry = 30, lastX = 0, lastY = 0;

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 += (e.clientX - lastX) * 0.5;
  rx -= (e.clientY - lastY) * 0.5;
  rx = Math.max(-90, Math.min(90, rx));
  lastX = e.clientX; lastY = e.clientY;
  cube.style.transform = `rotateX(${rx}deg) rotateY(${ry}deg)`;
});
  • Listen on the scene, not the cube. The scene is a steady rectangle. The turning cube changes shape under the pointer on every frame.
  • Use the change since the last event. Adding the whole distance from the first press makes the cube speed up the longer you drag.
  • Stop the page from scrolling. touch-action: none on the scene lets a finger turn the cube instead of scrolling. CSS touch-action explains the values.

Rotation order: turntable or tumble

The two angles can be written in either order, and the result feels completely different. Drag either cube below; both get the same two numbers.

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>Rotation order</title>
<style>
  body { margin: 0; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .stage {
    display: flex; justify-content: center; gap: 12px; padding: 14px 8px 4px;
    cursor: grab; touch-action: none; user-select: none;
  }
  .col { width: 170px; text-align: center; }
  .col code { font-size: 12px; background: #e6e9ef; padding: 2px 5px; border-radius: 4px; }
  .col b { display: block; margin: 6px 0 2px; font-size: 14px; }
  .scene { height: 180px; display: grid; place-items: center; perspective: 600px; }
  .cube { position: relative; width: 90px; height: 90px; transform-style: preserve-3d; }
  .face {
    position: absolute; inset: 0; display: grid; place-items: center;
    color: #fff; font: 700 13px system-ui; border: 2px solid rgba(255, 255, 255, .6);
  }
  .front  { background: #2563eb; transform: translateZ(45px); }
  .back   { background: #7c3aed; transform: rotateY(180deg) translateZ(45px); }
  .right  { background: #059669; transform: rotateY(90deg) translateZ(45px); }
  .left   { background: #ea580c; transform: rotateY(-90deg) translateZ(45px); }
  .top    { background: #db2777; transform: rotateX(90deg) translateZ(45px); }
  .bottom { background: #475569; transform: rotateX(-90deg) translateZ(45px); }
  .bar { display: flex; justify-content: center; gap: 8px; flex-wrap: wrap; padding: 6px; font-size: 13px; }
  button { font: inherit; padding: 6px 10px; border-radius: 8px; border: 1px solid #c9ced8; background: #fff; cursor: pointer; }
</style>
</head>
<body>
<div class="stage" id="stage">
  <div class="col">
    <b>Turntable</b><code>rotateX() rotateY()</code>
    <div class="scene"><div class="cube" id="a">
      <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>
  <div class="col">
    <b>Tumble</b><code>rotateY() rotateX()</code>
    <div class="scene"><div class="cube" id="b">
      <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>
</div>
<div class="bar">
  <button id="turn">Turn to the side (Y = 90)</button>
  <button id="reset">Reset</button>
  <span id="out"></span>
</div>

<script>
  const stage = document.getElementById('stage');
  const a = document.getElementById('a'), b = document.getElementById('b');
  const out = document.getElementById('out');
  let rx = -20, ry = 30, lastX = 0, lastY = 0;

  function draw() {
    // same two angles, only the order differs
    a.style.transform = `rotateX(${rx}deg) rotateY(${ry}deg)`;
    b.style.transform = `rotateY(${ry}deg) rotateX(${rx}deg)`;
    out.textContent = `X ${Math.round(rx)}  Y ${Math.round(ry)}`;
  }

  stage.addEventListener('pointerdown', (e) => {
    lastX = e.clientX; lastY = e.clientY;
    stage.setPointerCapture(e.pointerId);
  });
  stage.addEventListener('pointermove', (e) => {
    if (!stage.hasPointerCapture(e.pointerId)) return;
    ry += (e.clientX - lastX) * 0.5;
    rx = Math.max(-90, Math.min(90, rx - (e.clientY - lastY) * 0.5));
    lastX = e.clientX; lastY = e.clientY;
    draw();
  });
  document.getElementById('turn').addEventListener('click', () => { rx = 0; ry = 90; draw(); });
  document.getElementById('reset').addEventListener('click', () => { rx = -20; ry = 30; draw(); });
  draw();
</script>
</body>
</html>
Press "Turn to the side", then drag up and down. The left cube tilts toward you; the right one spins flat like a wheel.
Transform Across Up and down Feels like
rotateX() rotateY() Spins the box on its own vertical axis Tilts the box toward or away from you A product on a turntable
rotateY() rotateX() Spins around the screen's vertical axis Turns around the box's own X axis, wherever that points A ball rolled by hand

For product viewers, write rotateX() first and clamp it. Transforms apply from right to left, so rotateY turns the box on its own axis and rotateX then tilts the result toward you.

The clamp at 90 degrees stops the box from going upside down, where left and right drag would reverse.

A finished product viewer

The viewer puts the pieces together: a box with custom sizes, drag with a short coast after release, buttons for preset views, arrow keys, and a button on the front face.

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 product viewer</title>
<style>
  body { margin: 0; min-height: 100vh; font-family: system-ui, sans-serif; background: linear-gradient(#eef1f6, #dfe4ec); color: #1d2330; }
  .scene {
    --w: 200px; --h: 140px; --d: 80px;       /* box width, height, depth */
    height: 300px; display: grid; place-items: center; position: relative;
    perspective: 900px; cursor: grab; touch-action: none; user-select: none; outline: none;
  }
  .scene:focus-visible { box-shadow: inset 0 0 0 3px #2563eb; }
  .shadow {
    position: absolute; bottom: 38px; width: 220px; height: 26px; border-radius: 50%;
    background: radial-gradient(rgba(0, 0, 0, .28), transparent 70%);
  }
  .box { position: relative; width: var(--w); height: var(--h); transform-style: preserve-3d; }
  .box.animate { transition: transform .6s ease; }
  .face {
    position: absolute; box-sizing: border-box; padding: 10px; color: #fff;
    display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 6px;
  }
  /* front and back: width x height */
  .front, .back { width: var(--w); height: var(--h); }
  .front { background: linear-gradient(135deg, #1e3a8a, #2563eb); transform: translateZ(calc(var(--d) / 2)); }
  .back  { background: #1e3a8a; transform: rotateY(180deg) translateZ(calc(var(--d) / 2)); font-size: 12px; }
  /* sides: depth x height, centred on the box */
  .right, .left { width: var(--d); height: var(--h); left: calc((var(--w) - var(--d)) / 2); }
  .right { background: #1d4ed8; transform: rotateY(90deg) translateZ(calc(var(--w) / 2)); }
  .left  { background: #1e40af; transform: rotateY(-90deg) translateZ(calc(var(--w) / 2)); }
  /* top and bottom: width x depth */
  .top, .bottom { width: var(--w); height: var(--d); top: calc((var(--h) - var(--d)) / 2); }
  .top    { background: #3b82f6; transform: rotateX(90deg) translateZ(calc(var(--h) / 2)); }
  .bottom { background: #172554; transform: rotateX(-90deg) translateZ(calc(var(--h) / 2)); }
  .front b { font-size: 20px; letter-spacing: .5px; }
  .side-text { writing-mode: vertical-rl; font-weight: 700; letter-spacing: 2px; }
  .face button { font: 600 13px system-ui; padding: 6px 12px; border: 0; border-radius: 99px; background: #fff; color: #1e3a8a; cursor: pointer; }
  .bar { display: flex; justify-content: center; flex-wrap: wrap; gap: 6px; padding: 4px 8px; }
  .bar button { font: 14px system-ui; padding: 7px 12px; border-radius: 8px; border: 1px solid #c3c9d4; background: #fff; cursor: pointer; }
  #status { text-align: center; font-size: 13px; color: #444; margin: 6px 0 0; min-height: 18px; }
</style>
</head>
<body>
<div class="scene" id="scene" tabindex="0" aria-label="Product box. Drag, or use the arrow keys, to turn it.">
  <div class="shadow"></div>
  <div class="box" id="box">
    <div class="face front">
      <svg width="48" height="40" viewBox="0 0 48 40" aria-hidden="true"><path d="M6 28V22a18 18 0 0 1 36 0v6" fill="none" stroke="#fff" stroke-width="4"/><rect x="2" y="24" width="10" height="14" rx="3" fill="#fff"/><rect x="36" y="24" width="10" height="14" rx="3" fill="#fff"/></svg>
      <b>AURA ONE</b>
      <button id="buy">Add to cart</button>
    </div>
    <div class="face back"><b>In the box</b>Headphones<br>USB-C cable<br>Travel pouch</div>
    <div class="face right"><span class="side-text">AURA ONE</span></div>
    <div class="face left"><span class="side-text">WIRELESS</span></div>
    <div class="face top">Open here</div>
    <div class="face bottom">Made with care</div>
  </div>
</div>
<div class="bar">
  <button data-x="-15" data-y="-25">Front</button>
  <button data-x="0" data-y="-90">Side</button>
  <button data-x="0" data-y="180">Back</button>
  <button data-x="-90" data-y="0">Top</button>
</div>
<p id="status"></p>

<script>
  const scene = document.getElementById('scene');
  const box = document.getElementById('box');
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  let rx = -15, ry = -25, vx = 0, vy = 0, lastX = 0, lastY = 0, spin = 0;

  const draw = () => { box.style.transform = `rotateX(${rx}deg) rotateY(${ry}deg)`; };
  const clampX = (x) => Math.max(-90, Math.min(90, x));

  scene.addEventListener('pointerdown', (e) => {
    if (e.target.closest('button')) return;       // let the face button take the click
    cancelAnimationFrame(spin);
    box.classList.remove('animate');
    lastX = e.clientX; lastY = e.clientY; vx = vy = 0;
    scene.setPointerCapture(e.pointerId);
  });

  scene.addEventListener('pointermove', (e) => {
    if (!scene.hasPointerCapture(e.pointerId)) return;
    vy = (e.clientX - lastX) * 0.5;               // degrees moved this event
    vx = -(e.clientY - lastY) * 0.5;
    ry += vy; rx = clampX(rx + vx);
    lastX = e.clientX; lastY = e.clientY;
    draw();
  });

  // after release, keep turning and slow down (skipped with reduced motion)
  scene.addEventListener('lostpointercapture', () => {
    if (reduce) return;
    const coast = () => {
      vx *= 0.93; vy *= 0.93;
      if (Math.abs(vx) + Math.abs(vy) < 0.05) return;
      ry += vy; rx = clampX(rx + vx); draw();
      spin = requestAnimationFrame(coast);
    };
    spin = requestAnimationFrame(coast);
  });

  // go to a named view by the shortest way round
  function goTo(x, y) {
    cancelAnimationFrame(spin);
    if (!reduce) box.classList.add('animate');
    ry += ((y - ry) % 360 + 540) % 360 - 180;
    rx = x; draw();
  }
  document.querySelectorAll('.bar button').forEach((b) =>
    b.addEventListener('click', () => goTo(+b.dataset.x, +b.dataset.y)));

  scene.addEventListener('keydown', (e) => {
    const step = { ArrowLeft: [0, -15], ArrowRight: [0, 15], ArrowUp: [15, 0], ArrowDown: [-15, 0] }[e.key];
    if (!step) return;
    e.preventDefault();
    goTo(clampX(rx + step[0]), ry + step[1]);
  });

  document.getElementById('buy').addEventListener('click', () => {
    document.getElementById('status').textContent = 'Added to cart. The button is plain HTML on a 3D face.';
  });
  draw();
</script>
</body>
</html>
Drag, flick, or use the view buttons. Tab to the box and use the arrow keys. The Add to cart button is plain HTML on a 3D face.
  1. Coast after release. On lostpointercapture, keep adding the last movement to the angles and multiply it by 0.93 each frame with requestAnimationFrame. Stop when it is tiny.
  2. Preset views with a transition. A class adds transition: transform .6s only while a button animates the box. During a drag the class is removed, or every move would lag.
  3. Take the short way round. Before going to a view, the code adjusts the Y target by whole turns so the box never spins 300 degrees to reach a view 60 degrees away.
  4. Keyboard. The scene has tabindex="0" and turns 15 degrees per arrow key.
  5. Reduced motion. With prefers-reduced-motion: reduce, the box jumps straight to each view and does not coast.

The faces are real elements, so text stays text and buttons stay buttons. The Add to cart button was clicked on the turned face in Chromium and Firefox. These demos were not tested in Safari.

When to move to WebGL

CSS 3D places flat rectangles in space. That covers a lot: boxes, cards, cubes, 3D carousels, the walls of a room. It stops being the right tool when the object is not made of rectangles.

CSS for flat panels made of HTML. WebGL for triangles, model files and light.
CSS for flat panels made of HTML. WebGL for triangles, model files and light.
Need CSS 3D WebGL
Box, card, cube, carousel Yes, a handful of elements Works, but more code
Text, buttons, links on the object Yes, they are normal HTML Drawn as pixels; controls sit outside the canvas
Curved or detailed shapes Only by faking with many small faces Yes, built from triangles
Load a glTF or OBJ model file No Yes, usually through a library
Light, shadow, reflections No light source; color each face by hand Yes, worked out in shaders
Extra download None None for raw WebGL; a library if you use one

A useful test: if you would draw the object's parts as divs anyway, stay with CSS. If you would reach for a 3D modeling program, move to WebGL.

WebGL in HTML starts from a blank canvas, and libraries such as three.js handle models and lights for you.

When it does not work

What you see Cause Fix
All six faces sit on top of each other No preserve-3d on the cube element Add transform-style: preserve-3d
The cube turns but has no depth No perspective on the parent Put perspective: 700px on the scene
Faces all cross at the front of the cube translateZ written before the rotation Rotate first, then translateZ
One side face sits inside the box, the other floats outside Side faces not centered on the box Offset them by half of W minus D
Up and down drag spins the box flat Order is rotateY() rotateX() Write rotateX() first
The box flips upside down X angle not limited Clamp it between -90 and 90
The cube speeds up during a long drag Adding the total distance each move Add only the change since the last event
On a phone, the page scrolls instead The browser treats the finger as a scroll touch-action: none on the scene
Preset views lag behind the pointer The transition is on during drags Add the transition class only for button moves

For flip cards, which use the same pieces with two faces, see CSS flip card.

3D does not survive a screenshot. The depth only shows while the box turns, and a still image of a cube is just three colored 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 grab the box and turn it themselves. If you change the code later, the same link shows the new version.

Questions people ask

Can I make 3D in the browser without WebGL?

Yes, for shapes made of flat panels. CSS 3D transforms place ordinary elements in 3D space, so a cube, a box, a card or a carousel needs only HTML and CSS. Curved models, model files and lighting need WebGL.

Why does my CSS cube look flat?

Usually the element that holds the faces is missing transform-style: preserve-3d, or its parent has no perspective. Properties such as overflow: hidden, opacity below 1 or a filter on the cube element also flatten it.

Can I put buttons and links on the faces of a CSS cube?

Yes. Each face is a normal element, so text, images, buttons and links on it keep working. In the product viewer demo, the Add to cart button on the front face was clicked in Chromium and Firefox at an angle.

Why does the cube spin like a wheel when I drag it up or down?

The rotations are in the order rotateY() rotateX(). Write rotateX() first and rotateY() second, and limit the X angle to between -90 and 90 degrees. The box then turns like a product on a stand.

When should I use three.js or WebGL instead of CSS?

When the object comes from a model file such as glTF, has curved surfaces, needs light and shadow worked out by the renderer, or has more faces than you would want as separate elements. WebGL draws triangles on a canvas, and libraries such as three.js wrap it.

Keep reading