CSS in 3D: Learning to Think in Cubes Instead of Boxes
My path to learning CSS was a little unorthodox. I didn’t start as a front-end developer. I was a Java developer. In fact, my earliest recollections of CSS were picking colors for things in Visual Studio.
It wasn’t until later that I got to tackle and find my love for the front end. And exploring CSS came later. When it did, it was around the time CSS3 was taking off. 3D and animation were the cool kids on the block. They almost shaped my learning of CSS. They drew me in and shaped (pun intended) my understanding of CSS more than other things, like layout, color, etc.
What I’m getting at is I’ve been doing the whole 3D CSS thing a minute. And as with anything you spend a lot of time with, you end up refining your process over the years as you hone that skill. This article is a look at how I’m currently approaching 3D CSS and goes over some tips and tricks that might help you!
Everything’s a cuboid
For most things, we can use a cuboid. We can create more complex shapes, for sure but they usually take a little more consideration. Curves are particularly hard and there are some tricks for handling them (but more on that later).
We aren’t going to walk through how to make a cuboid in CSS. We can reference Ana Tudor’s post for that, or check out this screencast of me making one:
At its core, we use one element to wrap our cuboid and then transform six elements within. Each element acts as a side to our cuboid. It’s important that we apply transform-style: preserve-3d
. And it’s not a bad idea to apply it everywhere. It’s likely we’ll deal with nested cuboids when things get more complex. Trying to debug a missing transform-style
while hopping between browsers can be painful.
* { transform-style: preserve-3d; }
For your 3D creations that are more than a few faces, try and imagine the whole scene built from cuboids. For a real example, consider this demo of a 3D book. It’s four cuboids. One for each cover, one for the spine, and one for the pages. The use of background-image
does the rest for us.
Setting a scene
We’re going to use cuboids like LEGO pieces. But, we can make our lives a little easier by setting a scene and creating a plane. That plane is where our creation will sit and makes it easier for us to rotate and move the whole creation.
For me, when I create a scene, I like to rotate it on the X and Y axis first. Then I lay it flat with rotateX(90deg)
. That way, when I want to add a new cuboid to the scene, I add it inside the plane element. Another thing I will do here is to set position: absolute
on all cuboids.
.plane {
transform: rotateX(calc(var(--rotate-x, -24) * 1deg)) rotateY(calc(var(--rotate-y, -24) * 1deg)) rotateX(90deg) translate3d(0, 0, 0);
}
Start with a boilerplate
Creating cuboids of various sizes and across a plane makes for a lot of repetition for each creation. For this reason, I use Pug to create my cuboids via a mixin. If you’re not familiar with Pug, I wrote a 5-minute intro.
A typical scene looks like this:
//- Front
//- Back
//- Right
//- Left
//- Top
//- Bottom
mixin cuboid(className)
.cuboid(class=className)
- let s = 0
while s < 6
.cuboid__side
- s++
.scene
//- Plane that all the 3D stuff sits on
.plane
+cuboid('first-cuboid')
As for the CSS. My cuboid class is currently looking like this:
.cuboid {
// Defaults
--width: 15;
--height: 10;
--depth: 4;
height: calc(var(--depth) * 1vmin);
width: calc(var(--width) * 1vmin);
transform-style: preserve-3d;
position: absolute;
font-size: 1rem;
transform: translate3d(0, 0, 5vmin);
}
.cuboid > div:nth-of-type(1) {
height: calc(var(--height) * 1vmin);
width: 100%;
transform-origin: 50% 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotateX(-90deg) translate3d(0, 0, calc((var(--depth) / 2) * 1vmin));
}
.cuboid > div:nth-of-type(2) {
height: calc(var(--height) * 1vmin);
width: 100%;
transform-origin: 50% 50%;
transform: translate(-50%, -50%) rotateX(-90deg) rotateY(180deg) translate3d(0, 0, calc((var(--depth) / 2) * 1vmin));
position: absolute;
top: 50%;
left: 50%;
}
.cuboid > div:nth-of-type(3) {
height: calc(var(--height) * 1vmin);
width: calc(var(--depth) * 1vmin);
transform: translate(-50%, -50%) rotateX(-90deg) rotateY(90deg) translate3d(0, 0, calc((var(--width) / 2) * 1vmin));
position: absolute;
top: 50%;
left: 50%;
}
.cuboid > div:nth-of-type(4) {
height: calc(var(--height) * 1vmin);
width: calc(var(--depth) * 1vmin);
transform: translate(-50%, -50%) rotateX(-90deg) rotateY(-90deg) translate3d(0, 0, calc((var(--width) / 2) * 1vmin));
position: absolute;
top: 50%;
left: 50%;
}
.cuboid > div:nth-of-type(5) {
height: calc(var(--depth) * 1vmin);
width: calc(var(--width) * 1vmin);
transform: translate(-50%, -50%) translate3d(0, 0, calc((var(--height) / 2) * 1vmin));
position: absolute;
top: 50%;
left: 50%;
}
.cuboid > div:nth-of-type(6) {
height: calc(var(--depth) * 1vmin);
width: calc(var(--width) * 1vmin);
transform: translate(-50%, -50%) translate3d(0, 0, calc((var(--height) / 2) * -1vmin)) rotateX(180deg);
position: absolute;
top: 50%;
left: 50%;
}
Which, by default, gives me something like this:
Powered by CSS variables
You may have noticed a fair few CSS variables (aka custom properties) in there. This is a big time-saver. I’m powering my cuboids with CSS variables.
--width
: The width of a cuboid on the plane--height
: The height of a cuboid on the plane--depth
: The depth of a cuboid on the plane--x
: The X position on the plane--y
: The Y position on the plane
I use vmin
mostly as my sizing unit to keep everything responsive. If I’m creating something to scale, I might create a responsive unit. We mentioned this technique in a previous article. Again, I lay the plane down flat. Now I can refer to my cuboids as having height, width, and depth. This demo shows how we can move a cuboid around the plane changing its dimensions.
Debugging with dat.GUI
You might have noticed that little panel in the top right for some of the demos we’ve covered. That’s dat.GUI. It’s a lightweight controller library for JavaScript that super useful for debugging 3D CSS. With not much code, we can set up a panel that allows us to change CSS variables at runtime. One thing I like to do is use the panel to rotate the plane on the X and Y-axis. That way, it’s possible to see how things are lining up or work on a part that you might not see at first.
const {
dat: { GUI },
} = window
const CONTROLLER = new GUI()
const CONFIG = {
'cuboid-height': 10,
'cuboid-width': 10,
'cuboid-depth': 10,
x: 5,
y: 5,
z: 5,
'rotate-cuboid-x': 0,
'rotate-cuboid-y': 0,
'rotate-cuboid-z': 0,
}
const UPDATE = () => {
Object.entries(CONFIG).forEach(([key, value]) => {
document.documentElement.style.setProperty(`--${key}`, value)
})
}
const CUBOID_FOLDER = CONTROLLER.addFolder('Cuboid')
CUBOID_FOLDER.add(CONFIG, 'cuboid-height', 1, 20, 0.1)
.name('Height (vmin)')
.onChange(UPDATE)
CUBOID_FOLDER.add(CONFIG, 'cuboid-width', 1, 20, 0.1)
.name('Width (vmin)')
.onChange(UPDATE)
CUBOID_FOLDER.add(CONFIG, 'cuboid-depth', 1, 20, 0.1)
.name('Depth (vmin)')
.onChange(UPDATE)
// You have a choice at this point. Use x||y on the plane
// Or, use standard transform with vmin.
CUBOID_FOLDER.add(CONFIG, 'x', 0, 40, 0.1)
.name('X (vmin)')
.onChange(UPDATE)
CUBOID_FOLDER.add(CONFIG, 'y', 0, 40, 0.1)
.name('Y (vmin)')
.onChange(UPDATE)
CUBOID_FOLDER.add(CONFIG, 'z', -25, 25, 0.1)
.name('Z (vmin)')
.onChange(UPDATE)
CUBOID_FOLDER.add(CONFIG, 'rotate-cuboid-x', 0, 360, 1)
.name('Rotate X (deg)')
.onChange(UPDATE)
CUBOID_FOLDER.add(CONFIG, 'rotate-cuboid-y', 0, 360, 1)
.name('Rotate Y (deg)')
.onChange(UPDATE)
CUBOID_FOLDER.add(CONFIG, 'rotate-cuboid-z', 0, 360, 1)
.name('Rotate Z (deg)')
.onChange(UPDATE)
UPDATE()
If you watch the timelapse video in this tweet. You’ll notice that I rotate the plane a lot as I build up the scene.
That dat.GUI code is a little repetitive. We can create functions that will take a configuration and generate the controller. It takes a little tinkering to cater to your needs. I started playing with dynamically generated controllers in this demo.
Centering
You may have noticed that by default each cuboid is half under and half above the plane. That’s intentional. It’s also something I only recently started to do. Why? Because we want to use the containing element of our cuboids as the center of the cuboid. This makes animation easier. Especially, if we’re considering rotating around the Z-axis. I found this out when creating “CSS is Cake”. After making the cake, I then decided I wanted each slice to be interactive. I then had to go back and change my implementation to fix the rotation center of the flipping slice.
Here I’ve broken that demo down to show the centers and how having an offset center would affect the demo.
Positioning
If we are working with a scene that’s more complex, we may split it up into different sections. This is where the concept of sub-planes comes in handy. Consider this demo where I’ve recreated my personal workspace.
There’s quite a bit going on here and it’s hard to keep track of all the cuboids. For that, we can introduce sub-planes. Let’s break down that demo. The chair has its own sub-plane. This makes it easier to move it around the scene and rotate it — among other things — without affecting anything else. In fact, we can even spin the top without moving the feet!
Aesthetics
Once we’ve got a structure, it’s time to work on the aesthetics. This all depends on what you’re making. But you can get some quick wins from using certain techniques. I tend to start by making things “ugly” then go back and make CSS variables for all the colors and apply them. Three shades for a certain thing allows us to differentiate the sides of a cuboid visually. Consider this toaster example. Three shades cover the sides of the toaster:
Our Pug mixin from earlier allows us to define class names for a cuboid. Applying color to a side usually looks something like this:
/* The front face uses a linear-gradient to apply the shimmer effect */
.toaster__body > div:nth-of-type(1) {
background: linear-gradient(120deg, transparent 10%, var(--shine) 10% 20%, transparent 20% 25%, var(--shine) 25% 30%, transparent 30%), var(--shade-one);
}
.toaster__body > div:nth-of-type(2) {
background: var(--shade-one);
}
.toaster__body > div:nth-of-type(3),
.toaster__body > div:nth-of-type(4) {
background: var(--shade-three);
}
.toaster__body > div:nth-of-type(5),
.toaster__body > div:nth-of-type(6) {
background: var(--shade-two);
}
It’s a little tricky to include extra elements with our Pug mixin. But let’s not forget, every side to our cuboid offers two pseudo-elements. We can use these for various details. For example, the toaster slot and the slot for the handle on the side are pseudo-elements.
Another trick is to use background-image
for adding details. For example, consider the 3D workspace. We can use background layers to create shading. We can use actual images to create textured surfaces. The flooring and the rug are a repeating background-image
. In fact, using a pseudo-element for textures is great because then we can transform them if needed, like rotating a tiled image. I’ve also found that I get flickering in some cases working directly with a cuboid side.
One issue with using an image for texture is how we create different shades. We need shades to differentiate the different sides. That’s where the filter
property can help. Applying a brightness``()
filter to the different sides of a cuboid can lighten or darken them. Consider this CSS flipping table. All the surfaces are using a texture image. But to differentiate the sides, brightness filters are applied.