Things to Watch Out for When Working with CSS 3D

Share this:

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:

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 like 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:

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. Update: this issue is fixed in Firefox 55+.

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 54 or older):

Things That Break 3D (Or Cause Flattening)

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 .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:

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-3dtells 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:

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:

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.

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:

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:

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:

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-53 (the flag is set to true by default in Firefox 54+) and is not yet supported in Edge (but you can vote for implementation).

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:

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.

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 or Safari... yet! It does however have this effect in Chrome, Opera and Firefox.

Consider the following demo, a group of cubes rotating together in 3D:

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:

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.

Result when setting opacity: .5 on the individual cube facesResult when setting opacity: .5 on the scene

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.

Note that, until recently, filter still needed the -webkit- prefix for all WebKit browsers and that, while current versions of all major desktop browsers now support it unprefixed, most mobile browsers still need this prefix.

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:

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); }

The result is that the whole thing gets flattened into the plane of the assembly in addition to being blurred. Edge is the exception, everything disappears.

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.

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.

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.

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!

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. This doesn't happen in Firefox, though the spec says it should.

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:

Some really neat stuff here, I’ve always loved 3D transforms. Performance still leaves something to be desired in a lot of cases though… get a few of these demos running simultaneously and my poor Chromebook can hardly scroll anymore.

👋

CSS-Tricks* is created, written by, and maintained by Chris Coyier and a team of swell people. It is built on WordPress and powered up by Jetpack. It is made possible through sponsorships from products and services we like.