CSS 3D: The Cube - part 2
This is part 2 of a tutorial on CSS cubes. If you haven't, you might want to check out part 1
Now, onto our today's topic: the cube!
More of pretty much the same cube:
It is mostly the same basic setup as last time: six walls, grouped into a cube, inside a scene with a single-point perspective.
<div class="scene">
<div class="cube">
<div class="wall"></div>
<div class="wall"></div>
<div class="wall"></div>
<div class="wall"></div>
<div class="wall"></div>
<div class="wall"></div>
</div>
</div>
body {
margin: 0;
box-sizing: border-box;
background-color: black;
display: grid;
place-items: center;
min-height: 100vh;
font-size: .5vh;
}
.scene {
width: 100em;
aspect-ratio: 1;
perspective: 250em;
position: absolute;
}
.scene * {
transform-style: preserve-3d;
position: absolute;
inset: 0;
}
It does look a little different, it's colored this time - but you would be able to create something similar with what you've learned previously. All the difference lies in how we're going to organize the walls this time.
Grouping into pairs
They won't be identified by their individual placement, like last time:
front
back
left
right
top
bottom
Instead, front and back, left and right, and top and bottom will be grouped into pairs based on which axis of the .scene's absolute XYZ coordinate system they move along to get into position:
horizontal (the X axis) - the left and right wall
vertical (the Y axis) - the top and bottom wall
applicate (the Z axis) - the front and back wall
The order, as you can tell, is different than in Part 1 - but this should feel more organic in this setup. So, let's add the classes to the pairs of .walls. You should end up with:
<div class="scene">
<div class="cube">
<div class="wall horizontal">left</div>
<div class="wall horizontal"></div>
<div class="wall vertical"></div>
<div class="wall vertical"></div>
<div class="wall applicate"></div>
<div class="wall applicate"></div>
</div>
</div>
You can do this manually or copy the code above. Alternatively - if your code editor has the Emmet extension (VS Code and CodePen do) - all you need is to type in:
.scene>.cube>.wall.horizontal*2+.wall.vertical*2+.wall.applicate*2
The stylesheet for everything so far was provided above. All there's left to do is style the pairs of walls. They need some color - one per pair. Since we're dealing with a trio: the X, Y and Z - how about we pair it with another three: the RGB color palette:
.horizontal {
background-color: red;
}
.vertical {
background-color: green;
}
.applicate {
background-color: blue;
}
Now, let's get them into place - starting with the first class. If we were positioning just one wall, we could do this:
.left {
background-color: red;
transform: rotateY(90deg) translateZ(50em);
}
If we did that to a pair, they would both end up in the same place. We need the right one to turn in the opposite direction before moving:
.right {
background-color: red;
transform: rotateY(-90deg) translateZ(50em);
}
The overall procedure is similar, except for the direction of the turn: clockwise or counterclockwise; positive and negative. Just one variable, different for the first and the second element of the pair.
Let's define it, then:
.wall:nth-child(odd) {
--direction: 1;
}
.wall:nth-child(even) {
--direction: -1;
}
This pseudo-class targets containers based on their placement in the sequence within their parent container. Our hypothetical .left is the first .wall, and the hypothetical .right is the second .wall - and we could indeed just put 1 and 2, respectively, in the parentheses. But the way we did does the trick: 1 is an odd number and 2 is an even number. What's more, this covers the remaining pairs: top is 3 and front is 5 - ergo, odd, and bottom and back are 4 and 6, respectively, which makes them even numbers.
Now, let's apply the variable to the formula:
.horizontal {
background-color: red;
transform: rotateY( calc( 90deg*var(--direction) ) ) translateZ(50em);
}
Now, do the same for the vertical:
.vertical {
background-color: green;
transform: rotateX( calc( 90deg*var(--direction) ) ) translateZ(50em);
}
and just so we know which one is which in the pair, let's apply a color inversion to the second one:
.wall:nth-child(even) {
--direction: -1;
filter: invert();
}
Right now you should be seeing something like this:
Notice the .applicate wall that we haven't positioned yet is yellow. It's going to be the back wall, but our CSS doesn't know it yet, it just knows it's the last one of the six walls, so it's stacked on top.
Time to set them in place, too. Here's how we did in Part I:
.front {
transform: translateZ(50em); /* pulls the wall closer to the viewer*/
}
.back {
transform: translateZ(-50em); /* pushes the wall away from the viewer*/
}
So now, we could do a version of this that would apply to both .walls of the .applicate class:
.applicate {
background-color:blue;
transform: translateZ( calc( 90deg*var(--direction) ) );
}
And with that, the walls are in place - the blue one now in the front, as it should. Let's give the whole thing a spin, so we can take a look at it from all sides:
.cube {
animation: spin 6s linear infinite; /* adds a spinning animation */
}
@keyframes spin { /* the details of the animation: */
100% { /* at the end point of the animation */
transform: rotateY(360deg); /* ...the cube will have make a full 360 degree turn */
}
}
Looking good, right?
It does. Technically. It is a correct replica of the structure we established in Part I - with less code repetition. In most cases, it would do the job perfectly. But there's one thing it doesn't take into account: the fact that a container has a front face and a backface. When we translateZ a face 50em in the positive, it's going forward. When it moves in the Z axis by -50em, it is effectively going backward - and the yellow wall that we see as the cube spins is in actuality its backface. Right now it doesn't make a difference - but it might if you happen to need to hide backfaces.
.wall {
backface-visibility: hidden;
}
Play the animation and toggle the property off and on to see what happens to the yellow wall
The back walls are gone - or, more precisely, hidden. All the .wall faces inside the cube appear transparent, as you'd expect - but so does the yellow. We don't like that, do we? For the sake of consistency, we will want all outward-facing walls to be visible, including the back. So what do we do with that back wall to make it face forward when in place?
It'd need to make a 180-degree turn before moving:
.back {
transform: rotateY(180deg) translateZ(50em);
}
The problem is that we don't have a separate class for the .back. It's paired with the front wall - which needs to turn exactly 0 degrees before it moves forward:
.front {
transform: rotateY(0deg) translateZ(50em);
}
In theory, at least - because, again, we don't have a separate .front class. We have a pair of .applicate walls - the first of which needs to turn 0deg, and the latter takes a half-turn of 180. Luckily, there's a variable common to both, each with the opposite value: 1 and -1. We could use that. We need to find a formula that would give us:
0deg for the first of the pair
180deg for the other
We already know that in the previous pairs, the odd one would take the value of 90deg and the even one would turn by -90deg as a result of these calculations - resulting in a difference of 180deg:
.horizontal {
background-color: red;
transform: rotateY( calc( 90deg*var(--direction) ) ) translateZ(50em);
}
.vertical {
background-color: green;
transform: rotateX( calc( 90deg*var(--direction) ) ) translateZ(50em);
}
So, we'd need to offset those values by 90deg before applying the transform, like so:
90 + 90 = 180
90 - 90 = 0
Let's copy the .horizontal's transform and tweak it by adding a 90deg rotation and subtracting the original one from it:
.applicate {
background-color: green;
transform: rotateY( calc( 90deg - 90deg*var(--direction) ) ) translateZ(50em);
}
Finally, all walls are there, facing forward. The top and bottom face, too - even though we can't see them, as the side walls obscure them from our view.
Let's make the walls see-through, just to make sure:
.wall {
opacity: .5;
}
The styling for the .cube and its children (omitting the default .scene CSS setup) presents as follows:
.horizontal {
background-color: red;
transform: rotateY( calc( 90deg*var(--direction) ) ) translateZ(50em);
}
.vertical {
background-color: green;
transform: rotateX( calc( 90deg*var(--direction) ) ) translateZ(50em);
}
.applicate {
background-color:blue;
transform: rotateY( calc( 90deg - 90deg*var(--direction) ) ) translateZ(50em);
}
.wall {
opacity: .5;
}
.wall:nth-child(odd) {
--direction: 1;
}
.wall:nth-child(even) {
--direction: -1;
filter: invert();
}
.cube {
animation: spin 6s linear infinite; /* adds a spinning animation */
}
@keyframes spin { /* the details of the animation: */
100% { /* at the end point of the animation */
transform: rotateY(360deg); /* ...the cube will have make a full 360 degree turn */
}
}
It is shorter than what we ended up with in Part I - but there is still room for improvement. For one, all walls make the same translation of 50em - and this fact is reiterated for each class. What if we applied a general formula to the general .wall class - with a variable for the turn and the default translation of 50em?
.horizontal {
background-color: red;
--turn: rotateY( calc( 90deg*var(--direction) ) );
}
.vertical {
background-color: green;
--turn: rotateX( calc( 90deg*var(--direction) ) );
}
.applicate {
background-color:blue;
--turn: rotateY( calc( 90deg - 90deg*var(--direction) ) );
}
.wall {
opacity: .5;
transform: var(--turn) translateZ(50em);
}
Much nicer, isn't it? What if I told you we can go even further? Notice how both .horizontal and .vertical take the same value:
calc( 90deg*var(--direction) )
in their respective rotation transforms? Also, .horizontal and .applicate use rotation along the same axis. We could specify variables shared by each subset to make use of that.
First, let's specify the variables for rotation values - and replace those values with a variable in the formulas - one for .horizontal and .vertical, and one for .applicate only /* and just comment out the replaced stylings instead of deleting them, for reference */ :
.horizontal, .vertical {
--turnVal: calc( 90deg*var(--direction) );
}
.horizontal {
background-color: red;
/* --turn: rotateY( calc( 90deg*var(--direction) ) );*/
--turn: rotateY( var(--turnVal) );
}
.vertical {
background-color: green;
/* --turn: rotateX( calc( 90deg*var(--direction) ) ); */
--turn: rotateX( var(--turnVal) );
}
.applicate {
background-color:blue;
--turnVal: calc( 90deg - 90deg*var(--direction) );
/* --turn: rotateY( calc( 90deg*var(--direction) ) ); */
--turn: rotateY( var(--turnVal) );
}
.wall {
opacity: .5;
transform: var(--turn) translateZ(50em);
}
Now, let's specify the the variables for rotation axis - and replace those values with a variable in the formulas - one for .horizontal and .applicate, and one for .vertical only:
.horizontal, .vertical {
--turnVal: calc( 90deg*var(--direction) );
}
.horizontal {
background-color: red;
/* --turn: rotateY( var(--turnVal) ); */
--turn: var(--turnAxis);
}
.vertical {
background-color: green;
--turnAxis: rotateX( var(--turnVal) );
/* --turn: rotateX( var(--turnVal) ); */
--turn: var(--turnAxis);
}
.horizontal, .applicate {
--turnAxis: rotateY( var(--turnVal) );
--turn: var(--turnAxis);
}
.applicate {
background-color:blue;
--turnVal: calc( 90deg - 90deg*var(--direction) );
/* --turn: rotateY( var(--turnVal) ); */
--turn: var(--turnAxis);
}
.wall {
opacity: .5;
transform: var(--turn) translateZ(50em);
}
This covers all the bases: the variable --turn has a variable inside it defining its rotation axis, which in turn has a variable for the value.
And it's a gorgeous tangle.
It's a real Gordian knot of a style code: perfectly functional and unbreakable. And oh, so bulky. Let's look at it and make a few strategic cuts. You'll notice that as we replaced the --turn variable for each class, we basically got the same thing for all of them:
--turn: var(--turnAxis);
We could, therefore, cut it from each individual class and paste it just once into the .wall class:
.horizontal {
background-color: red;
/* --turn: var(--turnAxis); */
}
.vertical {
background-color: green;
--turnAxis: rotateX( var(--turnVal) );
/* --turn: var(--turnAxis); */
}
.horizontal, .vertical {
--turnVal: calc( 90deg*var(--direction) );
}
.horizontal, .applicate {
--turnAxis: rotateY( var(--turnVal) );
}
.applicate {
background-color:blue;
--turnVal: calc( 90deg - 90deg*var(--direction) );
/* --turn: var(--turnAxis); */
}
.wall {
--turn: var(--turnAxis);
opacity: .5;
transform: var(--turn) translateZ(50em);
}
Much nicer, no? Though, actually, not that much less code - and all of that changes nothing. Visually the cube itself looks identical to its unoptimized predecessor:
The optimization affects only what happens under the hood: the amount of data the computer needs to fetch and the number of places it needs to get them from.
And you'll hate me - but there's more you could optimize!
OK, not today, maybe. You might've indeed had enough for now.
See you next time?
Subscribe to my newsletter
Read articles from Maciek Fitzner directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by