Things to Watch Out for When Working with CSS 3D

Avatar of Ana Tudor
Ana Tudor on (Updated on )

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

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

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:

The problem.

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.

The solution.

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>

It doesn’t show up at all.

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

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

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.

Result when opacity doesn’t force flattening (Edge, Safari)
Result when opacity forces flattening (Chrome, Firefox, Opera, per new spec changes)

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 CSS-Tricks (@css-tricks)
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.

Result when setting opacity: .5 on the individual cube faces
Result 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.

$n: 20; // number of cubes

@for $i from 0 to $n {
    $angle: random(360)*1deg;

    .cube:nth-child(#{$i + 1}) {
        filter: hue-rotate($angle);
    }
}

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:

$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 CSS-Tricks (@css-tricks)
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); }
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.

See the Pen
gooey cubes (pure CSS 3D, no Firefox)
by CSS-Tricks (@css-tricks)
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?

See the Pen
orbiting cube of pies blending #0
by CSS-Tricks (@css-tricks)
on CodePen.

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 CSS-Tricks (@css-tricks)
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. 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:

See the Pen orbiting cube of pies blending #3 by Ana Tudor (@thebabydino) on CodePen.

This seems to fix everything!