Simplifying the Apple Watch Breathe App Animation With CSS Variables

Avatar of Ana Tudor
Ana Tudor on

When I saw the original article on how to recreate this animation, my first thought was that it could all be simplified with the use of preprocessors and especialy CSS variables. So let’s dive into it and see how!

Animated gif. Shows the result we want to get: six initially coinciding circles move out from the middle of the screen while the whole assembly scales up and rotates.
The result we want to reproduce.

The structure

We keep the exact same structure.

In order to avoid writing the same thing multiple times, I chose to use a preprocessor.

My choice of preprocessor always depends on what I want to do, as, in a lot of cases, something like Pug offers more flexibility, but other times, Haml or Slim allow me to write the least amount of code, without even having to introduce a loop variable I wouldn’t be needing later anyway.

Until recently, I would have probably used Haml in this case. However, I’m currently partial to another technique that lets me avoid setting the number of items both in the HTML and CSS preprocessor code, which means I avoid having to modify it in both if I need to use a different value at some point.

To better understand what I mean, consider the following Haml and Sass:

- 6.times do
  .item
$n: 6; // number of items

/* set styles depending on $n */

In the example above, if I change the number of items in the Haml code, then I need to also change it in the Sass code, otherwise things break. In a more or less obvious manner, the result is not the intended one anymore.

So we can go around that by setting the number of circles as the value of a CSS variable we later use in the Sass code. And, in this situation, I feel better using Pug:

- var nc = 6; // number of circles

.watch-face(style=`--nc: ${nc}`)
  - for(var i = 0; i < nc; i++)
    .circle(style=`--i: ${i}`)

We’ve also set the index for every .circle element in a similar manner.

The basic styles

We keep the exact same styles on the body, no change there.

Just like for the structure, we use a preprocessor in order to avoid writing almost the same thing multiple times. My choice is Sass because that’s what I’m most comfortable with, but for something simple like this demo, there’s nothing in particular about Sass that makes it the best choice – LESS or Stylus do the job just as well. It’s just faster for me to write Sass code, that’s all.

But what do we use a preprocessor for?

Well, first of all, we use a variable $d for the diameter of the circles, so that if we want to make them bigger or smaller and also control how far out they go during the animation, we only have to change the value of this variable.

In case anyone is wondering why not use CSS variables here, it’s because I prefer to only take this path when I need my variables to be dynamic. This is not the case with the diameter, so why write more and then maybe even have to come up with workarounds for CSS variable bugs we might run into?

$d: 8em;

.circle {
  width: $d; height: $d;
}

Note that we are not setting any dimensions on the wrapper (.watch-face). We don’t need to.

In general, if the purpose of an element is just to be a container for absolutely positioned elements, a container on which we apply group transforms (animated or not) and this container has no visible text content, no backgrounds, no borders, no box shadows… then there’s no need to set explicit dimensions on it.

A side effect of this is that, in order to keep our circles in the middle, we need to give them a negative margin of minus the radius (which is half the diameter).

$d: 8em;
$r: .5*$d;

.circle {
  margin: -$r;
  width: $d; height: $d;
}

We also give them the same border-radius, mix-blend-mode and background as in the original article and we get the following result:

Chrome screenshot. Shows the expected result we get at this point after applying these properties.
The expected result so far (live demo).

Well, we get the above in WebKit browsers and Firefox, as Edge doesn’t yet support mix-blend-mode (though you can vote for implementation and please do that if you want to see it supported because your votes do count), so it shows us something a bit ugly:

Edge screenshot. No mix-blend-mode support means the overlapping regions don't look any different from the non-overlapping ones and the result is uglyish.
The Edge result doesn’t look that good.

To get around this, we use @supports:

.circle {
  /* same styles as before */
  
  @supports not (mix-blend-mode: screen) {
    opacity: .75
  }
}

Not perfect, but much better:

Edge screenshot. Shows the result we get when we use partial transparency to get a result that's more like the mix-blend-mode one in other browsers.
Using @supports and opacity to fix the lack of mix-blend-mode support in Edge (live demo).

Now let’s look a bit at the result we want to get:

Screenshot of the desired circular distribution with annotations. The whole thing is split into two halves (a left one and a right one) by a vertical midline. The first three circles are in the right half and have a bluish green background, while the last three of the six circles are in the left half and have a yellowish green background. The circles are numbered starting from the topmost one in the right half and then they go clockwise.
The desired result.

We have six circles in total, three of them in the left half and three others in the right half. They all have a background that’s some kind of green, those in the left half a bit more towards yellow and those in the right half a bit more towards blue.

If we number our circles starting from the topmost one in the right half and then going clockwise, we have that the first three circles are in the right half and have a bluish green background and the last three are in the left half and have a yellowish green background.

At this point, we’ve set the background for all the circles to be the yellowish blue one. This means we need to override it for the first half of the six circles. Since we cannot use CSS variables in selectors, we do this from the Pug code:

- var nc = 6; // number of circles

style .circle:nth-child(-n + #{.5*nc}) { background: #529ca0 }
.watch-face(style=`--nc: ${nc}`)
  - for(var i = 0; i < nc; i++)
    .circle(style=`--i: ${i}`)

In case you need a refresher on this, :nth-child(-n + a) selects the items at the valid indices we get for n ≥ 0 integer values. In our case, a = .5*nc = .5*6 = 3, so our selector is :nth-child(-n + 3).

If we replace n with 0, we get 3, which is a valid index, so our selector matches the third circle.

If we replace n with 1, we get 2, also a valid index, so our selector matches the second circle.

If we replace n with 2, we get 1, again valid, so our selector matches the first circle.

If we replace n with 3, we get 0, which isn’t a valid index, as indices are not 0-based here. At this point, we stop as it becomes clear we won’t be getting any other positive values if we continue.

The following Pen illustrates how this works – the general rule is that :nth-child(-n + a) selects the first a items:

See the Pen by thebabydino (@thebabydino) on CodePen.

Returning to our circular distribution, the result so far can be seen below:

See the Pen by thebabydino (@thebabydino) on CodePen.

Positioning

First off, we make the wrapper relatively positioned and its .circle children absolutely positioned. Now they all overlap in the middle.

See the Pen by thebabydino (@thebabydino) on CodePen.

In order to understand what we need to do next, let’s take a look at the following illustration:

SVG Illustration. Shows the circles in their initial (overlapping dead in the middle) and final positions (with the rightmost one being highlighted in its final position). The segment between the central point of this circle in the initial position and in the final position is a horizontal segment of length equal to the circle radius.
The rightmost circle going from its initial to its final position (live).

The central points of the circles in the initial position are on the same horizontal line and a radius away from the rightmost circle. This means we can get to this final position by a translation of a radius $r along the x axis.

But what about the other circles? Their central points in the final position are also a radius away from their initial position, only along other lines.

SVG Illustration. Shows the circles in their initial (overlapping dead in the middle) and final positions. The segments connecting the initial position of their central points (all dead in the middle) and the final positions of the same points are highlighted. They're all segments of length equal to the circle radius.
All circles: initial position (dead in the middle) is a radius away from the final one for each and every one of them (live).

This means that, if we first rotate their system of coordinates until their x axis coincides with the line between the initial and final position of the central points and then translate them by a radius, we can get them all in the correct final position in a very similar manner.

See the Pen by thebabydino (@thebabydino) on CodePen.

Alright, but rotate each of them by what angle?

Well, we start from the fact that we have 360° on a circle around a point.

See the Pen by thebabydino (@thebabydino) on CodePen.

We have six circles distributed evenly, so the rotation difference between any two consecutive ones is 360°/6 = 60°. Since we don’t need to rotate the rightmost .circle (the second one), that one’s at , which puts the one before (the first one) at -60°, the one after (the second one) at 60° and so on.

See the Pen by thebabydino (@thebabydino) on CodePen.

Note that -60° and 300° = 360° - 60° occupy the same position on the circle, so whether we get there by a clockwise (positive) rotation of 300° or by going 60° the other way around the circle (which gives us the minus sign) doesn’t matter. We’ll be using the -60° option in the code because it makes it easier to spot a convenient pattern in our case.

So our transforms look like this:

.circle {
  &:nth-child(1 /* = 0 + 1 */) {
    transform: rotate(-60deg /* -1·60° = (0 - 1)·360°/6 */) translate($r);
  }
  &:nth-child(2 /* = 1 + 1 */) {
    transform: rotate(  0deg /*  0·60° = (1 - 1)·360°/6 */) translate($r);
  }
  &:nth-child(3 /* = 2 + 1 */) {
    transform: rotate( 60deg /*  1·60° = (2 - 1)·360°/6 */) translate($r);
  }
  &:nth-child(4 /* = 3 + 1 */) {
    transform: rotate(120deg /*  2·60° = (3 - 1)·360°/6 */) translate($r);
  }
  &:nth-child(5 /* = 4 + 1 */) {
    transform: rotate(180deg /*  3·60° = (4 - 1)·360°/6 */) translate($r);
  }
  &:nth-child(6 /* = 5 + 1 */) {
    transform: rotate(240deg /*  4·60° = (5 - 1)·360°/6 */) translate($r);
  }
}

This gives us the distribution we’ve been after:

See the Pen by thebabydino (@thebabydino) on CodePen.

However, it’s very repetitive code that can easily be compacted. For any of them, the rotation angle can be written as a function of the current index and the total number of items:

.circle {
  /* previous styles */
  
  transform: rotate(calc((var(--i) - 1)*360deg/var(--nc))) translate($r);
}

This works in WebKit browsers and Firefox 57+, but fails in Edge and older Firefox browsers due to the lack of support for using calc() inside rotate() functions.

Fortunately, in this case, we have the option of computing and setting the individual rotation angles in the Pug code and then using them as such in the Sass code:

- var nc = 6, ba = 360/nc;

style .circle:nth-child(-n + #{.5*nc}) { background: #529ca0 }
.watch-face
  - for(var i = 0; i < nc; i++)
    .circle(style=`--ca: ${(i - 1)*ba}deg`)
.circle {
  /* previous styles */
  
  transform: rotate(var(--ca)) translate($r);
}

We didn’t really need the previous custom properties for anything else in this case, so we just got rid of them.

We now have a compact code, cross-browser version of the distribution we’ve been after:

See the Pen by thebabydino (@thebabydino) on CodePen.

Good, this means we’re done with the most important part! Now for the fluff…

Finishing up

We take the transform declaration out of the class and put it inside a set of @keyframes. In the class, we replace it with the no translation case:

.circle {
  /* same as before */
  
  transform: rotate(var(--ca))
}

@keyframes circle {
  to { transform: rotate(var(--ca)) translate($r) }
}

We also add the @keyframes set for the pulsing animation on the .watch-face element.

@keyframes pulse {
  0% { transform: scale(.15) rotate(.5turn) }
}

Note that we don’t need both the 0% (from) and 100% (to) keyframes. Whenever these are missing, their values for the animated properties (just the transform property in our case) are generated from the values we’d have on the animated elements without the animation.

In the circle animation case, that’s rotate(var(--ca)). In the pulse animation case, scale(1) gives us the same matrix as none, which is the default value for transform so we don’t even need to set it on the .watch-face element.

We make the animation-duration a Sass variable, so that, if we ever want to change it, we only need to change it in one place. And finally, we set the animation property on both the .watch-face element and the .circle elements.

$t: 4s;

.watch-face {
  position: relative;
  animation: pulse $t cubic-bezier(.5, 0, .5, 1) infinite alternate
}

.circle {
  /* same as before */
  
  animation: circle $t infinite alternate
}

Note that we’re not setting a timing function for the circle animation. This is ease in the original demo and we don’t set it explicitly because it’s the default value.

And that’s it – we have our animated result!

We could also tweak the translation distance so that it’s not exactly $r, but a slightly smaller value (something like .95*$r for example). This can also make the mix-blend-mode effect a bit more interesting:

See the Pen by thebabydino (@thebabydino) on CodePen.

Bonus: the general case!

The above is for six .circle petals in particular. Now we’ll see how we can adapt it so that it works for any number of petals. Wait, do we need to do more than just change the number of circle elements from the Pug code?

Well, let’s see what happens if we do just that:

Screenshots. They show the result we get for nc equal to 6, 8 and 9. When nc is 6, we have the previous case: splitting the whole thing into two halves with a vertical line, we have the first three (bluish green) circles in the right half and the last three (yellowish green) circles in the left half. When nc is 8, we also have the first half of the circles (the first four, bluish green) on one side of a line splitting the assembly into two geometrically symmetrical halves and the last four circles (yellowish green) on the other side of the same line. This line however isn't vertical anymore. In the nc = 9 case, all circles are yellowish green.
The result for nc equal to 6 (left), 8 (middle) and 9 (right).

The results don’t look bad, but they don’t fully follow the same pattern – having the first half of the circles (the bluish green ones) on the right side of a vertical symmetry line and the second half (yellowish green) on the left side.

We’re pretty close in the nc = 8 case, but the symmetry line isn’t vertical. In the nc = 9 case however, all our circles have a yellowish green background.

So let’s see why these things happen and how we can get the results we actually want.

Making :nth-child() work for us

First off, remember we’re making half the number of circles have a bluish green background with this little bit of code:

.circle:nth-child(-n + #{.5*nc}) { background: #529ca0 }

But in the nc = 9 case, we have that .5*nc = .5*9 = 4.5, which makes our selector :nth-child(-n + 4.5). Since 4.5 is not an integer, the selector isn’t valid and the background doesn’t get applied. So the first thing we do here is floor the .5*nc value:

style .circle:nth-child(-n + #{~~(.5*nc)}) { background: #529ca0 }

This is better, as for a nc value of 9, the selector we get is .circle:nth-child(-n + 4), which gets us the first 4 items to apply a bluish green background on them:

See the Pen by thebabydino (@thebabydino) on CodePen.

However, we still don’t have the same number of bluish green and yellowish green circles if nc is odd. In order to fix that, we make the circle in the middle (going from the first to the last) have a gradient background.

By “the circle in the middle” we mean the circle that’s an equal number of circles away from both the start and the end. The following interactive demo illustrates this, as well as the fact that, when the total number of circles is even, we don’t have a middle circle.

See the Pen by thebabydino (@thebabydino) on CodePen.

Alright, how do we get this circle?

Mathematically, this is the intersection between the set containing the first ceil(.5*nc) items and the set containing all but the first floor(.5*nc) items. If nc is even, then floor(.5*nc) and ceil(.5*nc) are equal and our intersection is the empty set . This is illustrated by the following Pen:

See the Pen by thebabydino (@thebabydino) on CodePen.

We get the first ceil(.5*nc) items using :nth-child(-n + #{Math.ceil(.5*nc)}), but what about the other set?

In general, :nth-child(n + a) selects all but the first a - 1 items:

See the Pen by thebabydino (@thebabydino) on CodePen.

So in order to get all but the first floor(.5*nc) items, we use :nth-child(n + #{~~(.5*nc) + 1}).

This means we have the following selector for the middle circle:

:nth-child(n + #{~~(.5*nc) + 1}):nth-child(-n + #{Math.ceil(.5*nc)})

Let’s see what this gives us.

  • If we have 3 items, our selector is :nth-child(n + 2):nth-child(-n + 2), which gets us the second item (the intersection between the {2, 3, 4, ...} and {2, 1} sets)
  • If we have 4 items, our selector is :nth-child(n + 3):nth-child(-n + 2), which doesn’t catch anything (the intersection between the {3, 4, 5, ...} and {2, 1} sets is the empty set )
  • If we have 5 items, our selector is :nth-child(n + 3):nth-child(-n + 3), which gets us the third item (the intersection between the {3, 4, 5, ...} and {3, 2, 1} sets)
  • If we have 6 items, our selector is :nth-child(n + 4):nth-child(-n + 3), which doesn’t catch anything (the intersection between the {4, 5, 6, ...} and {3, 2, 1} sets is the empty set )
  • If we have 7 items, our selector is :nth-child(n + 4):nth-child(-n + 4), which gets us the fourth item (the intersection between the {4, 5, 6, ...} and {4, 3, 2, 1} sets)
  • If we have 8 items, our selector is :nth-child(n + 5):nth-child(-n + 4), which doesn’t catch anything (the intersection between the {5, 6, 7, ...} and {4, 3, 2, 1} sets is the empty set )
  • If we have 9 items, our selector is :nth-child(n + 5):nth-child(-n + 5), which gets us the fifth item (the intersection between the {5, 6, 7, ...} and {5, 4, 3, 2, 1} sets)

Now that we can select the item in the middle when we have an odd number of them in total, let’s give it a gradient background:

- var nc = 6, ba = 360/nc;

style .circle:nth-child(-n + #{~~(.5*nc)}) { background: var(--c0) }
  | .circle:nth-child(n + #{~~(.5*nc) + 1}):nth-child(-n + #{Math.ceil(.5*nc)}) {
  |   background: linear-gradient(var(--c0), var(--c1))
  | }
.watch-face(style=`--c0: #529ca0; --c1: #61bea2`)
  - for(var i = 0; i < nc; i++)
    .circle(style=`--ca: ${(i - 1)*ba}deg`)

The reason why we use a top to bottom gradient is that, ultimately, we want this item to be at the bottom, split into two halves by the vertical symmetry line of the assembly. This means we first need to rotate it until its x axis points down and then translate it down along this new direction of its x axis. In this position, the top of the item is in the right half of the assembly and the bottom of the item is in the left half of the assembly. So, if we want a gradient from the right side of the assembly to the left side of the assembly, this is a top to bottom gradient on that actual .circle element.

See the Pen by thebabydino (@thebabydino) on CodePen.

Using this technique, we have now solved the issue of the backgrounds for the general case:

See the Pen by thebabydino (@thebabydino) on CodePen.

Now all that’s left to do is make the symmetry axis vertical.

Taming the angles

In order to see what we need to do here, let’s focus on the desired positioning in the top part. There, we want to always have two circles (the first in DOM order on the right and the last in DOM order on the left) symmetrically positioned with respect to the vertical axis that splits our assembly into two halves that mirror each other.

See the Pen by thebabydino (@thebabydino) on CodePen.

The fact that they’re symmetrical means the vertical axis splits the angular distance between them ba (which is 360° divided by the total number of circles nc) into two equal halves.

SVG Illustration. Shows the circles distributed around a central point with the two at the top (symmetrical with respect to the vertical axis that splits the whole assembly into two mirrored halves) being highlighted. This middle axis splits the angular distance between the central points of these two circles into two equal halves.
Angles formed by vertical symmetry line and the radial lines to the central points of the top angles are both equal to half a base angle (live).

So both are half a base angle (where the base angle ba is 360° divided by the total number of circles nc) away from the vertical symmetry axis, one in the clockwise direction and the other one the other way.

The upper half of the symmetry axis is at -90° (which is equivalent to 270°).

SVG Illustration. Shows degrees around a circle in 90° steps. We start from the right (3 o'clock). This is the 0° and, in general, any multiple of 360° angle. Going clockwise, we have 90° down (at 6 o'clock), 180° on the left (at 9 o'clock) and 270° at the top (12 o'clock). Going the other way from 0°, we have -90° at the top (12 o'clock), -180° on the left (9 o'clock) and so on.
Degree values around the circle (live).

So in order to get to the first circle in DOM order (the one at the top on the right), we start from , go by 90° in the negative direction and then by half a base angle back in the positive direction (clockwise). This puts the first circle at .5*ba - 90 degrees.

SVG Illustration. Shows graphically how to get the angular position of the first circle. Starting from 0° (3 o'clock), we go in the negative direction by 90° (getting at 12 o'clock). Afterwards, we go back in the positive direction by half a base angle.
How to get the angle the first circle is placed at (live).

After that, every other circle is at the angle of the previous circle plus a base angle. This way, we have:

  • the first circle (index 0, selector :nth-child(1)) is at ca₀ = .5*ba - 90 degrees
  • the second circle (index 1, selector :nth-child(2)) is at ca₁ = ca₀ + ba = ca₀ + 1*ba degrees
  • the third circle (index 2, selector :nth-child(3)u) is at ca₂ = ca₁ + ba = ca₀ + ba + ba = ca₀ + 2*ba degrees
  • in general, the circle of index k is at caₖ = caₖ₋₁ + ba = ca₀ + k*ba degrees

So the the current angle of the circle at index i is .5*ba - 90 + i*ba = (i + .5)*ba - 90 degrees:

- var nc = 6, ba = 360/nc;

//- same as before
.watch-face(style=`--c0: #529ca0; --c1: #61bea2`)
  - for(var i = 0; i < nc; i++)
    .circle(style=`--ca: ${(i + .5)*ba - 90}deg`)

This gives our final Pen, where we only need to change nc from the Pug code to change the result:

See the Pen by thebabydino (@thebabydino) on CodePen.