This seems simple right?
I mean there are loads of 3D CSS cubes using 2 divs, and loads of cubes using one div that are static, so how much harder can it be to do a truly 3D rotating cube in a single div?
Spoilers...a LOT harder!
In fact I asked a few people if they thought it was doable and the answer I got was "impossible", as "CSS just isn't up to it".
Now the thing you have to know is, I like to do "impossible", especially with CSS (as I suck at CSS and it makes me better at it!).
I mean, I have built a CSS only syntax highlighter, did bubble sort in CSS and even built a neural network in CSS.
So surely I can do a CSS only single div 3D cube right?
Well let me tell you, this challenge nearly broke me. I have tried to do this 3 times and failed.
But today, I finally did it.
And I truly believe this may be the single greatest thing I have ever done!
Wanna see it?
Of course you do, it is the only reason you are here right, to see if I am lying?
Well behold the (majesty? chaos?) that is a 3D cube in pure CSS! (be warned, the CSS may break your brain a bit!)
Go on, check out the HTML, JS and CSS tabs!
There is also an interactive demo where you can set the X and Y rotation with sliders at the end of this article
Oh and before you say anything about the JS - that purely sets the rotation position in a way that is cross-browser, we could have used CSS Houdini props to do it without CSS if it weren't for certain annoying browsers.
All it is doing is changing the rotation of the cube using CSS props anyway!
Why was it so hard?
The short answer is with a single div it meant I only had 3 sides to play with (the div itself, and the ::before
and ::after
pseudo elements.
And in case you didn't know, a cube has 6 sides!
This meant we had to do what seems like a simple trick, work out which 3 sides are facing the camera and show them.
However, anyone who has worked in 3D will tell you, working out which side is facing the camera is not simple.
I mean, it isn't too difficult in code, but in CSS? That is where things get messy!
Let me step you through a few challenges I came across!
1. calculating which side to show
This needed some serious math skills.
Luckily many others before me have worked this one out and I just grabbed some JS and converted it to CSS.
Now there is a fair bit to this, but one of the key things is once you do a load of sine, cosine magic you have the X, Y and Z position of a shape in 3D space.
Getting the X, Y and Z coordinates of a rotated face of the cube
That is what this bit does:
/* the "normals" for this side, we repeat these for each side but change the value to reflect it's position in 3D space (so the back is at z = -1, the left is at x = -1 etc.) */
--normals-front-x: 0;
--normals-front-y: 0;
--normals-front-z: 1;
/* the calculations to adjust the 3D rotation back to X, Y, Z coordinates */
--front-y1: calc((var(--normals-front-y) * cos(var(--xRot))) - (var(--normals-front-z) * sin(var(--xRot))));
--front-z1: calc(var(--normals-front-y) * sin(var(--xRot)) + var(--normals-front-z) * cos(var(--xRot)));
--front-x2: calc(var(--normals-front-x) * cos(var(--yRot)) + var(--front-z1) * sin(var(--yRot)));
--front-z2: calc(var(--normals-front-x) * -1 * sin(var(--yRot)) + var(--front-z1) * cos(var(--yRot)));
--front-x3: calc(var(--front-x2) * cos(var(--zRot)) - var(--front-y1) * sin(var(--zRot)));
--front-y3: calc(var(--front-x2) * sin(var(--zRot)) + var(--front-y1) * cos(var(--zRot)));
--front-x: var(--front-x3);
--front-y: var(--front-y3);
--front-z: var(--front-z2);
/* repeated for each side of the cube */
getting the z-axis position or "dot product"
Then once we have these X, Y and Z positions (relative to us in 3D space) we can get their "dot product" to work out the shapes central Z-position relative to the camera direction.
That is what all this is doing:
/* getting the magnitude of the camera position. It is worth noting that because I use camera position of X: 0, Y: 0 and Z: 1 this is not really needed as --normalised-cam-z = 1 and --normalised-cam-x and --normalised-cam-y = 0, however I left it in for completeness */
--magnitude-cam: sqrt(calc((var(--normals-camera-x) * var(--normals-camera-x)) + (var(--normals-camera-y) * var(--normals-camera-y)) + (var(--normals-camera-z) * var(--normals-camera-z))));
--normalised-cam-x: var(--normals-camera-x) / var(--magnitude-cam);
--normalised-cam-y: var(--normals-camera-y) / var(--magnitude-cam);
--normalised-cam-z: var(--normals-camera-z) / var(--magnitude-cam);
/* getting the dot-product of the camera normals and the sides X, Y and Z positions and adding them up. */
--dot-prod-front: calc((var(--normals-camera-x) * var(--front-x)) + (var(--normals-camera-y) * var(--front-y)) + (var(--normals-camera-z) * var(--front-z)));
/* repeated for each side */
This dot product gives us the distance along the camera's view distance, or the distance along the Z axis (depth).
Ok, now we can start working out where sides show up!
That is a lot of setup, but now we have what we need, a way to see which 3 faces are closest to the camera!
That is what this bit is all about:
--show-front: Min(1, Max(calc(var(--dot-prod-front) * 100 - var(--dot-prod-back) * 100), 0));
--show-back: calc(1 - var(--show-front));
--show-right-dot: Min(1, Max(calc(var(--dot-prod-right) * 100 - var(--dot-prod-left) * 100), 0));
--show-left-dot: calc(1 - var(--show-right-dot));
--show-right: calc(var(--show-right-dot) * var(--X-Not-between-90-270) + var(--show-left-dot) * var(--X-between-90-270)); --show-left: calc(1 - var(--show-right));
It may not be immediately obvious what is happening here though, so let's break it down.
We take the z-position of the front and the z-position of the back and compare them.
If the front is closer to us than the back (a greater z position) then we want to show that, if the back is closer to us then we want to show that.
So what is all the min max nonsense?
Well we want to convert decimals into a Boolean.
So what we do is:
- multiply both numbers by 100 (to ensure that their difference is likely to be greater than 1).
- subtract the "front" from the "back" so we either get a positive number or a negative number.
- Get the maximum of the two numbers and 0 (so if front - back > 0 we would get a positive number as it is larger than 0, but if the front - back < 0 we get 0 as that is now larger than the negative number)
- We then get the minimum of the previous output and 1, this is to cap any positive numbers at 1.
We could do the same with clamp(0, front-back, 1)
but for some reason I always end up writing it this way!
We finally have a Boolean!
Phew, that was a lot, but now we have a Boolean value for if the front is closer to the camera than the back and we can then use that in our front/back CSS to move the position of the side:
.cube {
/* other props */
translateZ(calc(
(var(--show-front) - var(--show-back)) * var(--cube-size) * 0.5
));
So we either get (1 - 0) * 100px * 0.5
(50px) if the front should show and (0 - 1) * 100px * 0.5
(-50px) if the back should show.
We then apply this to the z-axis and as if by magic we can move the front and back of the shape depending on which is facing the camera (as we rotate the shape the dot product will change in such a way back > front and so we switch positions so it is facing us)!
We can do similar tricks for the left / right and the top / bottom, but that one is even simpler as we can move the side by the whole length of the cube:
/* left / right adjustment to move it one cube length to switch sides*/
translateZ(calc(var(--show-right) * var(--cube-size)));
/* top / bottom adjustment */
translateZ(calc(var(--show-top) * var(--cube-size)));
And that should be it right?
Well no, we have a couple of "gotchyas", and that is why this became so difficult!
2. the front and back changing position
This one caught me out!
Because we are using a single div we have a unique problem.
You see, when we move the front / back face position it also moves the position of the left / right position and top / bottom position by the same amount.
This completely breaks everything as we only want the front / back to change position relative to the user.
This is because the ::before
and ::after
pseudo elements are positioned relative to the main .cube
element. The .cube
moves, they move.
So we have to account for this in our CSS.
That is why we have two transforms on our ::before
and ::after
psuedo elements.
transform:
translate(-50%, 0%)
/* this transform accounts for the front / back changing position and moves this face by the same amount in the opposite direction so that it stays in the same location */
translateZ(calc(
-1 * (var(--show-front) - var(--show-back)) * var(--cube-size) / 2
))
rotateY(90deg)
translateZ(calc(
var(--show-right) * var(--cube-size)
));
When you see it you think "well that is straight forward", but I had to completely change the way I was rotating and positioning the sides of the cube several times to make it this simple and it really broke my brain as I was trying to visualise it in 3D space (badly! Took me like 10 tries!).
Anyway that fixes that one, we must be done now right?
3. rotation changing left / right and up / down
Nearly, this was the last problem!
Once a shape rotates more than 90 degrees on the X axis (and less than 270 degrees) things break.
This is because of our switching front and back positions and rotation in 3D space meaning that left becomes right and right becomes left.
We have the same problem rotating on the y axis at 90 and 270 degrees, our top and bottom positions change (relative to the front / back face).
This is what this little bit of chaos is about:
--X-above-90: Min(1, Max(calc(var(--rotX) - 90), 0));
--X-below-90: calc(1 - var(--X-above-90));
--X-above-270: Min(1, Max(calc(var(--rotX) - 270), 0));
--X-below-270: calc(1 - var(--X-above-270));
--X-between-90-270: Max(0, 1 - Max(var(--X-below-90), var(--X-above-270)));
--X-Not-between-90-270: calc(1 - var(--X-between-90-270));
/* repeated for the Y axis rotation */
It let's us calculate if X rotation is between 90 and 270 so we can invert our left / right positions, and the same for our Y rotation so we can invert the top / bottom positions.
That is why we have --show-right-dot
and --show-right
, we have to apply those transforms:
--show-right-dot: Min(1, Max(calc(var(--dot-prod-right) * 100 - var(--dot-prod-left) * 100), 0)
);
--show-left-dot: calc(1 - var(--show-right-dot));
--show-right: calc(var(--show-right-dot) * var(--X-Not-between-90-270) + var(--show-left-dot) * var(--X-between-90-270));
--show-left: calc(1 - var(--show-right));
The key bit is --show-right
as we do a branchless if!
--show-right: calc(var(--show-right-dot) * var(--X-Not-between-90-270) + var(--show-left-dot) * var(--X-between-90-270));
is equivalent to:
-
right(1) * not between(1) + left(0) * between(0)
if x is < 90 or x > 270 and our dot product says right should be showing (output is 1). -
right(1) * not between(0) + left(0) * between(1)
if x is between 90 and 270 and right should be showing (output is 0 - we have swapped right to the left). -
right(0) * not between(1) + left(1) * between(0)
if x is < 90 or x > 270 and our dot product says left should be showing (output is 0 which is left). -
right(0) * not between(0) + left(1) * between(1)
if x is between 90 and 270 and right should be showing (output is 1 - we now made--show-right
true even though our dot product says left should show).
That is it!
Well almost, it may still be really hard to follow from that explanation.
I find the easiest way to understand is to play!
So here is a demo where you can set the X and Y rotations using sliders and then inspect stuff.
There is also something interesting at the bottom!
Those rectangles in red and green have their margins set by CSS properties we use in the application, so you can see the values change as you move the sliders. If you inspect the shapes and check the margin you can see the value for each property.
show front, show back etc. just move by 300px to the right if they are on (and stay to the left if off).
Have a play with the sliders and see if it all starts to make sense! 💗
Top comments (6)
Very impressive! When it comes to JS part what did you intend to use to replace
setInterval
with pure CSS?A custom counter prop and then just an animation on infinite to increment. 💗
You are so talented at CSS!
I read this and… 🤯
Great article!
Thanks, glad you enjoyed it! 💗
I think this may be the most complex CSS thing I have ever done!
Let me know if it even makes sense! haha
Amazing! That is so cool!