3D CSS and custom properties

The challenge

Some time ago I saw one of those advertisements that seem to rotate on the sides of a 3D cube. Nothing special, but I became curious how that was done and decided to do some research.

One of the first pages that comes up when Googling "3d cube CSS" is by David DeSandro. Here, he shows how to build a cube in 3D by just using CSS. He explains it well, and even generalizes it to a box (i.e. a "cube" with inequal sides). I hate using things that I don't understand so I set out to reproduce his html and CSS, and throw CSS custom properties (which I prefer to call variables) into the mix. This is what I learned.

In a nutshell: 3D in CSS is powerful, but limited. It's not as versatile as a canvas, but a lot easier to use - if you understand what you're doing. And a lot of functionality is built into CSS. For example, it's really easy to display images on the sides of the cube (remember the ad?) or even iframes - projecting whole web pages onto the sides of the cube. And through animations (also built-in for CSS), you can set up movement of the cube as a whole. But there are a few caveats.

Phase 1: building the cube

Basically, you set up six div elements, one for each side. You rotate and translate those until they form the cube. David explains that really well. For example, the cube he builds has a front face that sorts of floats a little above the page (towards the viewer). The axes of HTML/CSS are:

  • X - left to right (left is minus, right is plus). Positive rotation rotates the front face upwards
  • Y - top to bottom (top is minus (!), bottom is plus). Positive rotation rotates the front face towards the right
  • Z - back to front. Front is towards the viewer and is positive. Positive rotation rotates the front face clockwise (around its own center)

If the sides of the cube have length S, the front face is made by setting up a square S by S div, which stretches from (0,0) to (S, S). Using CSS variables we can have all faces (which we'll give a class name of "face") have the same size (and make sure they "stack" in their container by making their position absolute). It turns out y9ou can actually do that by using a CSS variable:

.face {
  position: absolute;
  width: var(--S);
  height: var(--S);
}

The "var" syntax is used to access the value of the variable, defined somewhere as:

... {
   --S: 150px;
}

(I don't like the -- prefix - it looks too much like something with a minus sign, or even the decrement operator, but it is what it is).

We're trying to have half the cube stick out of the screen, so the other half is behind the screen - in other words: the center of the cube is at Z = 0. To move the front face in place, we have to translate it over +S/2 in the Z direction. This conveniently shows how to calculate values with variables:

.face.front {
   transform: translateZ(calc(var(--S) / 2));
}

calc(var(--S) / 2) is "the value of variable S divided by 2", where S is the length of the sides of the cube.

Likewise, the back face has to be translated by +S/2 in the Z direction, after being rotated 180 degrees along the Y axis:

.face.back {
  transform: rotateY(180deg) translateZ(calc(var(--S) / 2));
}

Why the rotateY, you ask? Well, the faces of the cube hae a back and a front themselves. The "front" should point away from the center of the cube. If we just move the back face behind the screen (-S/2 in the Z direction), its front will point to the center. That's why we rotate it along the vertical axis (which is the Y axis) to basically flip it around. One difficult thing here is, that after the rotation, the axes for the face have changed! The Z axis is now also 'flipped', so we have to do the same translation over +S/2 (and not -S/2!) in the Z direction.

So how about the left and right faces then? They should both be rotated 90 degrees along that same vertical axis: the left face over -90 and the right over +90 degrees. After this, we have to pull the left face towards the left and the right face towards the right. For the left face, which was turned to the left, the Z axis now runs left to right. To move it left, we have to pull it out of the screen again, which is also positive Z. The right face has to undergo the same translation, because by turning it left, the Z axis is turned to left to right as well, but in the other direction. Anyway, this becomes:

.face.left {
  transform: rotateY(-90deg) translateZ(calc(var(--S) / 2));
}
.face.right {
  transform: rotateY(90deg) translateZ(calc(var(--S) / 2));
}

Top and bottom are similar, of course, only they rotate along the X axis. Translation in the Z direction is still +S/2:

.face.top {
  transform: rotateX(90deg) translateZ(calc(var(--S) / 2));
}
.face.bottom {
  transform: rotateX(-90deg) translateZ(calc(var(--S) / 2));
}

So with the CSS so far, we can make a cube like this:

<div class="cube">
  <div class="face front">front</div>
  <div class="face back">back</div>
  <div class="face left">left</div>
  <div class="face right">right</div>
  <div class="face top">top</div>
  <div class="face bottom">bottom</div>
</div>

Making it 3D

But not quite yet! To make the cube appear in real 3D, we need another CSS rule:

transform-style: preserve-3d;

This one is essential - without it, there's no 3D, only flat CSS! In practice, it means that there's no Z translation. But where should we apply this rule? The answer (which I don't really understand yet) is: on the div containing the faces, i.e. the one with the "cube" class. So we need:

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

See this JSFiddle for the results so far. To distinguish the various faces, they've been transparently colored and given some styling for the text on each face (a line-height to take care of vertical centering and a text-align for horizontal centering). Again using CSS variables we set the font size to 1/4 of the side itself. This is how that looks:

The "cube" div has a black border so you can see has been rotated. Also, this is where the Z plane (the screen, as it were) is intersecting with the cube.

Pretty good so far! But not quite good enough. There's something wrong with the cube - it looks slightly off.

Adding perspective

To make the picture convincing, we need to add perspective. There's a CSS rule for that:

perspective: 800px;

This is the distance from the viewer to the page. Big values (e.g. 20000px) mean we're far away and there's almost no perspective; small values (e.g. 50px) move us really up close and make for a sort of fish-eye effect.

When we add the perspective-rule to the body, we get this result:

Much more convincing! (Why the perspective rule can't be added to the .cube-div itself is a mystery to me. It has a completely different effect than intended. More understanding needed)

Adding more variables

Now it would be nice if we could rotate the cube to view it from all angles. To make that easier, we set up a few more variables:

--rotX: 30deg;
--rotY: 30deg;
--rotZ: 0deg;

and we change the transform in div.cube to

.cube {
  transform: rotateX(var(--rotX)) rotateY(var(--rotY)) rotateZ(var(--rotZ));
}

Now we need a way to change those variables. You can see how here:

In a nutshell, the --rotX/Y/Z variables are set from the onchange handlers of the sliders. There is some juggling in Javascript to get and set custom properties, but that's basically it. (The part I really would never have figured out without googling is the call to getComputedstyle).

Bonus: Animation

Since this is "just" css, we can animate the cube using normal animation rules - see the last lines of the CSS in this fiddle:

Phase 2: fitting within a container

If you look carefully when the cube is in its default position, you'll see that the front face is bigger than the "base plane" (with the black border around it). That means that the cube will actually extend outside any box that you might define. The solution is to move the whole cube back, away from the viewer, by S/2 (so translate along the Z axis by -S/2). This puts the front face flush with the page: the cube it glued to the back of your screen as it were. (David also points out that the front face is now less blurry since it's exactly "on the screen" instead of just in front of it)

To do that, we need yet another surrounding div, the stage, with these rules:

.stage {
  transform: translateZ(calc(var(--S) / -2));
  transform-style: preserve-3d;
}

To show that it all works, we create the final surrounding container:

.container {
  /* Set the size of the sides of the cube here */
  --S: 150px;
  /* Set perspective */
  perspective: 800px;
  width: var(--S);
  height: var(--S);
  margin: 50px auto 0 auto;
  outline: solid 1px red;
}

The outline and margin are just to show the location of the container. We set the size to S too, to demonstrate that the front face of the cube now fits within the container. The perspective style is necessary here, too (why?) to keep the perspective really 3d.

Here's how that looks:

This makes the html a little more complex, but not overly so. See the HTML tab in the fiddle above.

And now for an ad for MOBZystems, Home of Tools:

Phase 3: cube to box

Since we're making everything configurable using variables, I wanted to see if I could parametrize the cube into a box. That turned out to be a little complex, though not particularly difficult. If we define some variables --sideX, --sideY and --sideZ, we can make the six faces like this:

/* Every face: */
.face { position: absolute; }

/* Define a size for the pairs of faces: */

/* "X-faces": left and right */
.faceX { width: var(--sideZ); height: var(--sideY); line-height: var(--sideY); }
/* "Y-faces": top and bottom */
.faceY { width: var(--sideX); height: var(--sideZ); line-height: var(--sideZ); }
/* "Z-faces": front and back */
.faceZ { width: var(--sideX); height: var(--sideY); line-height: var(--sideY); }

/* And rotation/translation for each individual face: */
.face.front { transform: rotateY(0deg) translateZ(calc(var(--sideZ) / 2)); }
.face.back { transform: rotateY(180deg) translateZ(calc(var(--sideZ) / 2)); }
.face.left { transform: rotateY(-90deg) translateZ(calc(var(--sideZ) / 2)); }
.face.right { transform: rotateY(90deg) translateZ(calc(var(--sideX) - var(--sideZ) / 2)); }
.face.top { transform: rotateX(90deg) translateZ(calc(var(--sideZ) / 2)); }
.face.bottom { transform: rotateX(-90deg) translateZ(calc(var(--sideY) - var(--sideZ) / 2)); }

The line-height is meant to center text on the face vertically.

The HTML then becomes:

 <div class="cube">
<div class="face faceZ front">front</div>
<div class="face faceZ back">back</div>
<div class="face faceX left">left</div>
<div class="face faceX right">right</div>
<div class="face faceY top">top</div>
<div class="face faceY bottom">bottom</div>
</div>

Multiple boxes on the same page!

One thing we have glossed over so far is: where do we define the variables? In the first few examples, we set them on the body; in the last, on the container. It turns out that CSS variables also have a scope, and that their values can be inherited - this is CSS, after all. So that means we can have two distinct boxes on the same page with different values for the parameters. First, we define two divs, imaginatively called box1 and box2, and place a "cube" inside them:

<div id="box1">
<div class="container">
<div class="stage">
<div class="cube">
<div class="face faceZ front">front</div>
<div class="face faceZ back">back</div>
<div class="face faceX left">left</div>
<div class="face faceX right">right</div>
<div class="face faceY top">top</div>
<div class="face faceY bottom">bottom</div>
</div>
</div>
</div>
</div>
<div id="box2">
<!-- same contents as #box1 -->
</div>

Now we can assign the two boxes their own set of variables:

/* Set the sides of the first box */
#box1 { --sideX: 400px; --sideY: 300px; --sideZ: 200px; }
/* Set the sides of the second box */
#box2 { --sideX: 200px; --sideY: 300px;--sideZ: 400px; }

The rest of the CSS stays the same, because all other style rules depend on the variables. I think that's neat.

Of course you can change styles on the individual boxes using CSS:

/* Give them both different animation duractions */
#box1 .cube { animation-duration: 15s; }
#box2 .cube { animation-duration: 8s; }

And if you wanted, you could even set the variables as inline styles - this is CSS!

<div id="box1" style="--sideX: 50px; --sideY: 100px; --sideZ: 75px">

The possibilities are endless.

Putting it all together

Since we're now displaying boxes, not cubes, some of our class names are not really applicable anymore. Long story short: I made a CSS file called 3dbox.css. If you use the following HTML for your boxes:

div class="stage3d">
<div class="plane3d">
<div class="box3d">
<div class="face faceZ front">front</div>
<div class="face faceZ back">back</div>
<div class="face faceX right">right</div>
<div class="face faceX left">left</div>
<div class="face faceY top">top</div>
<div class="face faceY bottom">bottom</div>
</div>
</div>
</div>

you will get a box with sides of the default value of 300px, each with a different color. It's up to you to set --sideX, --sideY and/or --sideZ on the .stage3d class or a containing element. Rotation is built-in: if you set --rotX, --rotY and/or --rotZ the cube will be displayed rotated. In the next fiddle, the only styling used is the font of the text on the faces:

Conclusion

I'm stil not completely satisfied that I now understand 3D in CSS. There has been some trial and error getting the transform-style: preserve-3d style right, and we need too many nested divs for my taste, but that's CSS for you (or for me, at least). But all in all I am really happy with the relatively short resulting CSS file and the flexibility CSS variables offer.

I had fun and learned a lot. I hope that goes for you, too, when you read this.