Solving the Last Item Problem for a Circular Distribution with Partially Overlapping Items

Avatar of Ana Tudor
Ana Tudor on

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

Clockwise circular (cyclic) distribution with twelve partially overlapping square items. Every item's top left corner is underneath the previous item's bottom left corner
Clockwise circular (cyclic) distribution with partially overlapping items.

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:

Highlighting the issue we encounter with our circular distribution using the above code. The last item (11), ends up both over one before it (10) and over the one after (0), while the first item (0) is both under the one before it (11) and under the one after it (1).
Highlighting the issue we encounter with our circular distribution.

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

The result after ensuring all our items are within the same 3D rendering context: they are all in the correct order, with every item's top left corner underneath the bottom left corner of the previous item.
The result after ensuring all our items are within the same 3D rendering context (live demo).

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

Screenshot illustrating the Firefox issue described above.
Screenshot illustrating the Firefox issue.

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:

Geometric illustration. First item and its projection onto the plane of the screen, side view from the + of the x axis. From this point, we see these as two lines, AB and CD, which intersect in the middle, this intersection being the point O. The angle between them is the angle of rotation of each item around its own x axis, 40° in this case.
First item and its projection onto the plane of the screen, side view.

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

Geometric illustration. We draw a rectangle whose left edge is the CD projection and whose top right corner is the A point.
Creating the CDFA rectangle.

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.

Geometric illustration focused on the right triangle AFB
The right triangle AFB

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

The final result after fixing the height issue: all items are square again and they are all in the correct order, with every item's top left corner underneath the bottom left corner of the previous item.
The final result after fixing the height issue (live demo).

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.

Animated gif showing a tic toc loader. Eighteen bars are distributed on a circle, all pointing towards the origin. Every two opposing bars animate at the same time, rotating by half a turn around their own central points. Once they are done, the next pair of opposing bars starts animating.
Tic toc loader (see the live demo, WebKit only)

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.

Animated gif showing a sea shell loader. There are two layers, with eighteen bars distributed on identical circles on each layer. All the bars rotate around their own central points at the same time, with those on the layer behind being 90 degrees away from those on the layer in front at all times.
Sea shell loader (see the live demo, WebKit only)

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:

Diagram showing five discs distributed clockwise on a circle, partly overlapping, with each of the discs partly underneath the disc following it.
Diagram (see the live demo, WebKit only)

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

Circular image gallery. Image thumbnails are distributed in a similar fashion to the discs in the previous demo, on a circle around the current image. Clicking on a thumbnail selects that image and moves it in the middle where it grows to its natural size, while the previously selected image shrinks and moves back in place on the circle. All images show pictures of Amur leopards.
Circular image gallery (see the live demo, WebKit only)

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

Animated gif. 12 discs are distributed on a circle in a similar fashion to the previous demos. The ones on odd positions shift out on another outer layer. The two layers rotate in opposite directions, then the items on the outer layer shift back on the inner layer and then the animation repeats itself.
Disc Buddies loading animation (see the live demo, WebKit only)