As some people might know, I've always loved 3D geometry. Which has meant getting drawn towards playing with CSS 3D transforms in order to create various geometric shapes. I've built a huge collection of such demos, which you can check out on CodePen.
Because of this, I've often been asked whether it would be possible to create responsive 3D shapes using, for example, % values instead of the em values my demos normally use. The answer is a bit more complex than a yes/no answer, so I thought it would be a good idea to put it in an article. Let's dive in!
Responsive 3D shapes using % values
This is possible, right? It is, but it complicates things. To illustrate that, let's take an example. Let's say we want to create a cube.
Before we actually start working on that, two things.
If you need a refresher of how creating a simple, non-responsive rectangular cuboid with the use of CSS 3D transforms works, you can check out this older article all the way to the bar distribution part. It explains (in extreme detail even) a number of concepts related to how CSS transforms work, the process of building the cuboid with clean and compact code and I'm not going to go through all these things again here.
Let's recap what % values mean for a few CSS properties we might want to set on an element.
% values for various CSS properties
For width, padding and margin a % value is relative to the width of the parent of the element on which we are setting these properties.
Let's consider the following situation: we have an element, a child of the body. On this element, we set % value width, padding and margin.
.boo {
width: 50%;
padding: 10%;
margin: 5%;
}
If we resize the viewport such that the width of the body (the parent of our element) is 300px, then our element has a width of 150px (50% of 300px), a padding of 30px (10% of 300px) and a margin of 15px (5% of 300px), as it can be seen when inspecting the element.
Inspecting the element (see live demo).
For this to work as we've intended, the width of the parent shouldn't depend on that of its children. This is a circularity we should avoid. It happens, for example, if the parent has position: absolute. In this case, it appears that the width of the parent is computed such that the content of our element fits, then our element's width, padding and margin get computed as % values of the parent's width.
For height, a % value is relative to the height of the parent of the element on which we are setting the height property. Let's consider our element is the child of the body, which we've made to cover the entire viewport in height.
body { height: 100vh; }
.boo { height: 50%; }
If we resize the viewport such that the height of the body (the parent of our element) is 300px, then the height of our element is 150px (50% of 300px), as it can be seen when inspecting the element.
Inspecting the element (see live demo).
For this to work as we've intended, the height of the parent shouldn't depend on its children. This is another circularity we should avoid. But unlike in the case of width, we find ourselves in this situation whenever we don't explicitly set a height on the parent.
For transform, if a translate() / translateX() / translateY() / translateZ() / translate3d() function uses a % value, then this value is relative to the size of the element along that axis. Note that these are the axes of the element's local system of coordinates, which gets transformed in 3D along with the element itself. Its x axis always points to the right of the element, its y axis always points to the bottom of the element and its z axis always points out from the front of the element.
A value of translate(50%) or translate(50%, 0) or translateX(50%) or translate3d(50%, 0, 0) moves the element along the x axis by half its own width. This means that the vertical midline of the element ends up being where its right edge was initially. In this live test, the grey box represents the element in its intial position, with no transform applied. The orange box represents the element with a transform of translateX(50%) applied.
Applying a transform of translateX(50%) on an element.
A value of translate(0, 50%) or translateY(50%) or translate3d(0, 50%, 0) moves the element along the y axis by half its own height. This means that the horizontal midline of the element ends up being where its bottom edge was initially. In this live test, the grey box represents the element in its intial position, with no transform applied. The orange box represents the element with a transform of translateY(50%) applied.
Applying a transform of translateY(50%) on an element.
What about a value of translateZ(50%) or translate3d(0, 0, 50%)? Well, no anomaly here, it moves the element along the z axis by half its size along that axis. But what's the size of the element along the z axis? We're not setting it anywhere, that's for sure, but all elements are flat, contained in a plane, and consequently, their size along their z axis is always 0. This means that a translation along the z axis that uses a % value does nothing, because any % of 0 is still 0. If we check the computed value of translateZ(50%), we can see that it's none.
Consequences of the way % values work
The first one is that using % values we cannot create elements whose width depends on the viewport height and creating elements whose height depends on the viewport width isn't that straightforward.
We wanted to create a cube, so let's see how we can create a square whose edge length is 20% of the viewport width. First of all, we make sure our square element has both width and height equal to 0. We can set these explicitly, or we can absolutely position our square, which is what we choose to do in this case as it comes in handy later when creating the cube. Then we set the padding on this square element to half the % value we want for the cube's edge length 20%/2 = 10% - this makes every padding value (padding-top, padding-right, padding-bottom and padding-left) be 10% of the viewport width.
$edge-len: 20%;
.square {
position: absolute;
padding: .5*$edge-len;
}
Adding up a padding-left of 10% and a padding-right of 10% gives us 20% of the viewport width horizontally across the square. Adding up a padding-top of 10% and a padding-bottom of 10% gives us 20% of the viewport width vertically across the square. The result is a square that scales with the viewport width.
We have a square that scales with the viewport width (see live test).
This looks great, but we have to keep in mind that we have zeroed our element's width and height, which means we cannot set box-sizing: border-box on it and adding a border that wouldn't add up to the total space occupied by our square on the screen in this case requires either emulation with an inset box-shadow or subtracting the border-width from the padding using calc().
$edge-len: 20%;
$bw: .5em; // border width
.square {
position: absolute;
border: solid $bw currentColor;
padding: calc(#{.5*$edge-len} - #{$bw});
}
Also getting cool effects using background-clip, like a transparent space between the background and border becomes more complicated as well. For a simple spaced out border we need to subtract both the border-width and the box-shadow spread from the padding using calc(). Not to mention we also need a margin equal to the box-shadow spread to fix positioning.
$edge-len: 20%;
$bw: .5em; // border width
$s: .75em; // shadow spread
.square {
position: absolute;
margin: $s;
border: solid $bw transparent;
padding: calc(#{.5*$edge-len} - #{$bw + $s});
box-shadow: 0 0 0 $s currentColor;
background: #e18728 padding-box;
}
For a double spaced out border, we have no choice but to use a pseudo-element.
$edge-len: 20%;
$bo: .25em; // outer border width
$so: .5em; // outer gap width
$bi: 1em; // inner border width
$si: .75em; // inner gap width
$inner-len: calc(100% - #{2*($so + $si + $bo + $bi)});
.square {
position: absolute;
padding: .5*$edge-len;
box-shadow: inset 0 0 0 $bo;
&:before {
position: absolute;
border: solid $bi currentColor;
padding: $si;
width: $inner-len; height: $inner-len;
transform: translate(-50%, - 50%);
background: #e18728 content-box;
content: '';
}
}
The second consequence is that translating such an element along its z axis (forward and backward) by an amount that depends on at least one of its viewport-dependant dimensions (width and height) isn't that straightforward either.
We have already established that we cannot use a % value to translate an element along its z axis by an amount that depends on a viewport dimension. However, with transforms, we have more than one way of bringing an element into a certain position.
For example, rotate(53deg) translate(5em) is equivalent to translate(3em, 4em) rotate(53deg), as it can be seen in this live test.
So, while we cannot move our square forward by half of its width using a translateZ() with a % value, we can come up with a transform chain that doesn't use a translateZ() function, but still puts our element right where we want it.
Let's consider the square with no border we got above and let's say we want to move it forward by half its edge length. We have more than one way of doing this.
For example, we could start by rotating it around its y axis by -90°. This rotates both our square and its system of coordinates such that it now faces left and its x axis points towards us, out of the screen.
Next, we translate it along its x axis by 50%. The x axis now points towards us, so this means our square gets moved forward by half its edge length. The vertical midline of our square is in the position we want it, but we're seeing our square from the right and we want to see it from the front.
This is why our final step is to reverse the initial rotation. So we rotate our square by 90° around its y axis - this makes it face us again, while its x axis points to the right again.
These three steps result in the rotateY(-90deg) translateX(50%) rotateY(90deg) transform chain and they are illustrated by the following demo (click to play):
See the Pen translating a square forward by half its edge length without translateZ - method 1 by Ana Tudor (@thebabydino) on CodePen.
Another transform chain that gives the same result is rotateX(90deg) translateY(50%) rotateX(-90deg). Just like the previous one, it uses the trick of rotating the element (and its local system of coordinates along with it) such that we make another axis (y in this case) point in the direction that the z axis was before this rotation, translating along this other axis (y) and then finally reversing the first rotation. We can see this illustrated in the Pen below:
See the Pen translating a square forward by half its edge length without translateZ - method 2 by Ana Tudor (@thebabydino) on CodePen.
Note that the previous two demos don't work in Edge.
Creating a responsive cube using % values
We start with the following structure:
<div class='cube'>
<div class='cube--face'></div>
<div class='cube--face'></div>
<div class='cube--face'></div>
<div class='cube--face'></div>
<div class='cube--face'></div>
<div class='cube--face'></div>
</div>
We could simplify this with a preprocessor like Haml or Slim or whatever.
.cube
- 6.times do
.cube__face
I do this because I don't like writing the same thing multiple times and, with something like Haml, I'm not even introducing a loop variable that I'm not using in the loop anyway. It's mostly down to personal preference here since we don't have a lot of code anyway. There are just 7 HTML elements: the .cube element and its 6 face children1.
We take the body element to be our scene, so we make it cover the entire viewport height and set a perspective on it. Remember that this is an arbitrary decision, taken just so that we simplify things. We could just as well take our scene to be any element whose width depends on the viewport width in some way.
We then absolutely position our elements, making sure that the .cube is dead in the middle of the scene and it has perspective-style: preserve-3d so that its children can be transformed in 3D as well.
body {
height: 100vh;
perspective: 20em;
}
div { position: absolute; }
.cube {
top: 50%; left: 50%;
transform-style: preserve-3d;
}
Next step would be to size our cube faces like before, using padding, right? Well, if we do that, we discover that the padding is actually evaluated to 0px!
Checking out the computed styles in dev tools: our % value padding is evaluated to 0px!
That's because they aren't children of the body anymore, they are children of the .cube element, which is an absolutely positioned 0x0 element.
We can get past this by sizing the cube element using the padding trick and then making its children the same size.
$edge-len: 16%;
.cube {
/* previous styles */
margin: -.5*$edge-len;
padding: .5*$edge-len;
&__face {
top: 0; right: 0; bottom: 0; left: 0;
background: orange;
}
}
We've also added a negative margin on the .cube element, so that we have its midpoint dead in the middle of the scene instead of its top left corner.
See the Pen responsive cube using % values - step 1 by Ana Tudor (@thebabydino) on CodePen.
This is a start. All the face elements are the size we wanted them to be and they're right where we wanted them. Now let's see how we can transform the 6 face elements in 3D such that they form a cube.
We start by dividing the 6 faces into two categories: 4 lateral faces and 2 base faces. We consider the lateral faces to be the right, back, left and front ones and the base ones the top and the bottom. This is an arbitrary decision. We could have taken the lateral ones to be the bottom, front, top and back ones and the base ones to be the left and the right. We take the first 4 face elements in DOM order to be the lateral ones and the others to be the base ones.
The next thing we do is pick the axis we want to do the translations along, the axis which we want to point towards where on the cube (front, bottom, right...) we want to place our face elements. It cannot be the z axis because we need to work with % values here, so we pick the x axis. Again, remember that this is completely arbitrary - in fact, you could take using the y axis instead as something to try after you finish reading this.
We take the first face element. Without any transforms applied, its x axis points right. So we make it be the face on the right of the cube. We translate it by 50% in the positive direction along its x axis. This makes its vertical midline coincide with the vertical midline of the cube's right face. We then rotate it by 90° around its y axis to place it into the desired position on the cube (facing right).
See the Pen position face element on the right of cube by Ana Tudor (@thebabydino) on CodePen.
The CSS for this first .cube__face element is:
.cube__face:first-child {
transform: translateX(50%) rotateY(90deg);
}
For consistency purposes, we can write this in the following equivalent form (a rotation of 0° around any axis has no effect):
.cube__face:nth-child(1) {
transform: rotateY(0deg) translateX(50%) rotateY(90deg);
}
We move on to the second face, which we put on the back of the cube. This means we first rotate it by 90° around its y axis so that its x points towards that face (which means towards the back). Then we translate it by 50% in the positive direction along its x axis. Now its vertical midline coincides with that of the cube's back face. The final step is to rotate it again by 90° around its y axis so that it's in the desired position on the cube (facing back).
See the Pen position face element on the back of cube by Ana Tudor (@thebabydino) on CodePen.
We write down the transform for this face:
.cube__face:nth-child(2) {
transform: rotateY(90deg) translateX(50%) rotateY(90deg);
}
Now it's the turn of the third face to be positioned. This time, on the left of the cube. We start by rotating it by 180° around its y axis so that we can make its x axis point left, where we want to put it. Then we translate it by 50% in the positive direction along this x axis. This puts its vertical midline on that of the cube's left face. Finally, we rotate it by 90° more around its y axis so that it's positioned correctly on the cube (facing right).
See the Pen position face element on the left of cube by Ana Tudor (@thebabydino) on CodePen.
So the transform chain in this case is:
.cube__face:nth-child(3) {
transform: rotateY(180deg) translateX(50%) rotateY(90deg);
}
Now we got to the final lateral face! This one we position on the front of the cube. Just as with the previous three, the first thing we need to do is rotate it around its y axis in such a way that it makes its x axis point forward. We've seen before that this can be achieved by a -90° rotation around the y axis. But a -90° rotation puts an element in the same position as a 270° one, so, for consistency reasons, we're using 270° here. After the 270° rotation around the y axis, we translate our element by 50% in the positive direction along its x axis. And finally, we rotate it by 90° more around its y axis so that it faces forward, not left.
See the Pen position face element on the front of cube by Ana Tudor (@thebabydino) on CodePen.
The transform chain for this last lateral face is:
.cube__face:nth-child(4) {
transform: rotateY(270deg) translateX(50%) rotateY(90deg);
}
Now we can move on to the base faces. To position the first base face on the bottom of the cube, the first step is to rotate it such that its x axis points in that direction (down). That's a 90° rotation around its z axis. The following step is to translate it by 50% in the positive direction along this x axis that now points down, bringing its midline onto the cube's bottom face. And lastly, we rotate it by 90° around its y axis so that it faces down.
See the Pen position face element on the bottom of cube by Ana Tudor (@thebabydino) on CodePen.
This means that the CSS for positioning this face is:
.cube__face(5) {
transform: rotateZ(90deg) translateX(50%) rotateY(50%);
}
And we got to the last face, which we put on top of the cube. To do so, we start by rotating such that its x axis points up - that's a -90° rotation around its z axis. Following this, we translate it by 50% in the positive direction along this x axis that now points up. This way, we've brought the face's midline on the cube's top face. The final step is to rotate it by 90° rotation around its y axis so that it faces up.
See the Pen position face element on the top of cube by Ana Tudor (@thebabydino) on CodePen.
.cube__face(6) {
transform: rotateZ(-90deg) translateX(50%) rotateY(50%);
}
Putting everything together, we can start to notice some patterns:
.cube__face(1) { /* 1 = 0 + 1 */
transform: rotateY(0deg) /* 0° = 0*90° */
translateX(50%) rotateY(50%);
}
.cube__face(2) { /* 2 = 1 + 1 */
transform: rotateY(90deg) /* 90° = 1*90° */
translateX(50%) rotateY(50%);
}
.cube__face(3) { /* 3 = 2 + 1 */
transform: rotateY(90deg) /* 180° = 2*90° */
translateX(50%) rotateY(50%);
}
.cube__face(4) { /* 4 = 3 + 1 */
transform: rotateY(270deg) /* 270° = 3*90° */
translateX(50%) rotateY(50%);
}
.cube__face(5) { /* 5 = 4 + 1 */
transform: rotateZ(90deg) /* 90° = 1*90° = ((-1)^4)*90° */
translateX(50%) rotateY(50%);
}
.cube__face(6) { /* 6 = 5 + 1 */
transform: rotateZ(-90deg) /* -90° = -1*90° = ((-1)^5)*90° */
translateX(50%) rotateY(50%);
}
The first thing to notice here is that the last two transform functions in the chain are always the same. The next thing is that we can derive a general formula for the lateral faces and the base faces. For the lateral faces, the first transform function is rotateY($i*90deg), where $i is the face index. For the base faces, the first transform function is rotate(pow(-1, $i)*90deg). This means that we can compact it all in a loop, like this:
@for $i from 0 to 6 {
.cube__face:nth-child(#{$i + 1}) {
transform:
if($i < 4, rotateY($i*90deg),
rotateZ(pow(-1, $i)*90deg))
translateX(50%) rotateY(90deg);
}
}
The result of adding the above code to what we had before can be seen in the following Pen:
See the Pen responsive cube using % values - step 2 by Ana Tudor (@thebabydino) on CodePen.
It doesn't look like anything has changed, but, if we make the faces semitransparent, give them a sort of outline and animate the rotation of the .cube element, it becomes obvious that we now have a 3D shape. A responsive one even!
Responsive cube created using % values (see live demo).
We could also tweak this a bit so that the scene isn't the body anymore:
See the Pen responsive cube using % values - step 4 (scene != body) by Ana Tudor (@thebabydino) on CodePen.
Now this doesn't seem too bad. For the non-responsive case, the transform chain needed to position a face on the cube has a length of 2 - a rotation and a translateZ():
@for $i from 0 to 6 {
.cube__face:nth-child(#{$i + 1}) {
transform:
if($i < 4, rotateY($i*90deg),
rotateX(pow(-1, $i)*90deg))
translateZ(.5*$edge-len);
}
}
With a responsive cube created using % values, it has a length of 3 - a rotation and then a translateX(50%) and a rotateY(50%). That's just 6 more transform functions in total - we can live with that.
The problem is that the more we want to do, the more complex things get.
A more complex example
Let's say we want to have something like a Rubik's cube. This is an assembly of cubes, with 3 along each dimension. That's 3*3*3 = 27 cubes in total. This means we have the following structure:
.assembly
- 27.times do
.cube
- 6.times do
.cube__face
Each cube gets created almost as above, with a few differences.
The most important one is that now the cubes' parent isn't the scene anymore, it's the .assembly element. Which is absolutely positioned like everything else in the scene, so by default, its size is 0x0. So in this case, we need to size the .assembly relative to the scene width, then make the .cube elements and their faces the same size as the assembly.
$edge-len: 8%;
div {
position: absolute;
transform-style: preserve-3d;
}
.assembly {
top: 50%; left: 50%;
margin: -.5*$edge-len;
padding: .5*$edge-len;
transform: rotateX(-30deg) rotateY(30deg);
}
[class*='cube'] {
top: 0; right: 0; bottom: 0; left: 0;
}
What we have so far is 27 cubes, all of them positioned in the middle of the scene:
See the Pen responsive cube assembly using % values - step 0 by Ana Tudor (@thebabydino) on CodePen.
To make something like a Rubik's cube, we need to distribute these cubes along the three dimensions of space. Along each dimension, we have 3 cubes. One gets translated by its edge length in the negative direction of the axis, the second one stays in place and the third one is translated by its edge length in the positive direction of the axis. This means that the values for these translations are -100%, 0 and 100%. These three values can be written as -1*100%, 0*100% and 1*100% respectively. Since our indices along each axis are 0, 1 and 2, this means that the translation along each axis is the index minus 1, all multiplied by 100%.
So our basic distribution code looks like this:
@for $i from 0 to 3 { // along the x axis
$x: ($i - 1)*100%;
@for $j from 0 to 3 { // along the y axis
$y: ($j - 1)*100%;
@for $k from 0 to 3 { // along the z axis
$z: ($k - 1)*100%;
$idx: $i*3*3 + $j*3 + $k + 1;
.cube:nth-child(#{$idx}) {
transform: translate3d($x, $y, $z);
}
}
}
}
The problem is that this code does nothing when using % values. If we get rid of the $z and use just a translate($x, $y), then we can see the cubes distributed along the first two dimensions.
This distribution along the x and y axes looks perfect in Chrome and Edge. Firefox has 3D order issues, but we can easily fix these with z-index in the static case.
Distribution along the x and y axes, expected result and what we get in Chrome and Edge (left) vs. Firefox result (right)
With the way we've rotated our assembly, the x axis points back and we want the cubes in the back to be behind those in the front. So the higher the $i value, the lower the z-index should be - this means we add it with minus when we compute the value. The y axis points down and we want the cubes on higher up to be above those below. So the higher the $j value, the lower the z-index should be - this means we also add it with minus. The z axis points right and we want the cubes on the left to be behind those on their right. This means that the higher the $k value, the higher the z-index, so we need to add it with plus. We get that:
z-index: $k - $i - $j;
Now it also looks fine in Firefox:
See the Pen responsive cube assembly using % values - step 3 by Ana Tudor (@thebabydino) on CodePen.
But what about the third dimension? Well, we can do as before: rotate every cube around its y axis such that its x axis points in the direction its z axis originally pointed and then translate by $z along this x axis. This means our transform chain becomes:
transform: translate($x, $y) rotateY(90deg) translateX($z);
This does the trick:
Responsive assembly of cubes (see live demo).
We got our nice, responsive, Rubik's cube structure. But it's at the expense of 2 extra transform functions per cube. And we have 27 cubes, so that's 54 extra transform functions. Our CSS just got a lot bigger.
Responsive 3D shapes using viewport units
This should be easier, right? Yes, but... depending on what units we choose and on how we might want to animate our 3D shapes, we could run into bugs.
I like using viewport units better than using % because it doesn't come with the same limitations and complications. I can size the shapes depending on the viewport height, not just on the width. Even better, depending on the smaller viewport dimension! And creating the shapes works just the same as when using px or em, no need to add extra transform functions to the chain.
However, we need to be aware of a few browser issues.
Edge doesn't yet support vmax
This hasn't bothered me a lot because I've rarely wanted vmax-sized shapes and, on the very rare occasions that I have, I was able to get around that one way or another.
Current solutions
The first workaround that comes to mind when wanting to create a square whose size depends on the maximum viewport dimension is to set its width and height to a value with vw units and its min-width and min-height to the same value with vh units.
.boo {
width: 20vw; height: 20vw;
min-width: 20vh; min-height: 20vh;
}
It works like a charm:
Testing vmax emulation (see live demo).
We can make this more maintainable by using Sass:
$edge-len-landscape: 20vw;
$edge-len-portrait: $edge-len-landscape*1vh/1vw;
.boo {
width: $edge-len-landscape;
height: $edge-len-landscape;
min-width: $edge-len-portrait;
min-height: $edge-len-portrait;
}
Now if we want to change how much the edge length depends on the maximum dimension, we only need to change the value of $edge-len-landscape and we don't have to worry about maybe forgetting to change one value somewhere.
Unfortunately, this is not enough when we want to play in 3D. To position the faces in the middle, we'll want a negative margin that depends on the edge length. To create a cube, we need to translate each face by an amount that depends on the edge length. But which one? The portrait or the landscape one?
We have two options that work today: we can either use an orientation (or an aspect ratio) media query or we could use % values inside the translate() function like we did before.
So let's go back to our cube example.
With the first method, we combine the above emulation with the regular transform chains we'd apply in the non-responsive case and we add a media query for the transform part:
$edge-len-landscape: 20vw;
$edge-len-portrait: $edge-len-landscape*1vh/1vw;
.cube__face {
margin: -.5*$edge-len-landscape;
width: $edge-len-landscape;
height: $edge-len-landscape;
min-width: $edge-len-portrait;
min-height: $edge-len-portrait;
@for $i from 0 to 6 {
&:nth-child(#{$i + 1}) {
transform: if($i < 4, rotateY($i*90deg),
rotateX(pow(-1, $i)*90deg))
translateZ(.5*$edge-len-landscape);
}
}
@media (orientation: portrait) {
margin: -.5*$edge-len-portrait;
@for $i from 0 to 6 {
&:nth-child(#{$i + 1}) {
transform: if($i < 4, rotateY($i*90deg),
rotateX(pow(-1, $i)*90deg))
translateZ(.5*$edge-len-portrait);
}
}
}
}
This looks a like a bit too much, so it's probably better if we use a mixin:
$edge-len: 20vw;
@mixin faces($l: $edge-len) {
margin: -.5*$l;
width: $l; height: $l;
@for $i from 0 to 6 {
&:nth-child(#{$i + 1}) {
transform:
if($i < 4, rotateY($i*90deg),
rotateX(pow(-1, $i)*90deg))
translateZ(.5*$l);
}
}
}
.cube__face {
@include faces();
@media (orientation: portrait) {
@include faces($edge-len*1vh/1vw);
}
}
The result of the above code is a responsive cube depending on the maximum viewport dimension.
Responsive cube depending on maximum viewport dimension without using vmax (see live demo).
With the second method, we use %-valued translations in combination with the vmax-emulating sizing. In addition to the transform chains we had when doing everything with percentages, we also need to add a translate(-50%, -50%) at the beginning to compensate for the edge-dependant negative margin so that our square starts as being positioned in the middle of the scene.
If that's not too clear, consider this: we position all elements absolutely, we put the .cube in the middle of the scene (top: 50%; left: 50%;) and then we size its faces (using the vmax emulation method). But this makes the corner of our faces be in the middle of the scene, which is not what we wanted.
See the Pen absolutely position & relative size #0 by Ana Tudor (@thebabydino) on CodePen.
Even if we don't know what negative margin to apply because we don't know whether we're in portrait or in landscape mode, we can still fix this with a translate(-50%, -50%) - this translates the element to the left by half its width (whatever that may be, we don't need to know it) and up by half its height. This is how we get our cube faces to start from the right position.
See the Pen absolutely position & relative size #1 by Ana Tudor (@thebabydino) on CodePen.
Now we have to distribute the faces on the .cube, but if we simply add the transform chains from the all % case, then they overwrite transform: translate(-50%, -50%) and the .cube isn't positioned properly anymore.
See the Pen cube sized depending on max viewport dimension (no vmax!) - positioning fail by Ana Tudor (@thebabydino) on CodePen.
This is why we need to chain translate(-50%, -50%) before the other transform functions for each face:
@for $i from 0 to 6 {
.cube__face:nth-child(#{$i + 1}) {
transform: translate(-50%, -50%)
if($i < 4, rotateY($i*90deg),
rotateZ(pow(-1, $i)*90deg))
translateX(50%) rotateY(50deg);
}
}
Doing this gives us the result we were after.
Responsive cube depending on maximum viewport dimension without using vmax (see live demo).
Future solutions
At this point, CSS variables are listed as "in development" for Edge so, once they're supported, we should be able to start using them for a much cleaner workaround than the two above. The basic idea would be to use a CSS variable for the edge length. We initially set its value to be a vw one, then changing this value to vh inside a media query does the trick.
.boo {
--edge-len: 20vw;
width: var(--edge-len);
height: var(--edge-len);
}
@media (orientation: portrait) {
.boo { --edge-len: 20vh; }
}
For creating 3D shapes things get a bit trickier because some properties require values that are not equal to the edge length, but computed from it. For example, for our cube, we need to set on the faces a margin that's equal to half the edge length and them we also need to translate the faces by half the edge length. Solution? calc() to the rescue!
.cube__face {
--edge-len: 20vw;
margin: calc(-.5*var(--edge-len));
width: var(--edge-len); height: var(--edge-len);
@for $i from 0 to 6 {
&:nth-child(#{$i + 1}) {
transform: if($i < 4, rotateY($i*90deg),
rotateX(pow(-1, $i)*90deg))
translateZ(calc(.5*var(--edge-len)));
}
}
}
@media (orientation: portrait) {
.cube__face { --edge-len: 20vh; }
}
See the Pen cube sized depending on max viewport dimension with CSS variables by Ana Tudor (@thebabydino) on CodePen.
This works perfectly in Chrome and Firefox, but will it work in Edge when variables land there too? Well, there's another problem in Edge. calc() works inside translate functions when used for the translate values along the x and y axes, but not for those along the z axis. If we use calc() inside translateZ() or for the translate value along the z axis (the third argument) inside translate3d(), then the computed value for the transform in Edge ends up being none.
The first workaround that comes to mind is to start with another CSS variable, --inradius, from which we compute --edge-len:
.cube__face {
--inradius: 10vw;
--edge-len: calc(2*var(--inradius));
margin: calc(-1*var(--inradius));
width: var(--edge-len); height: var(--edge-len);
@for $i from 0 to 6 {
&:nth-child(#{$i + 1}) {
transform: if($i < 4, rotateY($i*90deg),
rotateX(pow(-1, $i)*90deg))
translateZ(var(--inradius));
}
}
}
@media (orientation: portrait) {
.cube__face { --inradius: 10vh; }
}
The inradius for both a cube and any of its square faces is equal to half the edge length.
See the Pen cube sized depending on max viewport dimension with CSS variables #2 by Ana Tudor (@thebabydino) on CodePen.
This should probably work in Edge once CSS variables arrive, but we won't know for sure until that actually happens.
Using vmin values inside translate functions fails in Edge
This sounds really inconvenient because vmin seems to be the go to unit when wanting to create responsive 3D shapes. Fortunately, this has a handy fix: setting the font-size in vmin and then working with em!
See the Pen is earth flat? (pure CSS 3D) by Ana Tudor (@thebabydino) on CodePen.
Animating translations that use viewport units fails in most browsers after viewport resize
This isn't a problem if we're not animating translations using viewport units.
However, it's the most problematic issue for me because I often want to animate things beyond a simple rotation of the 3D assembly and I haven't been able to find a fix for this problem.
Consider the following example: we have a rectangle we animate to go from the left of the screen to the right (live demo):
.boo {
margin-left: -5em;
width: 10em; height: 7.5em;
animation: ani 1s ease-in-out infinite alternate;
}
@keyframes ani {
to { transform: translate(100vw); }
}
Before resizing the viewport, everything looks fine in all browsers.
But if we increase or decrease the viewport width, both Chrome and Safari still animate to the same absolute value as before. It's like the 100vw value got replaced by its px equivalent. The rectangle isn't animated to the right of viewport anymore, but beyond it if we've decreased the viewport width and to some point in the middle if we've increased it. And the font-size trick used for the previous Edge issue doesn't help in this case.
Edge does something weird too - after resize, the element is translated to the right of the viewport, then just disappears, then flashes back onto the screen.
Firefox is the only browser that gets this one right.
For a simple 3D example, let's say we have a 3D assembly with two identical cubes.
<div class='assembly'>
<div class='cube'>
<div class='cube__face'></div>
<!-- 5 more cube faces here -->
</div>
<div class='cube'>
<div class='cube__face'></div>
<!-- 5 more cube faces here -->
</div>
</div>
Or, the DRY preprocessor version:
.assembly
- 2.times do
.cube
- 6.times do
.cube__face
Their edge length is in vmin units (well, in em units, but with font-size set to a vmin value so that we avoid the Edge bug mentioned before). We put the .assembly dead in the middle of the scene and then we offset the cubes to the right and to the left by a quarter of the viewport width (that's 25vw). So the basic styles before we actually do anything in 3D would look something like:
$edge-len: 16em;
$offset: 25vw;
body {
height: 100vh;
perspective: 32em;
}
div {
position: absolute;
transform-style: preserve-3d;
}
.assembly { top: 50%; left: 50%; }
.cube {
&:nth-child(1) { left: -$offset; }
&:nth-child(2) { left: $offset; }
&__face {
margin: -.5*$edge-len;
width: $edge-len; height: $edge-len;
background: url($image); /* so we can see it */
}
}
The result so far can be seen in the following Pen:
See the Pen colliding cubes - step #0 by Ana Tudor (@thebabydino) on CodePen.
We could compact the cube offsets a bit from this point with an index-based sign switching so that we can set them in a loop. -$offset = -1*$offset and $offset = 1*$offset. Also, -1 = (-1)^1 = (-1)^(0 + 1) and 1 = (-1)*(-1) = (-1)^2 = (-1)^(1 + 1). That gives us:
.cube {
@for $i from 0 to 2 {
&:nth-child(#{$i + 1}) {
left: pow(-1, $i + 1)*25vw;
}
}
}
The faces are positioned exactly like in the non-responsive case.
@for $i from 0 to 6 {
.cube__face:nth-child(#{$i + 1}) {
transform:
if($i < 4, rotateY($i*90deg),
rotateX(pow(-1, $i)*90deg))
translateZ(.5*$edge-len);
}
}
We now have the two cubes:
See the Pen colliding cubes - step #1 by Ana Tudor (@thebabydino) on CodePen.
The next step is to animate them so that they collide. This means translating the first one towards the right (in the positive direction of the x axis) and the second one towards the left (the negative direction of the x axis). Translating them by the $offset puts them both right in the middle, overlapping, but that's not what we want. We want the right face of the first cube to touch the left face of the second cube right in the middle. This means that they both need to be half an edge length away from the middle. It's like we've translated them by the $offset in one direction and then by half the edge length in the opposite direction. So the @keyframes look like this:
.cube {
animation: move 2s ease-in infinite alternate;
@for $i from 0 to 2 {
&:nth-child(#{$i + 1}) {
left: pow(-1, $i + 1)*25vw;
animation-name: move#{$i + 1};
}
}
}
@keyframes move1 {
to {
transform: translateX(calc(1*(#{$offset} - #{.5*$edge-len})));
}
}
@keyframes move2 {
to {
transform: translateX(calc(-1*(#{$offset} - #{.5*$edge-len})));
}
}
We can make this code more efficient by generating the @keyframes within the .cube loop:
.cube {
animation: move 2s ease-in infinite alternate;
@for $i from 0 to 2 {
&:nth-child(#{$i + 1}) {
left: pow(-1, $i + 1)*25vw;
animation-name: move#{$i + 1};
}
@at-root {
@keyframes move#{$i + 1} {
to {
transform: translateX(calc(#{pow(-1, $i)}*(#{$offset} - #{.5*$edge-len})));
}
}
}
}
}
The result looks great in all browsers. That is... until we resize the viewport. WebKit browsers don't update the translation amount in the keyframes to match the new viewport and neither does Edge.
Viewport-relative translation amount doesn't get updated after viewport resize in WebKit browsers and Edge (see live demo).
A more complex situation where this breaks things is when morphing from one 3D shape to another via truncation.
See the Pen tetrahedron truncation sequence (interactive, ~responsive) by Ana Tudor (@thebabydino) on CodePen.
Resizing the viewport in this case causes the triangular faces that open up when we start truncating the tetrahedron from its vertices not to be positioned correctly anymore as their place in 3D is also determined by a translation whose value depends on the tetrahedron's edge length (which uses viewport units so that the entire 3D shape scales as the viewport gets resized).
1 I've seen a lot of demos and tutorials adding .front, .back, .left, .right, .top, .bottom classes to these face elements and I personally find that pointless, even damaging. If we rotate the whole cube in 3D, which we often want to do, then the .front face isn't in front from our point of view anymore and that can get confusing. Giving them each a different name is kind of distracting fron the fact that they are all similar entities, it doesn't matter which we put where - the first one by DOM order could be placed on the front of the cube, on the right or on the bottom depending on the generic distribution formula we pick. Using a general index-dependent formula that we can position them all in 3D is another very good reason to ditch these classes.
The State of Responsive 3D Shapes is a post from CSS-Tricks