Let’s say we wanted to have something like this:

At first, this doesn’t seem too complicated. We start with 12
numbered items:
- 12.times do |i|
.item #{i}
We give these items dimensions, position them absolutely in the middle of their container, give them a background
, a box-shadow
(or a border
) and tweak the text-related properties a bit so that everything looks nice.
$d: 2em;
.item {
position: absolute;
margin: calc(50vh - #{.5*$d}) 0 0 calc(50vw - #{.5*$d});
width: $d; height: $d;
box-shadow: inset 0 0 0 4px;
background: gainsboro;
font: 900 2em/ #{$d} trebuchet ms, tahoma, verdana, sans-serif;
text-align: center;
}
So far, so good:
See the Pen by thebabydino (@thebabydino) on CodePen.
Now all that’s left is to distribute them on a circle, right? We get a base angle $ba
for our distribution, we rotate each item by its index times this $ba
angle and then translate it along its x
axis:
$n: 12;
$ba: 360deg/$n;
.item {
transform: rotate(var(--a, 0deg)) translate(1.5*$d);
@for $i from 1 to $n { &:nth-child(#{$i + 1}) { --a: $i*$ba } }
}
The result seems fine at first:
See the Pen by thebabydino (@thebabydino) on CodePen.
However, on closer inspection, we notice that we have a problem: item 11
is above both item 0
and item 10
, while item 0
is below both item 1
and 11
:

There are a number of ways to get around this, but they feel kind of hacky and tedious because they involve either duplicating elements, cutting corners with clip-path
, adding pseudo-elements to cover the corners or cut them out via overflow
. Some of these are particularly inefficient if we also need to animate the position of the items or if we want the items to be semi transparent.
So, what’s the best solution then?
3D to the rescue! A really neat thing we can do in this case is to rotate these items in 3D such that their top part goes towards the back (behind the plane of the screen) and their bottom part comes forward (in front of the plane of the screen). We do this by chaining a third transform
function – a rotateX()
:
transform: rotate(var(--a, 0deg)) translate(1.5*$d) rotateX(40deg)
At this point, nothing seems to have changed for the better – we still have the same problem as before and, in addition to that, our items appear to have shrunk along their y
axes, which isn’t something we wanted.
See the Pen by thebabydino (@thebabydino) on CodePen.
Let’s tackle these issues one by one. First off, we need to make all our items belong to the same 3D rendering context and we do this by setting transform-style: preserve-3d
on their parent (which in this case happens to be the body
element).

Those on current Firefox may have noticed we have a different kind of issue now. Item 8
appears both above the previous one (7
) and above the next one (9
), while item 7
appears both below the previous one (6
) and below the next one (8
).

This doesn’t happen in Chrome or in Edge and it’s due to a known Firefox bug where 3D transformed elements are not always rendered in the correct 3D order. Fortunately, this is now fixed in Nightly (55).
Now let’s move on to the issue of the shrinking height. If we look at the first item from the side after the last rotation, this is what we see:
The AB line, rotated at 40°
away from the vertical is the actual height
of our item (h
). The CD line is the projection of this AB line onto the plane of the screen. This is the size we perceive our item’s height to be after the rotation. We want this to be equal to d
, which is also equal to the other dimension of our item (its width
).
We draw a rectangle whose left edge is this projection (CD) and whose top right corner is the A point. Since the opposing edges in a rectangle are equal, the right edge AF of this rectangle equals the projection d
. Since the opposing edges of a rectangle are also parallel, we also get that the ∠OAF (or ∠BAF, same thing) angle equals the ∠AOC angle (they’re alternate angles).
Now let’s remove everything but the right triangle AFB. In this triangle, the AB hypotenuse has a length of h
, the ∠BAF angle is a 40°
one and the AF cathetus is d
.
From here, we have that the cosine of the ∠BAF angle is d/h
:
cos(40°) = d/h → h = d/cos(40°)
So the first thing that comes to mind is that, if we want the projection of our items to look as tall as it is wide, we need to give it a height of $d/cos(40deg)
. However, this doesn’t fix the squished text or any squished backgrounds, so it’s a better idea to leave it with its initial height: $d
and to chain another transform
– a scaleY()
using a factor of 1/cos(40deg)
. Even better, we can store the rotation angle into a variable $ax
and then we have:
$d: 2em;
$ax: 40deg;
.item {
transform: rotate(var(--a, 0deg)) translate(1.5*$d) rotateX($ax) scaleY(1/cos($ax));
}
The above changes give us the desired result (well, in browsers that support CSS variables and don’t have 3D order issues):

This method is really convenient because it doesn’t require us to do anything different for any one item in particular and it works nicely, without any other extra tweaks needed, in the case of semitransparent items. However, the above demo isn’t too exciting, so let’s take a look at a few slightly more interesting use cases.
Note that the following demos only work in WebKit browsers, but this is not something related to the method presented in the article, it’s just a result of the currently poor support of calc()
for anything other than length values.
The first is a tic toc loader, which is a pure CSS recreation of a gif from the Geometric Animations tumblr. The animation is pretty fast in this case, so it may be a bit hard hard to notice the effect here. It only works in WebKit browsers as Firefox and Edge don’t support calc()
as an animation-delay
value and Firefox doesn’t support calc()
in rgb()
either.

The second is a sea shell loader, also a pure CSS recreation of a gif from the same Tumblr and also WebKit only for the same reasons as the previous one.

The third demo is a diagram. It only works in WebKit browsers because Firefox and Edge don’t support calc()
values inside rotate()
functions and Firefox doesn’t support calc()
inside hsl()
either:

The fourth is a circular image gallery, WebKit only for the same reason as the diagram above.

The fifth and last is another loading animation, this time inspired by the Disc Buddies .gif by Dave Whyte.

Why not solve the issue of the shrinking height by rotating by a very small amount (I tried 0.1deg and it works in Firefox) instead of 40deg?
Because I wanted to show how to solve this problem in a logical way (not by trial and error/ approximations) if you’re in a situation that requires a larger rotation angle and therefore makes the height difference more noticeable.
I’m not getting the order bug in Firefox 53.0.3 (64-bit) for Mac. It’s perfect!
that’s really weird, in windows it works.
but it still doesn’t work on my mac all browsers.
Made this a year ago, but I had to cheat :)
The logical is awesome.
But there is a confuse for me,
why the rotate and translate keywords in different order, it will have different effect ?
like this pen:
And the final result is no working.
I try opera, firefox, safari and chrome, the 0 item still below 1 and 11
In general, transform functions are not commutative because, in general, matrix multiplication is not commutative.
If you chain a uniform (isotropic) scaling transform with a rotation, then yes, you get the same result regardless of the order, but that’s just a particular case.
In the case of a rotation followed by a translation, you first perform the rotation. If the value for the rotation is let’s say
90deg
, then thex
axis of the element goes from pointing right before the rotation to pointing down after the rotation. If you want to translate the element along itsx
axis in the positive direction after this rotation, this means you’re translating it down.By contrast, if you want to translate the element along its
x
axis before any rotation, you’re translating it to the right.And the final result works for me in Chrome/ Opera, Edge and Firefox 55+. There might be some issues on OSX/ iOS, but I have no idea why…
The final demo in Firefox (50.0.2) still shows box 11 above box 0…
right. maybe it’s my mac’s problom.
it’s works on windows. and all the browsers on mac it doesn’t work.
I made a screenshot:
https://singlexyz.github.io/screenshot.png
Looks good in Firefox 53.0.3.
I’ve upgraded to Firefox latest 53 and still the same issue, but this time is 6 under 7 and 8.
7 under 6 and 8 is what I’m seeing in Firefox 53 as well, as shown by the screenshot in the article. However, this issue is fixed in Firefox 55+.
Nice Article learned a lot!! Thanks Ana