To learn more how CSS works in depth, I've always explored different ways to use it beyond its original purpose. For example, I created a pure-CSS calculator that actually works.
People make amazing things by pushing CSS beyond its limits, like single-element drawings in A Single Div or oil paintings like Pure CSS Francine:
I've made experiments where I have multiple objects generated from one single HTML element, but this time, I wanted to see if I could render a 3D cube from one DOM element.
Here's the finished experiment:
Definition of a cube
If you look at any box, you can never ever see more than 3 faces at the same time.
Not only that. There are some situations depending on the perspective of the observer in which only 2 faces can be seen. There are even cases in which the observer can only see one face if they're positioned directly at the front of the box.
This means that opposite sides of a cube will virtually never exist at the same time for the observer.
Building a cube
With up to 3 objects to render, I could use CSS pseudo-elements along with the original element to create one face per axis (x,y,z). Then it was a matter of mirror them as the observer's perspective changes.
For this example, I used a <b>
element (for "box") that serves as the container for all the cube faces.
<b></b>
I used an element with a short name and kept it clean without class names or attributes because I wanted it to have the smallest footprint as possible. That way if I made a composition with thousands of elements I could concatenate and render them as a whole string as fast and small as possible.
Building a scene
To manipulate the entire composition and not individual cubes, such as changes in rotation and scaling, I defined a wrapper element. This element was useful for triggering the swap on all the cubes faces as the perspective changed in the composition.
<section class="scene">
<b></b>
</section>
To make it easier to work with, I setup the entire scene element from the beginning. I chose an isometric projection to work with the 3 sides at the same time.
In an isometric projection the 3 coordinate axes appear equally foreshortened:
.scene {
transform-style: preserve-3d;
transform: rotateX(70deg) rotateZ(45deg);
}
I set a variable with the size of the cube and, only for this example, the scene had the same size as one cube:
:root {
--cube-size: 200px;
}
.scene {
width: var(--cube-size);
height: var(--cube-size);
}
Creating the faces
All the elements and pseudo-elements had the same base properties such as size, position and 3D preservation. I defined all of those together:
b,
b::before,
b::after {
content: '';
transform-style: preserve-3d;
width: var(--cube-size);
height: var(--cube-size);
position: absolute;
}
Then I positioned the first face in the Z-axis to start giving form to the cube container. As I wanted to have an isometric view, I needed to show the top face of the cube. I positioned the top face as if the cube was resting in the top of the scene element:
b {
background: red;
transform: translateZ(var(--cube-size));
}
By having the scene container and the first face defined, I positioned the other faces relative to it:
Face in the X-axis:
b::before {
background: grey;
transform: rotateX(90deg);
transform-origin: bottom;
}
Face in the Z-axis:
b::after {
background: white;
transform: rotateY(-90deg);
transform-origin: right;
}
Here are the 3 faces properly positioned, from an isometric point of view, rotating on the Z axis:
The isometric example looked great so far, but when the point of view changes in the example above, you can see that the back part of the cube is still missing.
Swapping sides
As I mentioned at the beginning, by definition, two opposite faces can never coexist. So, I needed to place the faces in the opposite side whenever the face is out of the observer's view:
By adding a range-type input to the example I could determine where the observer is placed respect to the scene and therefore which faces I needed to show.
To make it simple, I divided the check for the observer's position to 90 degree chunks:
function calcFaces(z) {
let fX;
let fY;
if (z > 0) {
if (z < 180) {
fX = 'right';
if (z < 90) {
fY = 'front';
} else if (z > 90) {
fY = 'back';
}
} else if (z > 180) {
fX = 'left';
if (z < 270) {
fY = 'back';
} else if (z > 270) {
fY = 'front';
}
} else {
fY = 'back';
}
} else if (z < 0) {
if (z > -180) {
fX = 'left';
if (z > -90) {
fY = 'front';
} else if (z < -90) {
fY = 'back';
}
} else if (z < -180) {
fX = 'right';
if (z > -270) {
fY = 'back';
} else if (z < -270) {
fY = 'front';
}
}
} else {
fY = 'front';
}
return [fX, fY];
}
I'm sure this could be improved with some algorithm, but the whole point of making this is only to prove if I could build a cube with only 1 HTML element.
In order to swap the faces of n
amount of cubes inside the scene, I used and updated the attributes data-face-x
, data-face-y
, and data-face-z
. That way I could update an attribute directly into the HTML element and let CSS pick it up.
const scene = document.querySelector('.scene');
const rangeZ = document.querySelector('.rangeZ');
rangeZ.addEventListener('input', function () {
scene.style.transform = `rotateZ(${this.value}deg) rotateX(70deg)`;
const [ faceX, faceY ] = calcFaces(this.value);
scene.setAttribute('data-face-x', faceX);
scene.setAttribute('data-face-y', faceY);
});
Then I wrote all the possible combinations regarding the point of view, splitting responsibilities from the code I had before. Here is an example for the Y axis:
b::before {
background: grey;
}
[data-face-y="front"] b::before {
transform: rotateX(90deg);
transform-origin: bottom;
}
[data-face-y="back"] b::before {
transform: rotateX(-90deg);
transform-origin: top;
}
The face on the Y axis is only shown when it's on the observer's view:
Optimizations
Now imagine having dozens or maybe thousands of these CSS cubes at the same time to make a composition. It sets the GPU on fire 🔥. Check it out by yourself in this other experiment I made: Crossy Road Chicken in CSS Voxels.
There are some things I can do to make it easier for the browser, though. Let's dive into them.
Hidden elements
As I mentioned before, there are cases in which some faces can be hidden to the observer. In order to improve performance, I can temporarily destroy the pseudo-elements using content: none
to reduce the amount of elements the browser needs to render.
function calcFaces(z) {
// Define default faces as "none" in case Z = 0, 180, etc.
let fX = 'none';
let fY = 'none';
// ...
return [fX, fY];
}
[data-face-y="none"] b::before {
content: none;
}
[data-face-x="none"] b::after {
content: none;
}
Backface visibility
Going one step further, the observer will only be able to see only one side of the faces. There is no need to display both sides of a face at all times. I can get rid of the back faces of the elements and pseudo-elements. In order to tell the browser not to render the backfaces I use backface-visibility: hidden
.
Here you can see the element rendering both faces at all times (first fig.) and the element hidding the back face (second fig.):
Composition
The next step to keep stressing this experiment is to put many of these voxels together. The easiest way to do it is by adding more elements on the X and Y axes.
By defining the scene as a flexbox container and giving it a size, I can arrange all the elements inside.
.scene {
display: flex;
flex-wrap: wrap;
}
To create a map of every positioned cube I use the same approach as the imageData
property of the HTML canvas. It's an array containing the data of the canvas element in the RGB order, with integer values between 0
and 255
.
In this case I can't implement the alpha channel because the transparency can't live together with swapping faces.
Empty elements
To make a composition and to fill every empty space between cubes, I introduced an "empty" element. In case of not needing a cube I can avoid to have the whole definition of a 6-faces elements and pseudo-elements and define the basics to only fill the void.
<b></b>
<em></em>
<em></em>
<b></b>
Climbing the Z-axis
By repeating all the previous steps but moving the elements in another matrix in the Z-axis, I can make entire figures: Crossy Road Chicken in CSS Voxels.
Conclusion
I don't see many use cases where I'd apply everything I've detailed here simultaneously, but I've learned how to use each technique individually and can now apply them whenever needed. So it was worth it in the end.