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!

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.

I recently saw this loader on CodePen, a pure CSS 3D rotating set of bars with a fading reflection. It's done by using an element for each bar, then duplicating each and every one of these elements to create the reflection and finally adding a gradient cover to create the fading effect. Which sounds a bit like scratching behind your right ear with the toes of your left foot! Not to mention the gradient cover method for the fading effect doesn't work with non-flat-color backgrounds. Isn't there a better way to do this with CSS?

I recently saw a recreation of the Twitter heart animation among the picks on CodePen. If I happen to have a bit of time, I always look through the code of demos that catch my attention to see if there's something in there that I could use or improve. In this case, I was surprised to see the demo was using an image sprite. I later learned that this is how Twitter does it. Surely it could be done without images, right?

background-clip is one of those properties I've known about for years, but rarely used. Maybe just a couple of times as part of a solution to a Stack Overflow question. Until last year, when I started creating my huge collection of sliders. Some of the designs I chose to reproduce were a bit more complex and I only had one element available per slider, which happened to be an input element, meaning that I couldn't even use pseudo-elements on it. Even though that does work in certain browsers, the fact that it works is actually a bug and I didn't want to rely on that. All this meant I ended up using backgrounds, borders, and shadows a lot. I also learned a lot from doing that and this article shares some of those lessons.

Before anything else, let's see what background-clip is and what it does.

The following is a guest post by Ana Tudor. If you've seen Ana's work, perhaps you know that she uses mathematics and code together to make art. The finished pieces look like they take ages to make. But as I witness with my own eyes, Ana can think through what it takes to build something like this while doing it incredibly quickly. Here she'll explain to us the entire thought process in a step-by-step tutorial.

The following is a guest post by Ana Tudor. Ana tackles an interesting problem here: what happens when the stroke of an element grows it such that it appears cut off? Getting it to fit tightly again may seem like an exercise in guessing, checking, and magic numbers. But if you know Ana's work, you'll know she is uniquely qualified to teach us the math behind getting to the correct solution. She approaches it a number of different ways, with beautifully interactive demos along the way.

I have recently found myself wanting to tightly pack an SVG shape into an HTML container. By tight packing, I mean I want the outermost points of my shape to be right on the edges on the container, without anything going out of the container or getting cut off.

The outermost vertices of this four-point star are situated on the edges of its container. We have made the SVG fully cover its parent, and we have picked the coordinate system and the coordinates of the star's points such that the (0,0) point of the SVG canvas is right in the middle and the star's outermost vertices are on the axes of this coordinate system at the edges of the container. The demo below illustrates how the code works in this case:

As the demo above shows, increasing the stroke-width creates a problem: the stroke extends both inside and outside the initial boundaries of the four-point star, making it increase in size and not fit inside its container anymore. It's obvious that we either need to adjust the position of the vertices or change the viewBox attribute on our <svg> element.

Solution: adjusting the viewBox

The following demo illustrates how adjusting the viewBox would work:

Dragging the slider thumb to the left decreases the viewBox width and height, keeping them equal at all times, and changes the coordinates of the top left corner of the SVG canvas such that its (0, 0) point is always right in the middle. This is like a "zooming in" effect.

Dragging the slider thumb to the right increases the viewBox width and height, keeping them equal at all times, and changes the coordinates of the top left corner of the SVG canvas such that its (0, 0) point is always right in the middle. This is like a "zooming out" effect.

In our case, in order to fit our four-point star after increasing its stroke-width, we need to zoom out, so we need to increase the viewBox dimensions. But, by how much do we need to increase them in order to have the star just touching the edges? If we don't zoom out enough, then our star still gets cropped. If we zoom out too much, then it won't touch the edges anymore. So, what are the viewBox dimensions for the right amount of zoom and how do we get them?

In order to figure this out, we need to pick an exact value we want to increase the stroke-width to—let's say 20. We'll now take a closer look at what happens around the outer vertices after increasing the stroke-width to 20:

In the figure above, we have our star represented twice, both before and after increasing the stroke-width. We can see that, after increasing the stroke-width, our star extends outside the initial boundaries by a distance d in all directions. This means that we should increase the viewbox dimensions by twice this distance d.

All right, but how can we get d? Well, in order to do this, we'll focus on just one of the outer vertices, let's say the point at (250,0).

We'll call this point P. We'll also consider another point, Q, which is the rightmost point of the star after we have increased its stroke-width. It's situated on the x axis, just like P, only at a distance d to the right of P. If we draw a perpendicular through P onto one of the star's edges, we get the right triangle PQR, where PQ is equal to d and PR is equal to half the known stroke-width we have applied.

In this right triangle, PQ is the hypotenuse, and the sine of the angle α is the ratio between PR and PQ. PR is equal to half of our known stroke-width (20/2 = 10), while PQ is d. So, from here we get that d equals the ratio between half the stroke-width (10) and the sine of the angle α.

But, it looks like we just replaced one unknown with another, because we don't know α. However, we can compute it.

Our star is symmetrical with respect to the axes of coordinates, meaning that any of the two axes split our star into two perfectly mirrored halves. In addition to this, when a transversal line cuts two or more parallel lines, the corresponding angles (the angles occupying the same position at each intersection) are equal, as the demo below shows:

All these mean that, in the figure below, all marked angles are equal. Those above the x axis are each equal to the ones right below them because the star is symmetrical. All those on one side of the axis are again equal, as the dotted lines on the same side of the axis are parallel (and the transversal line is the x axis).

Since all these angles are equal, we can compute α from another right triangle—the one formed by the point P, the next vertex of our star (that we'll call S), and its projection onto the x axis (that we'll call T). The projection of a point onto a line is obtained at the intersection between the line and a perpendicular on it drawn through the point.

We know the coordinates of these three points. From the code we have used to create the star, point P is situated at (250,0), and point S is situated at (20,20).

Point T, the projection of point S onto the x axis, has the same x coordinate as S, while its y coordinate is 0. This means that T is situated at (20,0).

Knowing these coordinates, we can compute the lengths of the ST and PT segments. The length of the vertical ST segment is 20, while the length of the horizontal PT segment is 250 - 20 = 230.

The PST triangle is a right triangle, so the tangent of α is the ratio between the ST and the PT segments. Both these segments are known, which means we can compute α as the arctangent of their ratio: atan(20/230).

Now that we have computed α, we can replace it in the relation that gives us the distance d we were looking for: 10/sin(atan(20/230)) ≈ 115 (by the way, I simply googled the result of that). This means that the coordinates of the top left point will be (-250 - 115,-250 - 115) = (-365, -365) and that the viewbox width and height will be 500 + 2*115 = 500 + 230 = 730. In this case, after changing the viewBox (and leaving everything else unchanged), our code becomes:

You've probably realized the demo above doesn't look right. The star still looks cropped, even if it now fits inside the visible part of the SVG canvas. Why is this happening, and how can we fix it?

stroke-linejoin

Well, whenever we have a shape made up of various segments that meet at corners, we have a few options about how that's going to look. These options are controlled by a property called stroke-linejoin. This property can take one of the following four values:

miter: (the default) — the segments meet at a sharp angle

round: the corner is rounded

bevel: looks something like miter, except the vertex is cut off

inherit: whatever value was used for its parent group

Now if we look at the pen above carefully, we'll notice something interesting about the last join on the first column. It has stroke-linejoin: miter, but the visual result looks different from those of the other joins on the first column (the miter column). In fact, it looks just the result we get for the last join on the last column (the bevel column). And, it looks just like the problem we have with the star at this point. Our line join should be a miter because we haven't set stroke-linejoin to anything else. But, what we see is a bevel.

stroke-miterlimit

This is because of a property called stroke-miterlimit. As the name may suggest, this property imposes a limit on how much the miter can extend. When this limit is exceeded, the join is simply converted from a miter to a bevel.

The demo below shows that the smaller the angle between two segments gets, the more the miter would normally extend if the set stroke-miterlimit was high enough so that it never got converted to a bevel. It also shows how the angle at which the miter is converted to a bevel decreases as the value of stroke-miterlimitincreases:

All right, but what's the connection between the stroke-miterlimit and the actual miter length? In the previous demo, the values of the stroke-miterlimit range from 2 to 15, while the actual miter lengths are over 50 and can go well into the hundreds. The miter length also depends on the stroke-width, so it's pretty obvious that the imposed limit is not on the actual miter length itself. Instead, this limit is on the ratio between the miter length and the stroke width. And, as the following image shows, this ratio is equal to 1 over the sine of half the angle between the lines that are joined.

The sine of an acute angle (< 90°) increases as the angle increases, so 1 over the sine (the ratio on which we impose the limit set by stroke-miterlimit) is going to increase as the angle decreases.

This kind of explains why the join transforms from miter to bevel. If the angle gets really close to 0, then its sine also gets really close to 0, making the inverse of this value a very large number and having the miter extend a lot. And, while I don't have any actual metrics on that, I'm thinking it can't possibly be good for performance.

Fixing the star demo

Now coming back to our star, its angles are not that close to zero and all we need to do in order to prevent the joins from being transformed into bevels is set stroke-miterlimit to 1 over the sine of the α angle we had previously computed. α was atan(20/230), so the value we want is 1/sin(atan(20/230)) ≈ 11.5 and we'll set stroke-miterlimit: 12 just to be sure. The following pen shows how simply adding this one line to the CSS fixes everything.

Note: If we don't feel like doing all these computations, then we can simply set stroke-miterlimit to a ridiculously high value. Just how high? Well, 60 is enough for a 2° angle between the lines that are being joined, and it's pretty unlikely we'll often need more than that.

Problems

All right, but this method of refitting the star inside its container by changing the viewBox and therefore zooming out has also made the stroke look thinner than we initially intended it to be. We could try to fix this with vector-effect: non-scaling-stroke, but this only creates more problems as the stroke width won't scale anymore as the SVG's container, and consequently the SVG itself, change size.

So if we want to solve this, we have to leave the viewBox unchanged and just tweak the coordinates of our star's vertices.

Solution: tweaking the coordinates of the vertices

We know by how much the star expands outwards if we set stroke-width: 20. We have computed this distance d to be about 115. This means we have to move the outermost vertices inside by 115, from 250 along the axes to 250 - 115 = 135. We can't change just the coordinates of our outermost vertices, as that would distort our star and, the same time, changing the angles would change how much the miter extends, and the star wouldn't be tightly packed anymore anyway.

Instead, what we need to do is first compute a scaling factor which will be the ratio between the new non-zero coordinate of an outermost vertex and the old one: 135/250 = .54. Then we multiply the old coordinates of the other vertices by this factor in order to get their new coordinates (20*.54 = 10.8).

Of course, the future should bring a lot of good things, including avoiding all the pain that these computations might cause for some. SVG2 will allow us to specify a stroke-alignment — that's right, controlling whether increasing the stroke-width will make the stroke expand inwards, outwards or on both sides of the element's outline is going to be posible with just one line of code.

In our case, that would be stroke-alignment: inner. And poof! No other tricks required, no zooming by tweaking the viewBox, no repositioning of the points of our shape... none of that anymore.

But, in the meanwhile, let's see what I've been using this for.

Use case: 3D!

More than two years ago, I started playing with CSS 3D transforms in order to build various 3D shapes. The non-rectangular flat faces (with three, five, six, or ten edges) were created by nesting 2D transformed HTML elements and cutting out unwanted parts with overflow: hidden. Over the past few months, I've also started playing with SVG and began thinking about redoing those 3D shapes, this time using an SVG <polygon> for the non-rectangular faces.

Note: If you need a basic geometry refresher, this Pen explains what a polygon is and gives some examples.

This is where I ran into the problem presented above, because how much I move the 2D faces in 3D depends on their 2D dimensions. So, the simplest solution to that in my mind was to have the circumcircle (the circle on which all the vertices are situated) of my regular polygons dead in the middle of the SVG, and at least one of its vertices right on the edge. Since the SVG element would have the same dimensions as its HTML container, this would mean that half the dimension of the HTML container would be equal to the circumradius of the SVG polygon inside.

Note that not every polygon has a circumcircle (but all regular polygons and all triangles do).

So let's see how I dealt with it all by creating the simplest 3D shapes—prisms! Prisms are polyhedra (3D solids with flat faces and straight edges) with two n-polygonal base faces, connected by n quadrilateral faces. For simplicity, we'll consider all the lateral faces rectangular and the base polygons regular (all angles are equal and all edge lengths are equal). The demo below allows creating and also flattening a number of prisms.

The rectangular faces are easy to get. HTML elements are rectangles by default, so we'll just use one element for each rectangular face. But what about the bases?

Well, for each base, we'll take a square (equal width and height, preferably in vmin units, so that they scale with the viewport) HTML element with an SVG inside. The <svg> element covers its container completely (width and height are both set to be 100%), and the polygon inside touches the edges, but nothing gets cut out. Since the two bases are identical, we just define the polygon once and then reference it for each base.

However, in this case we'd end up setting the same viewBox on both SVG elements inside the base faces so a small improvement over this version would be to use <symbol>, so that we only have to set the viewBox in one place (on the <symbol> element).

The basic CSS would position all the geometric elements absolutely in the middle of their container. We'll make the parent of our 3D shape, in our case the <body> element be the scene by setting a perspective on it. Since the prism is absolutely positioned, we'll also have to set a height on the <body> and, because we have no other content, we'll make the body cover the entire viewport. We'll also give the face elements explicit width and height and make sure they're dead in the middle by using negative margins. And given that we might want to animate the prism itself in 3D, we'll set transform-style: preserve-3d on it.

Now let's see how we can get our tight-fitted regular polygon. We start from the fact that all the vertices of a regular polygon are situated on a circle called the circumcircle. But how exactly do we position the vertices on this circle?

Before anything else, we need to know what a circle looks like. A full circle has 360° and we start from the + of the x axis (3 o'clock).

If our polygon has n equal length edges, then the arc corresponding to one edge is going to have the number of degrees of a full circle (360°) over n. In the case of an equilateral triangle, n is 3, so the angle is 120°. For a square, n is 4, so the angle is 90°. For a regular pentagon, n is 5, so the angle is 72°.

Now if we go around the circle, starting from 0°, in n steps, then at every step we have a polygon vertex, as illustrated in the demo below.

For a triangle, n is 3, so each step is 360°/3 = 120°. This puts the three vertices of our equilateral triangle at 0°,120°, and 240°. For a square, n is 4, making each step 360°/4 = 90°, and puting the four vertices at 0°, 90°, 180°, and 270°. For a regular pentagon, n is 5, the angular step is 360°/5 = 72° and the vertices are situated at 0°, 72°, 144°, 216°, and 288°.

Now we need the coordinates of these vertices in order to draw our polygon. The position of a point in a plane is given by the length of the segment connecting the point to the origin (in our case, the radius r of our circle) and the angle between that segment and the x axis (in our case, i times the angular step, where i is the index of the vertex).

xi = r*cos(i*360°/n);
yi = r*sin(i*360°/n);

If we know the number of vertices/edges, then it's easy to compute the angles, but how much is the radius? Well, not taking into account the stroke-width of our <polygon> element, this would be half the dimension of our square SVG viewBox (which, let's say, we'll take to be 800). Adjusting for the stroke-width is going to mean subtracting half the miter length from this value, just like we did for the four-point star earlier.

In order to do this, we need to compute the angle of our regular polygon, because the miter length depends on the stroke-width and on the polygon angle. We know that the angles in a triangle always add up to 180° (you can drag the vertices to play with the triangle in the demo).

Knowing this, and knowing that we have three angles in a triangle, we get that the angle of a regular (or equilateral) triangle is 60°. But what about other regular polygons? Well, if we connect the first vertex of our polygon to all the other vertices, we can see that we've split our polygon into n - 2 triangles.

This means that the angles in a polygon with n vertices/edges add up to (n - 2)*180°. Given that all n angles of this regular polygon are equal, we get that each angle is (n - 2)*180°/n. If we actually do the computations, we find that we have 60° for an equilateral triangle, 90° for a square, 108° for a regular pentagon, 120° for a regular hexagon, and so on...

We now have all the data we need for setting the points attribute of our base polygon.

var r = 400 /* circumradius, no correction, half of 800 */,
sw = 20 /* stroke-width */,
n = 3 /* number of edges */,
sym = document.getElementById('basepoly'),
poly = sym.querySelector('polygon'),
vb = [-r, -r, 2*r, 2*r],
points_attr_text = '',
base_angle = 2*Math.PI/n,
poly_angle = (n - 2)*Math.PI/n,
correction = .5*sw/Math.sin(.5*poly_angle),
r_final = r - correction;
/* set the viewBox */
/* "viewBox", not "viewbox" (won't work) */
sym.setAttribute('viewBox', vb.join(' '));
for(var i = 0; i < n; i++) {
curr_angle = i*base_angle;
x = r_final*Math.cos(curr_angle);
y = r_final*Math.sin(curr_angle);
points_attr_text += x + ',' + y + ' ';
}
poly.setAttribute('points', points_attr_text);
poly.setAttribute('stroke-width', sw);

You can see this code working in the following pen. You can tweak the values for the number of edges/ vertices, circumradius or stroke-width in order to get different results. We could pack these polygons even tighter through various methods, but then we'd lose the advantage of having a simple and consistent method of doing it for any number of edges/vertices.

- var n = 3;
- var sw = 20;
- var r = 400;
- var base_angle = 2*Math.PI/n;
- var poly_angle = (n - 2)*Math.PI/n;
- var correction = .5*sw/Math.sin(.5*poly_angle);
- var r_final = r - correction;
mixin polygon(n)
- var points = '';
- for(var i = 0; i < n; i++) {
- var curr_angle = i*base_angle;
- var x = r_final*Math.cos(curr_angle)
- var y = r_final*Math.sin(curr_angle);
- points += ~~x + ',' + ~~y + ' ';
- }
polygon(points=points stroke-width='#{sw}')
svg(width='0' height='0')
symbol(id='basepoly'
viewbox='#{-r} #{-r} #{2*r} #{2*r}')
+polygon(n)
svg(class='vis')
use(xlink:href='#basepoly')

The next step we take is to combine this with the initial code we had and also auto-generate the lateral faces while we create the polygon. You can see the result in the pen below (or, if you prefer to do this the Jade way, be sure to check out this version).

All right, but at this point, all our faces are simply stacked one on top of the other in the middle of the screen, so we need to position them in 3D such that they form a prism.

Let's start with the base faces. Currently, they're in the vertical plane of the screen and we want them to be in a horizontal plane perpendicular onto the screen; one facing up, and the other one facing down. To do this, we'll apply a rotateX(90deg) transform for the one we want to face up, and a rotateX(-90deg) transform for the one we want to face down. Applying a rotation on an element also rotates its system of coordinates, so now the z axis, the one that was initially coming out of the screen, towards us, is pointing up and down respectively for the two rotated faces. These faces are now horizontal, but they're cutting the vertical lateral faces in the middle. So, what we need to do is to translate them vertically (one up and the other one down) by half the height of the lateral faces (which we have taken to be 32vmin in this case, so half of that is 16vmin). Translating vertically after the rotation means translating along the z axis (which was made vertical by the rotation), so we'll have to apply a translateY transform. The code for this will be:

.prism__face--base:nth-child(1) {
transform: rotateX(90deg) translateZ(16vmin); /* up */
}
.prism__face--base:nth-child(2) {
transform: rotateX(-90deg) translateZ(16vmin); /* down */
}

After adding these two rule sets, the two base faces are now in place.

Positioning the lateral faces means first rotating them around their y axis (with a rotateY transform) such that they each face an edge of the bases, then translating them outwards (with a translateZ()) by the inradius. But what the heck is the inradius? Well, it's the radius of a circle (called the incircle) that touches each edge of a polygon at precisely one point (each edge is tangent to the incircle). Just like in the case of the circumcircle, not all polygons have an incircle, but all regular ones, like those we are dealing with in the case of our prism bases, do.

Moreover, in the case of regular polygons, the circumcircle and the incircle are centred at the same point, while the circumradius and the inradius split the polygon's angles and respective edges into two equal halves. Representing it all graphically gives us something like in this Pen:

Here, the circumradii are the segments connecting the central point to the polygon vertices, and the inradii are the segments connecting the central point to the midpoints of the polygon's edges. Given that the polygon's edges are tangent to the incircle, the inradii drawn in the demo are perpendicular onto the polygon's edges.

If we take a triangle formed by an inradius, a circumradius and half a polygon edge, this triangle is a right triangle and allows us to compute the inradius relative to the circumradius and the polygon's angle.

In the demo above, this is the OVM triangle. OV is the circumradius, OM is the inradius and VM is half the regular's polygon edge. The VMO angle is a right angle (which makes the opposing edge, OV, the hypotenuse) and the OVM angle is half the polygon's angle. The sine of the OVM angle is the ratio between OM and OV and we'll use this relation to compute OM, as we already know OV and the OVM angle.

The circumradius is half the dimension of the base face that we have set in CSS and we already have the angle of the polygon, so this is all we need for the value of the inradius:

The only problem we still have is that the widths of the lateral faces are still not right. We have taken them to be equal to the dimension of the base face, but the base face is twice the circumradius of the base polygon and that's not equal to the edge of the base polygon. But we can compute the edge from the same triangle as the inradius:

edge_len = 2*cradius*Math.cos(.5*poly_angle);

... then set the proper width and margin-left for the lateral faces:

Best of all, changing the number of edges is as simple as just changing the n variable in the JS—try that for yourself! There are still a couple of little problems to be ironed out. We don't really need to set the same width and margin-left inline on every lateral face— we could simply put these into a style element. Also, on resize, we need to update these values, as well as the transforms on the lateral faces, because they depend on the pixel dimensions of the bases, which we have set in viewport units so that the prisms scale on resize. This final pen fixes all such little problems:

The following is a guest post by Ana Tudor. Ana always does a great job digging into the math behind what we can do graphically on the web. In this case, that's extra-useful as there are several ways to handle SVG transforms and they require a bit of mathematical thinking, especially converting one type to another for the best cross browser support. Here's Ana.

Just like HTML elements, SVG elements can be manipulated using transform functions. However, a lot of things don't work the same way on SVG elements as they do on HTML elements.

The following is a guest post by Ana Tudor. Perhaps you know Ana from her amazing work combining code, math, and art. Here, she shows us how we can change the normal behavior of clipping paths by applying some clever geometry, and then make it work across different technology and browsers.