Things to Watch Out for When Working with CSS 3D
I’ve always loved 3D geometry. I began playing with CSS 3D transforms as soon as I noticed support in CSS was getting decent. But while it felt natural to use transforms to create 2D shapes and move/rotate them in 3D to create polyhedra, there were some things that tripped me up at first. I thought I might write about the things that surprised me and the challenges I encountered so that you might avoid the same.
3D Rendering Context
I clearly remember I first ran into this one evening when curiosity hit me and I thought I’d write a quick test to see how browsers handle plane intersection. The test contained two plane elements:
<div class='plane'></div>
<div class='plane'></div>
They were identically sized, absolutely positioned in the middle of the screen, and given a background so they’d be visible:
$dim: 40vmin;
.plane {
position: absolute;
top: 50%; left: 50%;
margin: -.5*$dim;
width: $dim; height: $dim;
background: #ee8c25;
}
The scene was the entire body
element, made to cover the entire viewport and given a perspective
so that everything further away would appear smaller and everything closer would appear larger:
body {
margin: 0;
height: 100vh;
perspective: 40em;
}
To actually test plane intersection, the second plane element got a rotateY()
transform and a different background:
.plane:last-child {
transform: rotateY(60deg);
background: #d14730;
}
The result was disappointing. It seemed that no browser can properly handle plane intersection:
See the Pen test plane intersection (WRONG!) by Ana Tudor (@thebabydino) on CodePen.
But I was wrong. This is exactly what the code I had written should result in. What I should have done was put my two planes within the same 3D rendering context. If you’re not familiar with 3D rendering contexts, they’re not that different from stacking contexts. Just we can’t order elements via z-index
if they are not within the same stacking context, 3D transformed elements can’t be arranged in 3D order or be made to intersect if they are not within the same 3D rendering context.
The easiest way to make sure they’re within the same 3D rendering context is to put them inside another element:
<div class='assembly'>
<div class='plane'></div>
<div class='plane'></div>
</div>
And then absolutely position this containing element in the middle of the scene and set transform-style: preserve-3d
on it:
div { position: absolute; }
.assembly {
top: 50%; left: 50%;
transform-style: preserve-3d;
}
This solves the problem:
See the Pen test plane intersection (CORRECT) by Ana Tudor (@thebabydino) on CodePen.
If you’re using Firefox to view the above demo, you still can’t see the planes intersecting as they should because Firefox still doesn’t get this right. But you should see them intersecting in WebKit browsers and in Edge.
Now you may be wondering why even bother with adding that containing element, shouldn’t simply adding transform-style: preserve-3d
on the scene (the body
element in our case) work? Well, in this particular case, if we add this one rule and nothing else to the initial demo, it does work (unless you’re viewing it in Firefox, because, as I’ve said before, Firefox still has problems with 3D order and 3D intersections):
See the Pen test plane intersection (working, BUT…) by Ana Tudor (@thebabydino) on CodePen.
If we want to use 3D on an actual web page, our scene probably won’t be the body
element and we’ll probably want to add other properties on the scene. Properties that could interfere with that.
Things That Break 3D (Or Cause Flattening
Let’s say our scene should be another div
in the page and that we have other stuff around:
See the Pen two planes in smaller scene #0 by Ana Tudor (@thebabydino) on CodePen.
I’ve also added a few more transforms on the second plane to make it more obvious that it’s coming out of the scene. Which is something we don’t want. We want to be able to read the text, interact with controls we might have there, and so on.
1) overflow
The first idea that springs to mind is to just set overflow: hidden
on the scene. However, when we do that, we lose our beautiful 3D intersection:
This is because giving overflow
any value other than visible
effectively forces the value of transform-style
to flat
, even when we have explicitly set it to preserve-3d
. So using a container does mean writing a bit more code, but can spare us a lot of headaches.
This is why I now place everything in a scene in a containing element, even if that element isn’t being transformed in 3D. For example, consider the following demo:
See the Pen blue hex helix candy (pure CSS 3D) by Ana Tudor (@thebabydino) on CodePen.
All the rotating columns of hexagons are placed within a .helix
element:
<div class='helix'>
<div class='col'>
<!-- all the hexagons inside a column -->
</div>
<!-- the other columns -->
</div>
This .helix
element doesn’t have any other styles (directly set or inherited) except those that ensure the whole assembly is absolutely positioned in the middle of the viewport and that all the columns are within the same 3D rendering context:
div {
position: absolute;
transform-style: preserve-3d;
}
.helix { top: 50%; left: 50%; }
This is because I’m setting overflow: hidden
on the scene (the body
element in this case) as the size of the hexagons doesn’t depend on the viewport so I don’t know if they’re going to stretch outside (and cause scrollbars, which I don’t want) or not.
I confess to having hit this problem more than once before I learned my lesson. In my defence, there are situations where the effect of overflow: hidden
may not seem as obvious.
transform-style: preserve-3d
tells the browser that the 3D transformed children of the element it’s set on shouldn’t be flattened into the plane of their parent (the element we set transform-style: preserve-3d
on). So even intuitively, it kind of makes sense that also setting overflow: hidden
on the same element would undo this and prevent children from breaking out of the plane of their parent.
But sometimes a 3D transformed child can still be in the plane of its parent. Consider the following case: we have a card with two faces:
<div class='card'>
<div class='face'>front</div>
<div class='face'>back</div>
</div>
We position them all absolutely in the middle of the scene (the body
element in this case), give both the card and its faces the same dimensions, set transform-style: preserve-3d
on the card, set backface-visibility: hidden
on the faces and rotate the second one by half a turn around its vertical axis:
$dim: 40vmin;
div {
position: absolute;
width: $dim; height: $dim;
}
.card {
top: 50%; left: 50%;
margin: -.5*$dim;
transform-style: preserve-3d;
}
.face {
backface-visibility: hidden;
background: #ee8c25;
&:last-child {
transform: rotateY(.5turn);
background: #d14730;
}
}
The demo can be seen below:
See the Pen card #0 by Ana Tudor (@thebabydino) on CodePen.
Both faces are still in the plane of their parent, it’s just that the back one is rotated by half a turn around its vertical axis. It’s facing the opposite way, but it’s still in the same plane. Everything seems great so far.
Now let’s say we don’t want the faces to be rectangular. The simplest way to change that is to give the card border-radius: 50%
. But that doesn’t seem to do anything at all.
So let’s set overflow: hidden
on it:
See the Pen card #2 by Ana Tudor (@thebabydino) on CodePen.
Oops, this just broke our 3D card! Since we cannot do this, we need to round the corners of the faces:
.face { border-radius: 50%; }
See the Pen card #3 by Ana Tudor (@thebabydino) on CodePen.
In this case, the method solving the issue is even simpler than the one causing problems. But what if we wanted another shape, like a regular octagon, for example? A regular octagon is pretty easy to achieve with two elements (or an element and a pseudo):
<div class='octagon'>
<div class='inner'></div>
</div>
We give them both the same dimensions, rotate the .inner
element by 45deg
, give it a background so that we can see it and then set overflow: hidden
on the .octagon
element:
$dim: 65vmin;
div { width: $dim; height: $dim; }
.octagon { overflow: hidden; }
.inner {
transform: rotate(45deg);
background: #ee8c25;
}
The result can be seen in the following Pen:
See the Pen how to: basic regular octagon (pure CSS) by Ana Tudor (@thebabydino) on CodePen.
If we add text…
<div class='octagon'>
<div class='inner'>octagon</div>
</div>
The problem is that it’s clipped out in one of the corners, so we make it larger, align it horizontally with text-align: center
and bring it to the middle vertically by giving it a line height equal to the dimension of our .octagon
(or .inner
) element:
.inner {
font: 10vmin/ #{$dim} sans-serif;
text-align: center;
}
Now it looks much better, but the text is still rotated, as we have a rotation set on the .inner
element:
See the Pen octagon with text #1 by Ana Tudor (@thebabydino) on CodePen.
To solve this issue, we add a rotation to reverse it (of equal angle, but in the opposite direction, so negative) on the .octagon
element:
.octagon { transform: rotate(-45deg); }
We have an octagon with text!
See the Pen octagon with text – final! by Ana Tudor (@thebabydino) on CodePen.
Now let’s see how we could apply this if we want a card with octagonal faces. We cannot set overflow: hidden
on the card itself (making it play the role of the .octagon
element while the faces would be like .inner
elements) as that would break things and we wouldn’t have a nice 3D card with two distinct faces anymore:
See the Pen card #4 by Ana Tudor (@thebabydino) on CodePen.
Instead, we need to make each face play the role of the .octagon
element and use a pseudo to play the role of the inner element:
.face {
overflow: hidden;
transform: rotate(45deg);
backface-visibility: hidden;
&:before {
left: 0;
transform: rotate(-45deg);
background: #ee8c25;
content: 'front';
}
&:last-child {
transform: rotateY(.5turn) rotate(45deg);
&:before {
background: #d14730;
content: 'back'
}
}
}
This gives us the result we’ve been after:
See the Pen card #5 by Ana Tudor (@thebabydino) on CodePen.
2) clip-path
Another property that can cause similar problems is clip-path
. Going back to our card example, we cannot make it triangular by applying a clip-path
on the .card
element itself, because we need it to have a 3D transformed child, the second face. We should apply it on the card faces instead:
.face { clip-path: polygon(100% 50%, 0 0, 0 100%); }
Note that the clip-path
property still needs the -webkit-
prefix for WebKit browsers, setting the layout.css.clip-path-shapes.enabled
flag to true
in about:config
for Firefox (47+) and is not yet supported in Edge (but you can vote for implementation).
The result of adding the line of code above would look like this:
See the Pen card #6 by Ana Tudor (@thebabydino) on CodePen.
No 3D issues, but it looks really awkward. If the card is a triangle pointing right when viewed from the front, then it should point left when viewed from the back. But it doesn’t, it also points right. One solution to this problem would be to use different clip-path
values for each of the faces. Clip the front one using the same triangle pointing right and clip the back one using another triangle pointing left:
.face:last-child { clip-path: polygon(0 50%, 100% 0, 100% 100%); }
The result is just what we wanted:
See the Pen card #7 by Ana Tudor (@thebabydino) on CodePen.
Note that I have also changed the text-align
value: the default left
for the front face and set to right
for the back face.
Alternatively, we could also add a scaleX(-1)
to the transform chain on the back face (if you need a reminder of how scaling works, check out this interactive demo):
.face:last-child { transform: rotateY(.5turn) scaleX(-1); }
See the Pen card #8 by Ana Tudor (@thebabydino) on CodePen.
The shape looks fine in this case, but the text is backwards. This means we actually place the text and the background on a pseudo element on which we reverse the scale on the .face
element. Reversing a scale of factor f
means setting another scale of factor 1/f
. In our case, the f
factor is -1
, so the value we’re looking for the scale on the pseudo-element is 1/-1 = -1
.
.face:last-child:before {
transform: scaleX(-1);
background: #d14730;
text-align: right;
content: 'back';
}
The final result can be seen in this pen:
See the Pen card #9 by Ana Tudor (@thebabydino) on CodePen.
Masking properties set to any value other than none
can also force the used value of transform-style
to flat
, just like overflow
or clip-path
when set to values different from visible
and none
respectively.
3) opacity
This is an unexpected one.
It’s also a relatively new change to the spec so that the effect opacity
of less than 1
has on 3D rendering contexts matches that on stacking contexts. This is why sub-unitary opacity
doesn’t actually force flattening in Edge, Safari or Brave… yet! It does however have this effect in Chrome, Opera and Firefox.
Consider the following demo, a group of cubes rotating together in 3D:
See the Pen cube assembly #0 by Ana Tudor (@thebabydino) on CodePen.
Structurally, this means an .assembly
element containing a bunch of .cube
elements, each of them with 6
faces:
<div class='assembly'>
<div class='cube'>
<div class='cube__face'></div>
<!-- five more cube faces -->
</div>
<!-- more cubes, each with 6 faces -->
</div>
Now say we want the cubes to be semitransparent. We cannot do this:
.cube { opacity: .5; }
This leads to the transform-style
value on the .cube
elements to be forced to flat
even though we’ve set it to preserve-3d
, which makes the cube faces get flattened into the planes of their .cube
parents. For now just in Chrome, Opera and Firefox, but the rest of the browsers will implement this in the future as well.
We cannot set opacity: .5
on the .assembly
element either, as we have set transform-style
to preserve-3d
on it as well. So, again, the result is going to be inconsistent across browsers as the new spec forces flattening and some still follow the old one (which didn’t).
What we can do without running into any trouble is set opacity: .5
on the cube face elements:
See the Pen cube assembly #3 by Ana Tudor (@thebabydino) on CodePen.
We could also set it on the scene element, but note that is going to also affect any scene background
or pseudo-elements we might have. It’s also not going to make the individual cubes or faces semitransparent, just the whole assembly. And it doesn’t allow us to have different opacity values for different cubes.
4) filter
This is another one that surprised me though, unlike opacity
, it isn’t new and the results are consistent across browsers. Let’s look at the cubes example again. Say we wanted a random different hue for each cube via hue-rotate()
. Setting a filter
value other than none
on the cubes or the assembly results in flattened representations.
$n: 20; // number of cubes
@for $i from 0 to $n {
$angle: random(360)*1deg;
.cube:nth-child(#{$i + 1}) {
filter: hue-rotate($angle);
}
}
That filter
still needs the -webkit-
prefix for WebKit browsers.
This does work for giving each cube a random hue, but it also flattens them:
The solution in this case is to set the filter
on the cube faces within the loop:
$n: 20; // number of cubes
@for $i from 0 to $n {
$angle: random(360)*1deg;
.cube:nth-child(#{$i + 1}) .cube__face {
filter: hue-rotate($angle);
}
}
This gives us what we were after, cubes in random hues and still 3D, not flattened:
See the Pen cube assembly #6 by Ana Tudor (@thebabydino) on CodePen.
We also cannot set a filter
on the whole assembly. Consider the situation when we’d want it all blurred. Let’s say we do it like this:
.assembly { filter: blur(4px); }
What we could do here is try to apply the blur()
filter on the face elements, though the result wouldn’t be exactly as intended as if we’d have the individual faces blurred, not the cubes themselves. It also looks buggy, with Blink browsers experiencing some flickering, missing faces and being noticeably slowed down by the blur()
filter, while Edge messes things up completely. Firefox seems to do best here, save for the 3D order issues previously mentioned.
We could also try applying it on the scene, though that seems buggy (sometimes there’s flickering, faces disappear in Chrome and in Firefox, where the whole assembly then disappears completely, while Edge doesn’t display anything at all).
I was surprised because this next simpler demo of a rotating cube also has a blur()
filter applied on the scene and it seems to work fine for the most part in Blink browsers and in Edge. Nothing shows up in Firefox however.
See the Pen gooey cubes (pure CSS 3D, no Firefox) by Ana Tudor (@thebabydino) on CodePen.
Overall, filters in combination with 3D seem to be often problematic, so I’d say use with caution.
5) mix-blend-mode
Let’s say we have a .container
element with a sort of a rainbow background
. Inside this element, we have a .mover
element with an image background
, let’s say a blackberry pie. The class name probably gave this away already, but we animate the position of the .mover
element and we set mix-blend-mode: overlay
on it. This makes our mover have a different look depending on what part of its parent’s background
happens to be over.
See the Pen orbiting pie blending in (blend modes!) by Ana Tudor (@thebabydino) on CodePen.
Blend modes are not yet supported in Edge, so none of the demos in this section work there. You can however vote for mix-blend-mode
implementation. Also note that, for now, you probably shouldn’t take the .container
to be the body
or the html
element due to a Blink bug. This bug causes the blend mode on the .mover
to be ignored when it’s animated and the .container
is the body
or the html
. Firefox and Safari don’t have this problem.
Alright, but this is just 2D. How about our mover being a cube with image faces, a cube that’s rotating around in 3D?
So far, so good, but we don’t have any blending going on yet. We set mix-blend-mode: overlay
on our cube and… we now have blending, but it broke our 3D, the faces are flattened into the plane of the cube!
See the Pen orbiting cube of pies blending #1 by Ana Tudor (@thebabydino) on CodePen.
Again, this is because we apply 3D transforms on the cube as we animate it and it has 3D transformed children, so we want our cube to have a value of preserve-3d
for transform-style
. But setting mix-blend-mode: overlay
on our cube forces the used value of transform-style
to flat
, so the cube faces get flattened into the plane of their parent.
We could try setting mix-blend-mode: overlay
on the cube faces, but this doesn’t appear to be working. The cube is flattened and there’s no blending.
Another solution would be to add a .scene
element between the container and the moving cube and set perspective
and mix-blend-mode
on this element:
See the Pen orbiting cube of pies blending #3 by Ana Tudor (@thebabydino) on CodePen.
This seems to fix everything!
Things to Watch Out for When Working with CSS 3D is a post from CSS-Tricks