Slice and Dice a Disc with CSS

I recently came across an interesting sliced disc design. The disc had a diagonal gradient and was split into horizontal slices, offset a bit from left to right. Naturally, I started to think what would the most efficient way of doing it with CSS be.

Screenshot. Shows a diagonal gradient disc that has been split into eight horizontal slices, one on top of the other, with tiny gaps in between them and slightly offset to the left or right (with respect to the vertical axis of the disc) based on parity.
Sliced gradient disc.

The first thought was that this should be doable with border-radius, right? Well, no! The thing with border-radius is that it creates an elliptical corner whose ends are tangent to the edges it joins.

My second thought was to use a circle() clipping path. Well, turns out this solution works like a charm, so let’s take a close look at it!

Note that the following demos won’t work in Edge as Edge doesn’t yet support clip-path on HTML elements. It could all be emulated with nested elements with overflow: hidden in order to have cross-browser support, but, for simplicity, we dissect the clip-path method in this article.

Slicing a disc into equal parts

As far as the HTML structure goes, we generate it with a preprocessor to avoid repetition. First off, we decide upon a number of slices n. Then we pass this number to the CSS as a custom property --n. Finally, we generate the slices in a loop, passing the index of each to the CSS as another custom property --i.

- var n = 8; style :root { --n: #{n} } - for(var i = 0; i < n; i++) .slice(style=`--i: ${i}`)

Moving on to the CSS, we first decide upon a diameter $d for our disc. This is the width of our slices. The height is the diameter divided by the number of items calc(#{$d}/var(--n)).

In order to be able to tell them apart, we give our slices dummy backgrounds determined by parity.

$d: 20em; .slice { --parity: 0; width: $d; height: calc(#{$d}/var(--n)); background: hsl(36, calc(var(--parity)*100%), calc(80% - var(--parity)*30%)); &:nth-of-type(2n) { --parity: 1 }
}

We also position our slices in the middle with a column flex layout on their container (the body in our case).

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

To get the disc shape we use a circle() clipping path having the radius $r equal to half the diameter .5*$d and the central point dead in the middle of the assembly. Since we set this clip-path on the slices, the position of the central point for each slice is relative to the slice itself.

Horizontally, it’s always in the middle, at 50% of the slice. Vertically, it needs to be in the middle of the assembly, so that’s where the total number of items and the item’s index which we’ve passed as CSS variables from the preprocessor code come into play.

In the middle of the assembly means at half the height of the assembly from the top of the assembly. Half the height of the assembly is half the diameter .5*$d, which is equivalent to the radius $r. But this value is relative to the whole assembly and we need one that’s relative to the current slice. In order to get this, we subtract the vertical position of the current slice relative to the assembly, that is, how far the top of the current slice is relative to the top of the assembly.

The first slice (of index --i: 0) is at the very top of the assembly, so the amount we subtract in this case is 0.

The second slice (of index --i: 1) is at one slice height from the top of the assembly (the space occupied by the first slice), so the amount we subtract in this case is 1 slice heights.

The third slice (of index --i: 2) is at two slice heights from the top of the assembly (the space occupied by the first and second slices), so the amount we subtract in this case is 2 slice heights.

In the general case, the amount we subtract for each slice is the slice’s index (--i) multiplied by one slice height.

--h: calc(#{d}/var(--n)); /* slice height */
clip-path: circle($r at 50% calc(#{$r} - var(--i)*var(--h))

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

After doing this, we can offset the slices based on parity.

--sign: calc(1 - 2*var(--parity));
transform: translate(calc(var(--sign)*2%))

We now have our sliced disc!

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

Spacing out the slices

The first thought that comes to mind here is to use a margin on each slice.

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

This may be a good result in some cases, but what if we don’t want our disc to get elongated?

Well, we have the option of limiting the background to the content-box and adding a vertical padding:

box-sizing: border-box;
padding: .125em 0;
background: hsl(36, calc(var(--parity)*100%), calc(80% - var(--parity)*30%)) content-box;

Of course, in this case, we need to make sure box-sizing is set to border-box so that the vertical padding doesn’t add to the height.

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

The one little problem in this case is that it also cuts off the top of the first slice and the bottom of the last slice. This may not be an issue in some cases and we can always reset the padding-top on the :first-of-type and the padding-bottom on the :last-of-type to 0:

.slice { /* other styles */ padding: .125em 0; &:first-of-type { padding-top: 0 } &:last-of-type { padding-bottom: 0 }
}

However, we also have a one-line solution to this problem of creating gaps in between the slices: add a mask on the container!

This mask is a repeating-linear-gradient() which creates transparent stripes of the thickness of the gap $g, repeats itself after a slice height and is limited to the disc diameter $d horizontally and to the disc diameter $d minus a gap $g vertically (so that we don’t mask out the very top and the very bottom as we also did initially with the padding approach).

mask: repeating-linear-gradient(red 0, red calc(var(--h) - #{$g}), transparent 0, transparent var(--h)) 50% calc(50% - #{.5*$g})/ #{$d} calc(#{$d} - #{$g})

Note that in this case we need to set the slice height variable --h on the container as we’re using it for the mask.

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

Continuous background

In order to have a continuous gradient background, we need to give this background a height equal to that of the disc and set its vertical position relative to each slice such that it always starts from the top of the assembly… wherever that may be located relative to the slice.

The top of the first slice (of index --i: 0) coincides with that of the assembly, so our background starts from 0 vertically.

The top of the second slice (of index --i: 1) is 1 slice height below that of the assembly, so its background starts from 1 slice height above vertically. Since the positive direction of the y axis is down, this means our background-position along the y axis is calc(-1*var(--h)) in this case.

The top of the third slice (of index --i: 2) is 2 slice heights below that of the assembly, so its background starts from 2 slice heights above vertically. This makes our background-position along the y axis is calc(-2*var(--h)).

We notice a pattern here: in general, the background-position along the y axis for a slice is calc(-1*var(--i)*var(--h)).

background: linear-gradient(#eccc05, #c26e4c, #a63959, #4e2255, #333f3d) /* background-position */ 50% calc(-1*var(--i)*var(--h))/ 100% $d /* background-size */

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

But if we want a left to right gradient, then our background isn’t continuous anymore, something that becomes really obvious if we tweak the stop positions a bit in order to have abrupt changes:

background: linear-gradient(90deg, #eccc05 33%, #c26e4c 0, #a63959 67%, #4e2255 0, #333f3d) /* background-position */ 50% calc(-1*var(--i)*var(--h))/ 100% $d /* background-size */

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

In order to fix this issue, we set the offset as a Sass variable $o, set the horizontal background-size to the slice width (100% or $d) plus twice the offset and make sure we attach the background for the slices that move to the left (in the negative direction of the x axis, so by -$o) on the left side of the slice (background-position along the x axis is 0%) and for the slices that move to the right (in the positive direction of the x axis, so by $o) on the right side of the slice (background-position along the x axis is 100%).

$o: 2%;
transform: translate(calc(var(--sign)*#{$o}));
background: linear-gradient(90deg, #eccc05 33%, #c26e4c 0, #a63959 67%, #4e2255 0, #333f3d) /* background-position */ calc((1 - var(--parity))*100%) calc(-1*var(--i)*var(--h))/ calc(100% + #{2*$o}) $d /* background-size */

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

This works for gradients at any angle, as it can be seen in the interactive demo below – drag to change the gradient angle:

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

It also works for images, though in this case we need to remove the second background-size value so the image doesn’t get distorted, which leaves us with the caveat of getting vertical repetition if the image’s aspect ratio is greater than calc(#{$d} + #{2*$o}) : #{$d}. This isn’t the case for the square image we’re using below, but it’s still something to keep in mind.

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

Another thing to note is that above, the top of the image is attached to the top of of the assembly. If we want the middle of the image to be attached to the middle of the assembly, we need to tweak the vertical component of the background-position a bit.

First off, to attach the middle of the image to the middle of a slice, we use a background-position value of 50%. But we don’t want the middle of the image in the middle of each slice, we want it in the middle of the assembly for all slices. We already know the distance from the top of each slice to the vertical midpoint of the whole assembly – it’s the y coordinate of the clipping circle’s central point:

--y: calc(#{$r} - var(--i)*var(--h));
clip-path: circle($r at 50% var(--y))

The distance from the vertical midpoint of each slice to that of the assembly is this value --y minus half a slice’s height. So it results that the background-position we need along the y axis in order to have the vertical midpoint of the image attached to that of the assembly is calc(50% + var(--y) - .5*var(--h)).

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

Incremental slices

This means our slices don’t have the same height anymore. For example, the first one could have a unit height, the second one twice this height, the third one three times this height and so on…

The added heights of all these slices should equal the disc diameter. In other words, we should have the following equality:

h + 2*h + 3*h + ... + n*h = d

This can also be written as:

h*(1 + 2 + 3 + ... + n) = d

which makes it easier to notice something! Within the parenthesis, we have the sum of the first n natural numbers, which is always n*(n + 1)/2!

So our equality becomes:

h*n*(n + 1)/2 = d

This allows us to get the unit height h:

h = 2*d/n/(n + 1)

Applying this to our demo, we have:

--h: calc(#{2*$d}/var(--n)/(var(--n) + 1));
height: calc((var(--i) + 1)*var(--h));

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

Just like in the case of equal slices, the y coordinate of the central point of the clipping circle() is the disc radius $r minus the distance from the top of the assembly to the top of the current slice. This is the sum of the heights of all previous slices.

In the case of the first slice (--i: 0), we have no previous slice, so this sum is 0.

In the case of the second slice (--i: 1), we only have the first slice before and its height is the unit height (--h).

In the case of the third slice (--i: 2), the sum we want is that between the height of the first slice, which equals the unit height and that of the second slice, which is twice the unit height. That’s calc(var(--h) + 2*var(--h)) or calc(var(--h)*(1 + 2)).

In the case of the third slice (--i: 3), the sum is that between the height of the first slice, which equals the unit height, that of the second slice, which is twice the unit height and that of the third slice, which is three times the unit height. That’s calc(var(--h) + 2*var(--h) + 3*var(--h)) or calc(var(--h)*(1 + 2 + 3)).

Now we can see a pattern emerging! For every slice of index --i, we have that the added height of its previous slices is the unit height --h times the sum of the first --i natural numbers (and the sum of the first --i natural numbers is calc(var(--i)*(var(--i) + 1)/2)). This means our clip-path value becomes:

circle($r at 50% calc(var(--h)*var(--i)*(var(--i) + 1)/2))

We add the offset back in and we have the following result:

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

Sadly, having incremental slices means the repeating-linear-gradient() mask method of creating gaps cannot work anymore. What still works however just fine is the vertical padding method and we can set the padding values such that the top one is 0 for the first slice and the bottom one is 0 for the last slice.

padding: calc(var(--i)*#{$g}/var(--n)) /* top */ 0 /* lateral */ calc((var(--n) - 1 - var(--i))*#{$g}/var(--n)) /* bottom */

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

For a gradient background, the main idea remains the same as in the case of the equal slices. There are just two things we need to take into account.

One, the background-position along the y axis is minus the distance (in absolute value) between the top of the assembly and the top of the current slice. This distance isn’t calc(var(--i)*var(--h)) like in the case of equal slices of height --h anymore. Instead it’s, as computed a bit earlier, calc(var(--i)*(var(--i) + 1)/2*var(--h)). So the background-position along the y axis is calc(-1*var(--i)*(var(--i) + 1)/2*var(--h)).

And two, we want our background clipped to the content-box so that we keep the gaps, but we need to keep the background-origin to its initial value of padding-box so that our gradient stays continuous.

background: linear-gradient(var(--a), #eccc05, #c26e4c, #a63959, #4e2255, #333f3d) /* background-position */ calc((1 - var(--parity))*100%) /* x component */ calc(-1*var(--i)*(var(--i) + 1)/2*var(--h)) /* y component */ / /* background-size */ calc(100% + #{2*$o}) $d padding-box /* background-origin */ content-box /* background-clip */;

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

For an image background whose midpoint is attached to the middle of our assembly, we need to take into account the fact that half a slice height isn’t the same value for all slices anymore. Now the height of a slice is calc((var(--i) + 1)*var(--h)), so this is the value we need to subtract in the formula for the y component of the background-position.

--y: calc(#{$r} - .5*var(--i)*(var(--i) + 1)*var(--h));
background: url(/amur_leopard.jpg) /* background-position */ calc((1 - var(--parity))*100%) /* x component */ calc(50% + var(--y) - .5*(var(--i) + 1)*var(--h)) /* y component */ / /* background-size */ calc(100% + #{2*$o}) padding-box /* background-origin */ content-box /* background-clip */;
clip-path: circle($r at 50% var(--y));

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

Vertical slices

We can also slice our disc along the other direction. This means removing the flex-direction: column declaration from the container and letting the flex-direction be the initial one (row), switching the width and the height, the x and y coordinates of the circular clipping path’s central point, the direction along which we shift the slices, the dimensions and x and y positions of the masking gradient, which we also need to rotate so that it goes along the x axis.

body { /* same as before */ --w: calc(#{$d}/var(--n)); mask: repeating-linear-gradient(90deg, red 0, red calc(var(--w) - #{$g}), transparent 0, transparent var(--w)) calc(50% - #{.5*$g}) 50% / calc(#{$d} - #{$g}) #{$d}
} .slice { /* same as before */ width: var(--w); height: $d; transform: translatey(calc(var(--sign)*2%)); background: hsl(36, calc(var(--parity)*100%), calc(80% - var(--parity)*30%)); clip-path: circle($r at calc(#{$r} - var(--i)*var(--w)) 50%)
}

This gives us equal vertical slices with alternating backgrounds:

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

For the gradient case, we need to also reverse the two background dimensions and the background positions along the x and y axes:

background: linear-gradient(135deg, #eccc05 15%, #c26e4c, #a63959, #4e2255, #333f3d 85%) /* background-position */ calc(-1*var(--i)*var(--w)) calc((1 - var(--parity))*100%)/ #{$d} calc(100% + #{2*$o}) /* background-size */

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

For incremental slices, we combine the incremental case with the vertical case, which means swapping the values we have for the previous incremental case along the two axes:

--w: calc(#{2*$d}/var(--n)/(var(--n) + 1));
width: calc((var(--i) + 1)*var(--w)); height: $d;
clip-path: circle($r at calc(#{$r} - .5*var(--i)*(var(--i) + 1)*var(--w)) 50%);

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

To create the gaps, we use the padding method. But since we’re now in the vertical case, we need horizontal paddings, on the left and on the right and to make sure the padding-left for the first slice is 0 and the padding-right for the last slice is also 0:

box-sizing: border-box;
padding: 0 /* top */ calc((var(--n) - 1 - var(--i))*#{$g}/var(--n)) /* right */ 0 /* bottom */ calc(var(--i)*#{$g}/var(--n)) /* left */;
background: hsl(36, calc(var(--parity)*100%), calc(80% - var(--parity)*30%)) content-box

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

Finally, we have the gradient case:

background: linear-gradient(135deg, #eccc05 15%, #c26e4c, #a63959, #4e2255, #333f3d 85%) /* background-position */ calc(-.5*var(--i)*(var(--i) + 1)*var(--w)) /* x component */ calc((1 - var(--parity))*100%) /* y component */ / /* background-size */ #{$d} calc(100% + #{2*$o}) padding-box /* background-origin */ content-box /* background-clip */;

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

2D case

Again, we generate it with a bit of Pug, the total number of items being the product between the number of columns and the number of rows. For simplicity, we keep the number of rows and the number of columns equal.

- var n = 8, m = Math.pow(n, 2); style :root { --n: #{n}; --i: 0; --j: 0 } - for(var i = 1; i < n; i++) { | .tile:nth-of-type(#{n}n + #{i + 1}) { --i: #{i} } | .tile:nth-of-type(n + #{n*i + 1}) { --j: #{i} } - }
- for(var i = 0; i < m; i++) .tile

We’ve also passed the column and row indices (--i and --j respectively) to the CSS.

Since we’re in the 2D case, we switch from using a 1D layout (flex) to using a 2D one (grid). We also start with the disc diameter $d and, given the number of columns is equal to that of rows (--n), our disc gets divided into identical tiles of edge length --l: calc(#{$d}/var(--n)).

$d: 20em; body { --l: calc(#{$d}/var(--n)); display: grid; place-content: center; grid-template: repeat(var(--n), var(--l))/ repeat(var(--n), var(--l))
}

To create the gaps in between the tiles, we use the padding approach on the .tile elements and combine the horizontal and vertical cases such that we have the padding-top for the first row is 0, the padding-left for the first column is 0, the padding-bottom for the last row is 0 and the padding-right for the last-column is 0.

padding: calc(var(--j)*#{$g}/var(--n)) /* top */ calc((var(--n) - 1 - var(--i))*#{$g}/var(--n)) /* right */ calc((var(--n) - 1 - var(--j))*#{$g}/var(--n)) /* bottom */ calc(var(--i)*#{$g}/var(--n)) /* left */

Note that we’ve used the row index --j for the top to bottom direction (vertical paddings) and the column index --i from the left to right direction (lateral paddings).

To get the disc shape, we again combine the horizontal and vertical cases, using the column index --i to get the x coordinate of the circular clipping path’s central point and the row index --j to get its y coordinate.

clip-path: circle($r at calc(#{$r} - var(--i)*var(--l)) calc(#{$r} - var(--j)*var(--l)))

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

For a gradient background, it’s again combining the horizontal and the vertical cases and taking into account that here we have no offset at this point, which means the background-size is the disc diameter $d along both axes.

background: linear-gradient(135deg, #eccc05 15%, #c26e4c, #a63959, #4e2255, #333f3d 85%) /* background-position */ calc(-1*var(--i)*var(--l)) calc(-1*var(--j)*var(--l)) / #{$d} #{$d} /* background-size */ padding-box /* background-origin */ content-box /* background-clip */

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

For an image background, we remove the second background-size value so we prevent the image from getting stretched if it’s not square. We also adapt the code for attaching the image’s midpoint to that of the grid from the 1D case to the 2D case:

--x: calc(#{$r} - var(--i)*var(--l));
--y: calc(#{$r} - var(--j)*var(--l));
background: url(/amur_leopard.jpg) /* background-position */ calc(50% + var(--x) - .5*var(--l)) calc(50% + var(--y) - .5*var(--l)) / #{$d} /* background-size */ padding-box /* background-origin */ content-box /* background-clip */;
clip-path: circle($r at var(--x) var(--y))

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

In the incremental case, we don’t have the same dimensions for all tiles, so we use auto sizing for grid-template:

body { /* same as before */ grid-template: repeat(var(--n), auto)/ repeat(var(--n), auto)
}

Just like in the 1D case, we start by computing a unit edge length --u:

--u: calc(#{2*$d}/var(--n)/(var(--n) + 1))

We then set incremental dimensions along both axes for our tile elements:

width: calc((var(--i) + 1)*var(--u));
height: calc((var(--j) + 1)*var(--u))

We also need to adapt the coordinates of the clipping circle’s central point to the incremental case:

clip-path: circle($r at calc(#{$r} - .5*var(--i)*(var(--i) + 1)*var(--u)) calc(#{$r} - .5*var(--j)*(var(--j) + 1)*var(--u)))

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

For a gradient background, we adapt the equal tiles version to the incremental case. This means tweaking the background-position as we did before for the incremental slices, only this time we do it along both axes, not just along one:

background: linear-gradient(135deg, #eccc05 15%, #c26e4c, #a63959, #4e2255, #333f3d 85%) /* background-position */ calc(-.5*var(--i)*(var(--i) + 1)*var(--l)) calc(-.5*var(--j)*(var(--j) + 1)*var(--l)) / #{$d} #{$d} /* background-size */ padding-box /* background-origin */ content-box /* background-clip */

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

Finally, we have the image background option for the incremental 2D case:

background: url(/amur_leopard.jpg) /* background-position */ calc(50% + var(--x) - .5*(var(--i) + 1)*var(--u)) calc(50% + var(--y) - .5*(var(--j) + 1)*var(--u)) / #{$d} /* background-size */ padding-box /* background-origin */ content-box /* background-clip */

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

There are probably more variations we could be coming up with, but we’ll stop here. If you have more ideas on how to push this further, I’d love to hear about them!

The post Slice and Dice a Disc with CSS appeared first on CSS-Tricks.

DRY State Switching With CSS Variables: Fallbacks and Invalid Values

This is the second post in a two-part series that looks into the way CSS variables can be used to make the code for complex layouts and interactions less difficult to write and a lot easier to maintain. The first installment walks through various use cases where this technique applies. This post covers the use of fallbacks and invalid values to extend the technique to non-numeric values.

The strategy of using CSS Variables to drive the switching of layouts and interactions that we covered in the first post in this series comes with one major caveat: it only works with numeric values — lengths, percentages, angles, durations, frequencies, unit-less number values and so on. As a result, it can be really frustrating to know that you’re able to switch the computed values of more than ten properties with a single CSS variable, but then you need to explicitly switch the non-numeric values of properties like flex-direction or text-align from row to column or from left to right or the other way around.

One example would be the one below, where the text-align property depends on parity and the flex-direction depends on whether we are viewing the front end in the wide screen scenario or not.

Screenshot collage. On the left, we have the wide screen scenario, with four paragraphs as the four horizontal, offset based on parity slices of a disc. The slice numbering position is either to the right or left of the actual text content, depending on parity. The text alignment also depends on parity. In the middle, we have the normal screen case. The paragraphs are now full width rectangular elements. On the right, we have the narrow screen case. The paragraph numbering is always above the actual text content in this case.
Screenshot collage.

I complained about this and got a very interesting suggestion in return that makes use of CSS variable fallbacks and invalid values. It was interesting and gives us something new to work with, so let’s start with a short recap of what these are and go from there!

Fallback values

The fallback value of a CSS variable is the second and optional argument of the var() function. For example, let’s consider we have some .box elements whose background is set to a variable of --c:

.box { background: var(--c, #ccc) }

If we haven’t explicitly specified a value for the --c variable elsewhere, then the fallback value #ccc is used.

Now let’s say some of these boxes have a class of .special. Here, we can specify --c as being some kind of orange:

.special { --c: #f90 }

This way, the boxes with this .special class have an orange background, while the others use the light grey fallback.

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

There are a few things to note here.

First off, the fallback can be another CSS variable, which can have a CSS variable fallback itself and… we can fall down a really deep rabbit hole this way!

background: var(--c, var(--c0, var(--c1, var(--c2, var(--c3, var(--c4, #ccc))))))

Secondly, a comma separated list is a perfectly valid fallback value. In fact, everything specified after the first comma inside the var() function constitutes the fallback value, as seen in the example below:

background: linear-gradient(90deg, var(--stop-list, #ccc, #f90))

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

And last, but certainly not least, we can have different fallback values for the same variable used in different places, as illustrated by this example:

$highlight: #f90; a { border: solid 2px var(--c, #{rgba($highlight, 0)}) color: var(--c, #ccc); &:hover, &:focus { --c: #{$highlight} }
}

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

Invalid values

First off, I want to clarify what I mean by this. “Invalid values” is shorter and easier to remember, but what it really refers to any value that makes a declaration invalid at computed value time.

For example, consider the following piece of code:

--c: 1em;
background: var(--c)

1em is a valid length value, but this is not a valid value for the background-color property, so here this property will take its initial value (which is transparent) instead.

Putting it all together

Let’s say we have a bunch of paragraphs where we change the lightness of the color value to switch between black and white based on parity (as explained in the previous post in this series):

p { --i: 0; /* for --i: 0 (odd), the lightness is 0*100% = 0% (black) * for --i: 1 (even), the lightness is 1*100% = 100% (white)* / color: hsl(0, 0%, calc(var(--i)*100%)); &:nth-child(2n) { --i: 1 }
}

We also want the odd paragraphs to be right-aligned, while keeping the even ones left-aligned. In order to achieve this, we introduce a --parity variable which we don’t set explicitly in the general case — only for even items. What we do set in the general case is our previous variable, --i. We set it to the value of --parity with a fallback of 0:

p { --i: var(--parity, 0); color: hsl(0, 0%, calc(var(--i)*100%)); &:nth-child(2n) { --parity: 1 }
}

So far, this achieves exactly the same as the previous version of our code. However, if we take advantage of the fact that, we can use different fallback values in different places for the same variable, then we can also set text-align to the value of --parity using a fallback of… right!

text-align: var(--parity, right)

In the general case, where we’re not setting --parity explicitly; text-align uses the fallback right, which is a valid value, so we have right alignment. For the even items however, we’re setting --parity explicitly to 1, which is not a valid value for text-align. That means text-align reverts to its initial value, which is left.

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

Now we have right alignment for the odd items and left alignment for the even items while still putting a single CSS variable to use!

Dissecting a more complex example

Let’s consider we want to get the result below:

Screenshot. Shows a bunch of numbered cards. Odd ones have the numbering on the left, while even ones have it on the right. Odd ones are right-aligned, while even ones are left-aligned. Odd ones are shifted a bit to the right and have a bit of a clockwise rotation, while even ones are shifted and rotated by the same amounts, but in the opposite directions. All have a grey to orange gradient background, but for the odd ones, this gradient goes from left to right, while for the even ones it goes from right to left.
Numbered cards where even cards have symmetrical styles with respect to odd cards.

We create these cards with a paragraph element <p> for each one. We switch their box-sizing to border-box, then give them a width, a max-width, a padding and a margin. We also change the default font.

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

We’ve also added a dummy outline just to see the boundaries of these elements.

Next, let’s add the numbering using CSS counters and a :before pseudo-element:

p { /* same code as before */ counter-increment: c; &:before { content: counter(c, decimal-leading-zero) }
}

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

Now, we’ll give our paragraphs a flex layout and increase the size of the numbering:

p { /* same code as before */ display: flex; align-items: center; &:before { font-size: 2em; content: counter(c, decimal-leading-zero); }
}

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

Now comes the interesting part!

We set a switch --i that changes value with the parity — it’s 0 for the odd items and 1 for the even ones.

p { /* same code as before */ --i: 0; &:nth-child(2n) { --i: 1 }
}

Next, we want the numbering to be on the left for the odd items and on the right for the even ones. We achieve this via the order property. The initial value for this property is 0, for both the :before pseudo-element and the paragraph’s text content. If we set this order property to 1 for the numbering (the :before pseudo-element) of the even elements, then this moves the numbering after the content.

p { /* same code as before */ --i: 0; &:before { /* same code as before */ /* we don't really need to set order explicitly as 0 is the initial value */ order: 0; } &:nth-child(2n) { --i: 1; &:before { order: 1 } }
}

You may notice that, in this case, the order value is the same as the switch --i value, so in order to simplify things, we set the order to the switch value.

p { /* same code as before */ --i: 0; &:before { /* same code as before */ order: var(--i) } &:nth-child(2n) { --i: 1 }
}

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

Now we want a bit of spacing (let’s say $gap) in between the numbers and the paragraph text. This can be achieved with a lateral margin on the :before.

For the odd items, the item numbers are on the left, so we need a non-zero margin-right. For the even items, the item numbers are on the right, so we need a non-zero margin-left.

When the parity switch value is 0 for the odd items, the left margin is 0 = 0*$gap, while the right margin is $gap = 1*$gap = (1 - 0)*$gap.

Similarly for the even items, when the parity switch value is 1, the left margin is $gap = 1*$gap, while the right margin is 0 = 0*$gap = (1 - 1)*$gap.

The result in both cases is that margin-left is the parity switch value times the margin value ($gap), while margin-right is 1 minus the parity switch value, all multiplied with the margin value.

$gap: .75em; p { /* same code as before */ --i: 0; &:before { /* same code as before */ margin: 0 /* top */ calc((1 - var(--i))*#{$gap}) /* right */ 0 /* bottom */ calc(var(--i)*#{$gap}) /* left */; } &:nth-child(2n) { --i: 1 }
}

If we use the complementary value (1 - var(--i)) in more than one place, then it’s probably best to set it to another CSS variable --j.

$gap: .75em; p { /* same code as before */ --i: 0; --j: calc(1 - var(--i)); &:before { /* same code as before */ margin: 0 /* top */ calc(var(--j)*#{$gap}) /* right */ 0 /* bottom */ calc(var(--i)*#{$gap}) /* left */; } &:nth-child(2n) { --i: 1 }
}

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

Next, we want to give these items a proper background. This is a grey to orange gradient, going from left to right (or along a 90deg angle) in the case of odd items (parity switch --i: 0) and from right to left (at a -90deg angle) in the case of even items (parity switch --i: 1).

This means the absolute value of the gradient angle is the same (90deg), only the sign is different — it’s +1 for the odd items (--i: 0) and -1 for the even items (--i: 1).

In order to switch the sign, we use the approach we covered in the first post:

/* * for --i: 0, we have 1 - 2*0 = 1 - 0 = +1 * for --i: 1, we have 1 - 2*1 = 1 - 2 = -1 */
--s: calc(1 - 2*var(--i))

This way, our code becomes:

p { /* same code as before */ --i: 0; --s: calc(1 - 2*var(--i)); background: linear-gradient(calc(var(--s)*90deg), #ccc, #f90); &:nth-child(2n) { --i: 1 }
}

We can also remove the dummy outline since we don’t need it at this point:

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

Next, we do something similar for the transform property.

The odd items are translated a bit to the right (in the positive direction of the x axis) and rotated a bit in the clockwise (positive) direction, while the even items are translated a bit to the left (in the negative direction of the x axis) and rotated a bit in the other (negative) direction.

The translation and rotation amounts are the same; only the signs differ.

For the odd items, the transform chain is:

translate(10%) rotate(5deg)

While for the even items, we have:

translate(-10%) rotate(-5deg)

Using our sign --s variable, the unified code is:

p { /* same code as before */ --i: 0; --s: calc(1 - 2*var(--i)); transform: translate(calc(var(--s)*10%)) rotate(calc(var(--s)*5deg)); &:nth-child(2n) { --i: 1 }
}

This is now starting to look like something!

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

The next step is to round the card corners. For the odd cards, we want the corners on the left side to be rounded to a radius of half the height. For the even items, we want the corners on the right side to be rounded to the same radius.

Given we don’t know the heights of our cards, we just use a ridiculously large value, say something like 50vh, which gets scaled down to fit due to the way border-radius works. In our case, this means scaled down to whichever is smaller between half the item height (since going vertically has both a top and bottom rounded corner on the same side) and the full item width (since going horizontally has one rounded corner; either on the left or on the right, but not on both the right and the left).

This means we want the corners on the left to have this radius ($r: 50vh) for odd items (--i: 0) and the ones on the right to have the same radius for even items (--i: 1). As a result, we do something pretty similar to the numbering margin case:

$r: 50vh; p { /* same code as before */ --i: 0; --j: calc(1 - var(--i)); --r0: calc(var(--j)*#{$r}); --r1: calc(var(--i)*#{$r}); /* clockwise from the top left */ border-radius: var(--r0) /* top left */ var(--r1) /* top right */ var(--r1) /* bottom right */ var(--r0) /* bottom left */; &:nth-child(2n) { --i: 1 }
}

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

Now comes the truly interesting part — text alignment! We want the text in the odd items to be aligned right, while the text in the even items is aligned left. The only problem is that text-align doesn’t take a number value so, no addition or multiplication tricks can help us here.

What can help is combining the use of fallback and invalid values for CSS variables. To do this, we introduce another parity variable --p and it’s this variable that we actually set to 1 for even items. Unlike --i before, we never set --p explicitly for the general case as we want different fallback values of this variable to be used for different properties.

As for --i, we set it to --p with a fallback value of 0. This fallback value of 0 is the value that actually gets used in the general case, since we never explicitly set --p there. For the even case, where we explicitly set --p to 1, --i becomes 1 as well.

At the same time, we set the text-align property to --p with a fallback value of right in the general case. In the even case, where we have --p explicitly set to 1, the text-align value becomes invalid (because we have set text-align to the value of --p and --p is now 1, which is not a valid value for text-align), so the text reverts to being aligned to the left.

p { /* same code as before */ --i: var(--p, 0); text-align: var(--p, right); &:nth-child(2n) { --p: 1 }
}

This gives us the result we’ve been after:

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

Handling responsiveness

While our cards example looks great on wider screens, the same can’t be said when shrink things down.

Screenshot collage. Since the width of the cards depends on the viewport width, the viewport may get too narrow to allow for displaying the numbering and the paragraph text side by side and the right one of the two overflows in this case.
The wide screen result (left) vs. the narrow screen result (right)

In order to fix this, we introduce two more custom properties, --wide and --k to switch between the wide and narrow cases. We set --k to --wide with a fallback value of 0 in the general case and then set --wide to 1 if the viewport width is anything 340px and up.

p { /* same code as before */ --k: var(--wide, 0); @media (min-width: 340px) { --wide: 1 }
}

Since we only want our items to be transformed and have rounded corners in the wide case, we multiply the translation, rotation and radius values by --k (which is 0, unless the viewport is wide, which switches its value to 1).

p { /* same code as before */ --k: var(--wide, 0); --r0: calc(var(--k)*var(--j)*#{$r}); --r1: calc(var(--k)*var(--i)*#{$r}); border-radius: var(--r0) /* top left */ var(--r1) /* top right */ var(--r1) /* bottom right */ var(--r0) /* bottom left */; transform: translate(calc(var(--k)*var(--s)*10%)) rotate(calc(var(--k)*var(--s)*5deg)); @media (min-width: 340px) { --wide: 1 }
}

This is slightly better, but our content still overflows in narrow viewports. We can fix this by only placing the numbering (the :before pseudo-element) on the left or right side only in the wide case then moving it above the card in the narrow case.

In order to do this, we multiply both its order and its lateral margin values by --k (which is 1 in the wide case and 0 otherwise).

We also set flex-direction to --wide with a fallback value of column.

This means the flex-direction value is column in the general case (since we haven’t set --wide explicitly elsewhere). However, if the viewport is wide (min-width: 340px), then our --wide variable gets set to 1. But 1 is an invalid value for flex-direction, so this property reverts back to its initial value of row.

p { /* same code as before */ --k: var(--wide, 0); flex-direction: var(--wide, column); &:before { /* same code as before */ order: calc(var(--k)*var(--i)); margin: 0 /* top */ calc(var(--k)*var(--j)*#{$gap}) /* right */ 0 /* bottom */ calc(var(--k)*var(--i)*#{$gap}) /* left */; } @media (min-width: 340px) { --wide: 1 }
}

Coupled with setting a min-width of 160px on the body, we’ve now eliminated the overflow issue:

Responsive cards, no overflow (live demo).

One more thing we can do is tweak the font-size so that it also depends on --k:

p { /* same code as before */ --k: var(--wide, 0); font: 900 calc(var(--k)*.5em + .75em) cursive; @media (min-width: 340px) { --wide: 1 }
}

And that’s it, our demo is now nicely responsive!

Responsive cards, font smaller for narrow screens and with no overflow (live demo).

A few more quick examples!

Let’s look at a few more demos that use the same technique, but quickly without building them from scratch. We’ll merely go through the basic ideas behind them.

Disc slices

Sliced disc (live demo).

Just like the cards example we completed together, we can use a :before pseudo-element for the numbering and a flex layout on the paragraphs. The sliced disc effect is achieved using clip-path.

The paragraph elements themselves — the horizontal offsets, the position and intensity of the radial-gradient() creating the shadow effect, the direction of the linear-gradient() and the saturation of its stops, the color and the text alignment — all depend on the --parity variable.

p { /* other styles not relevant here */ --p: var(--parity, 1); --q: calc(1 - var(--p)); --s: calc(1 - 2*var(--p)); /* sign depending on parity */ transform: translate((calc(var(--i)*var(--s)*#{-$x}))); background: radial-gradient(at calc(var(--q)*100%) 0, rgba(0, 0, 0, calc(.5 + var(--p)*.5)), transparent 63%) calc(var(--q)*100%) 0/ 65% 65% no-repeat, linear-gradient(calc(var(--s)*-90deg), hsl(23, calc(var(--q)*98%), calc(27% + var(--q)*20%)), hsl(44, calc(var(--q)*92%), 52%)); color: HSL(0, 0%, calc(var(--p)*100%)); text-align: var(--parity, right); &:nth-child(odd) { --parity: 0 }
}

For the numbering (the :before pseudo-elements of the paragraphs), we have that both the margin and the order depend on the --parity in the exact same way as the cards example.

If the viewport width is smaller than the disc diameter $d plus twice the horizontal slice offset in absolute value $x, then we’re not in the --wide case anymore. This affects the width, padding and margin of our paragraphs, as well as their horizontal offset and their shape (because we don’t clip them to get the sliced disc effect at that point).

body { /* other styles not relevant here */ --i: var(--wide, 1); --j: calc(1 - var(--i)); @media (max-width: $d + 2*$x) { --wide: 0 }
} p { /* other styles not relevant here */ margin: calc(var(--j)*.25em) 0; padding: calc(var(--i)*#{.5*$r}/var(--n) + var(--j)*5vw) /* vertical */ calc(var(--i)*#{.5*$r} + var(--j)*2vw) /* horizontal */; width: calc(var(--i)*#{$d} /* wide */ + var(--j)*100% /* not wide */); transform: translate((calc(var(--i)*var(--s)*#{-$x}))); clip-path: var(--wide, /* fallback, used in the wide case only */ circle($r at 50% calc((.5*var(--n) - var(--idx))*#{$d}/var(--n))));
}

We’re in the narrow case below 270px and have a flex-direction of column on our paragraphs. We also zero out both the lateral margins and the order for the numbering.

body { /* other styles not relevant here */ --k: calc(1 - var(--narr, 1)); @media (min-width: 270px) { --narr: 0 }
} p { /* other styles not relevant here */ flex-direction: var(--narr, column); &:before { /* other styles not relevant here */ margin: 0 /* top */ calc(var(--k)*var(--q)*.25em) /* right */ 0 /* bottom */ calc(var(--k)*var(--p)*.25em) /* left */; order: calc(var(--k)*var(--p)); }
}

Four-step infographic

Screenshot collage. On the left, there's the wide screen scenario. In the middle, there's the normal screen scenario. On the right, there's the narrow screen scenario.
A four-step infographic (live demo).

This works pretty much the same as the previous two examples. We have a flex layout on our paragraphs using a column direction in the narrow case. We also have a smaller font-size in that same case:

body { /* other styles not relevant here */ --k: var(--narr, 1); @media (min-width: 400px) { --narr: 0 }
} p { /* other styles not relevant here */ flex-direction: var(--narr, column); font-size: calc((1.25 - .375*var(--k))*1em);
}

The parity determines each paragraph’s text alignment, which lateral border gets a non-zero value, and the position and direction of the border gradient. Both the parity and whether we’re in the wide screen case or not determine the lateral margins and paddings.

body { /* other styles not relevant here */ --i: var(--wide, 1); --j: calc(1 - var(--i)); @media (max-width: $bar-w + .5*$bar-h) { --wide: 0 }
} p { /* other styles not relevant here */ margin: .5em /* top */ calc(var(--i)*var(--p)*#{.5*$bar-h}) /* right */ 0 /* bottom */ calc(var(--i)*var(--q)*#{.5*$bar-h}) /* left */; border-width: 0 /* top */ calc(var(--q)*#{$bar-b}) /* right */ 0 /* bottom */ calc(var(--p)*#{$bar-b}) /* left */; padding: $bar-p /* top */ calc((var(--j) + var(--i)*var(--q))*#{$bar-p}) /* right */ $bar-p /* bottom */ calc((var(--j) + var(--i)*var(--p))*#{$bar-p}) /* left */; background: linear-gradient(#fcfcfc, gainsboro) padding-box, linear-gradient(calc(var(--s)*90deg), var(--c0), var(--c1)) calc(var(--q)*100%) /* background-position */ / #{$bar-b} 100% /* background-size */; text-align: var(--parity, right);
}

The icon is created using the :before pseudo-element, and its order depends on the parity, but only if we’re not in the narrow screen scenario — in which case it’s always before the actual text content of the paragraph. Its lateral margin depends both on the parity and whether we are in the wide screen case or not. The big-valued component that positions it half out of its parent paragraph is only present in the wide screen case. The font-size also depends on whether we’re in the narrow screen case or not (and this influences its em dimensions and padding).

order: calc((1 - var(--k))*var(--p));
margin: 0 /* top */ calc(var(--i)*var(--p)*#{-.5*$ico-d} + var(--q)*#{$bar-p}) /* right */ 0 /* bottom */ calc(var(--i)*var(--q)*#{-.5*$ico-d} + var(--p)*#{$bar-p}) /* left */;
font-size: calc(#{$ico-s}/(1 + var(--k)));

The ring is created using an absolutely positioned :after pseudo-element (and its placement depends on parity), but only for the wide screen case.

content: var(--wide, '');

The two-dimension case

Screenshot collage. On the left, we have the wide screen scenario. Each article is laid out as a 2x2 grid, with the numbering occupying an entire column, either on the right for odd items or on the left for even items. The heading and the actual text occupy the other column. In the middle, we have the normal screen case. Here, we also have a 2x2 grid, but the numbering occupies only the top row on the same column as before, while the actual text content now spans both columns on the second row. On the right, we have the narrow screen case. In this case, we don't have a grid anymore, the numbering, the heading and the actual text are one under the other for each article.
Screenshot collage (live demo, no Edge support due to CSS variable and calc() bugs).

Here we have a bunch of article elements, each containing a heading. Let’s check out the most interesting aspects of how this responsive layout works!

On each article, we have a two-dimensional layout (grid) — but only if we’re not in the narrow screen scenario (--narr: 1), in which case we fall back on the normal document flow with the numbering created using a :before pseudo-element, followed by the heading, followed by the actual text. In this situation, we also add vertical padding on the heading since we don’t have the grid gaps anymore and we don’t want things to get too crammed.

html { --k: var(--narr, 0); @media (max-width: 250px) { --narr: 1 }
} article { /* other styles irrelevant here */ display: var(--narr, grid);
} h3 { /* other styles irrelevant here */ padding: calc(var(--k)*#{$hd3-p-narr}) 0;
}

For the grid, we create two columns of widths depending both on parity and on whether we’re in the wide screen scenario. We make the numbering (the :before pseudo-element) span two rows in the wide screen case, either on the second column or the first, depending on the parity. If we’re not in the wide screen case, then the paragraph spans both columns on the second row.

We set the grid-auto-flow to column dense in the wide screen scenario, letting it revert to the initial value of row otherwise. Since our article elements are wider than the combined widths of the columns and the column gap between them, we use place-content to position the actual grid columns inside at the right or left end depending on parity.

Finally, we place the heading at the end or start of the column, depending on parity, and we as well as the paragraph’s text alignment if we’re in the wide screen scenario.

$col-1-wide: calc(var(--q)*#{$col-a-wide} + var(--p)*#{$col-b-wide});
$col-2-wide: calc(var(--p)*#{$col-a-wide} + var(--q)*#{$col-b-wide}); $col-1-norm: calc(var(--q)*#{$col-a-norm} + var(--p)*#{$col-b-norm});
$col-2-norm: calc(var(--p)*#{$col-a-norm} + var(--q)*#{$col-b-norm}); $col-1: calc(var(--i)*#{$col-1-wide} + var(--j)*#{$col-1-norm});
$col-2: calc(var(--i)*#{$col-2-wide} + var(--j)*#{$col-2-norm}); html { --i: var(--wide, 1); --j: calc(1 - var(--i)); @media (max-width: $art-w-wide) { --wide: 0 }
} article { /* other styles irrelevant here */ --p: var(--parity, 1); --q: calc(1 - var(--p)); grid-template-columns: #{$col-1} #{$col-2}; grid-auto-flow: var(--wide, dense column); place-content: var(--parity, center end); &:before { /* other styles irrelevant here */ grid-row: 1/ span calc(1 + var(--i)); grid-column: calc(1 + var(--p))/ span 1; } &:nth-child(odd) { --parity: 0 }
} h3 { /* other styles irrelevant here */ justify-self: var(--parity, self-end);
} p { grid-column-end: span calc(1 + var(--j)); text-align: var(--wide, var(--parity, right));
}

We also have numerical values such as grid gaps, border radii, paddings, font-sizes, gradient directions, rotation and translation directions depending on the parity and/or whether we’re in the wide screen scenario or not.

Even more examples!

If you want more of this, I’ve created an entire collection of similar responsive demos for you to enjoy!

Screenshot of collection page on CodePen, showing the six most recent demos added.
Collection of responsive demos.

The post DRY State Switching With CSS Variables: Fallbacks and Invalid Values appeared first on CSS-Tricks.

DRY Switching with CSS Variables: The Difference of One Declaration

This is the first post of a two-part series that looks into the way CSS variables can be used to make the code for complex layouts and interactions less difficult to write and a lot easier to maintain. This first installment walks through various use cases where this technique applies. The second post covers the use of fallbacks and invalid values to extend the technique to non-numeric values.

What if I told you a single CSS declaration makes the difference in the following image between the wide screen case (left) and the second one (right)? And what if I told you a single CSS declaration makes the difference between the odd and even items in the wide screen case?

On the left, a screenshot of the wide screen scenario. Each item is limited in width and its components are arranged on a 2D 2x2 grid, with the first level heading occupying an entire column, either the one on the right (for odd items) or the one on the left (for even items). The second level heading and the actual text occupy the other column. The shape of the first level heading also varies depending on the parity — it has the top left and the bottom right corners rounded for the odd items and the other two corners rounded for the even items. On the right, a screenshot of the narrower scenario. Each item spans the full viewport width and its components are placed vertically, one under another — first level heading, second level heading below and, finally, the actual text.
Screenshot collage.

Or that a single CSS declaration makes the difference between the collapsed and expanded cases below?

Animated gif. Shows a green button with a magnifier icon. Clicking this button makes it slide right and its background to turn red while a text search field slides out of it to the left and the magnifier morphs into a close (crossmark) icon.
Expanding search.

How is that even possible?

Well, as you may have guessed from the title, it’s all in the power of CSS variables.

There are already plenty of articles out there on what CSS variables are and how to get started with them, so we won’t be getting into that here.

Instead, we’ll dive straight into why CSS variables are useful for achieving these cases and others, then we’ll move on to a detailed explanation of the how for various cases. We’ll code an actual example from scratch, step by step, and, finally, you’ll be getting some eye candy in the form of a few more demos that use the same technique.

So let’s get started!

Why CSS variables are useful

For me, the best thing about CSS variables is that they’ve opened the door for styling things in a logical, mathematical and effortless way.

One example of this is the CSS variable version of the yin and yang loader I coded last year. For this version, we create the two halves with the two pseudo-elements of the loader element.

Animated gif. The yin and yang symbol is rotating while its two lobes alternate increasing and decreasing in size - whenever one is increasing, it squishes the other one down.
Rotating ☯ symbol, with its two lobes increasing and decreasing in size.

We use the same background, border-color, transform-origin and animation-delay values for the two halves. These values all depend on a switch variable --i that’s initially set to 0 on both halves (the pseudo-elements), but then we change it to 1 for the second half (the :after pseudo-element), thus dynamically modifying the computed values of all these properties.

Without CSS variables, we’d have to set all these properties (border-color, transform-origin, background, animation-delay) again on the :after pseudo-element and risk making some typo or even forgetting to set some of them.

How switching works in the general case

Switching between a zero and a non-zero value

In the particular case of the yin and yang loader, all the properties we change between the two halves (pseudo-elements) go from a zero value for one state of the switch and a non-zero value for the other state.

If we want our value to be zero when the switch is off (--i: 0) and non-zero when the switch is on (--i: 1), then we multiply it with the switch value (var(--i)). This way, if our non-zero value should be, let’s say an angular value of 30deg, we have:

  • when the switch is off (--i: 0), calc(var(--i)*30deg) computes to 0*30deg = 0deg
  • when the switch is on (--i: 1), calc(var(--i)*30deg) computes to 1*30deg = 30deg

However, if we want our value to be non-zero when the switch is off (--i: 0) and zero when the switch is on (--i: 1), then we multiply it with the complementary of the switch value (1 - var(--i)). This way, for the same non-zero angular value of 30deg, we have:

  • when the switch is off (--i: 0), calc((1 - var(--i))*30deg) computes to (1 - 0)*30deg = 1*30deg = 30deg
  • when the switch is on (--i: 1), calc((1 - var(--i))*30deg) computes to (1 - 1)*30deg = 0*30deg = 0deg

You can see this concept illustrated below:

Animated gif. Shows how changing the switch value from 0 to 1 changes the rotation of two boxes. The first box is rotated to 30deg when the switch is off (its value is 0) and not rotated or rotated to 0deg when the switch is on (its value is 1). This means we have a rotation value of calc((1 - var(--i))*30deg), where --i is the switch value. The second box is not rotated or rotated to 0deg when the switch is off (its value is 0) and rotated to 30deg when the switch is on (its value is 1). This means we have a rotation value of calc(var(--i)*30deg), with --i being the switch value.
Switching between a zero and a non-zero value (live demo, no Edge support due to calc() not working for angle values)

For the particular case of the loader, we use HSL values for border-color and background-color. HSL stands for hue, saturation, lightness and can be best represented visually with the help of a bicone (which is made up of two cones with the bases glued together).

Two cones with their bases glued together in the middle, one vertex pointing down and one up. The hue is cyclic, distributed around the central (vertical) axis of the bicone. The saturation axis goes horizontally from the central axis towards the surface of the bicone - it's 0% right on the axis and 100% right on the surface. The lightness axis goes vertically from the black vertex to the white one - it's 0% at the black vertex and 100% at the white vertex.
HSL bicone.

The hues go around the bicone, being equivalent to 360° to give us a red in both cases.

Shows the red being at 0° (which is equivalent to 360° since the hue is cyclic), the yellow at 60°, the lime at 120°, the cyan at 180°, the blue at 240° and the magenta at 300°.
Hue wheel.

The saturation goes from 0% on the vertical axis of the bicone to 100% on the bicone surface. When the saturation is 0% (on the vertical axis of the bicone), the hue doesn’t matter anymore; we get the exact same grey for all hues in the same horizontal plane.

The “same horizontal plane” means having the same lightness, which increases along the vertical bicone axis, going from 0% at the black bicone vertex to 100% at the white bicone vertex. When the lightness is either 0% or 100%, neither the hue nor the saturation matter anymore – we always get black for a lightness value of 0% and white for a lightness value of 100%.

Since we only need black and white for our ☯ symbol, the hue and saturation are irrelevant, so we zero them and then switch between black and white by switching the lightness between 0% and 100%.

.yin-yang { /* other styles that are irrelevant here */ &:before, &:after { /* other styles that are irrelevant here */ --i: 0; /* lightness of border-color when * --i: 0 is (1 - 0)*100% = 1*100% = 100% (white) * --i: 1 is (1 - 1)*100% = 0*100% = 0% (black) */ border: solid $d/6 hsl(0, 0%, calc((1 - var(--i))*100%)); /* x coordinate of transform-origin when * --i: 0 is 0*100% = 0% (left) * --i: 1 is 1*100% = 100% (right) */ transform-origin: calc(var(--i)*100%) 50%; /* lightness of background-color when * --i: 0 is 0*100% = 0% (black) * --i: 1 is 1*100% = 100% (white) */ background: hsl(0, 0%, calc(var(--i)*100%)); /* animation-delay when * --i: 0 is 0*-$t = 0s * --i: 1 is 1*-$t = -$t */ animation: s $t ease-in-out calc(var(--i)*#{-$t}) infinite alternate; } &:after { --i: 1 }
}

Note that this approach doesn’t work in Edge due to the fact that Edge doesn’t support calc() values for animation-delay.

But what if we want to have a non-zero value when the switch is off (--i: 0) and another different non-zero value when the switch is on (--i: 1)?

Switching between two non-zero values

Let’s say we want an element to have a grey background (#ccc) when the switch is off (--i: 0) and an orange background (#f90) when the switch is on (--i: 1).

The first thing we do is switch from hex to a more manageable format such as rgb() or hsl().

We could do this manually either by using a tool such as Lea Verou’s CSS Colors or via DevTools. If we have a background set on an element we can cycle through formats by keeping the Shift key pressed while clicking on the square (or circle) in front of the value in DevTools. This works in both Chrome and Firefox, though it doesn’t appear to work in Edge.

Animated gif. Shows how to cycle through formats (hex/ RGB/ HSL) via DevTools. In both Chrome and Firefox, we do this by keeping the Shift key pressed and clicking the square or circle in front of the <color> value.”/><figcaption>Changing the format from DevTools.</figcaption></figure>
<p>Even better, if we’re using Sass, we can extract the components with <a href=red()/ green()/ blue() or hue()/ saturation()/ lightness() functions.

While rgb() may be the better known format, I tend to prefer hsl() because I find it more intuitive and it’s easier for me to get an idea about what to expect visually just by looking at the code.

So we extract the three components of the hsl() equivalents of our two values ($c0: #ccc when the switch is off and $c1: #f90 when the switch is on) using these functions:

$c0: #ccc;
$c1: #f90; $h0: round(hue($c0)/1deg);
$s0: round(saturation($c0));
$l0: round(lightness($c0)); $h1: round(hue($c1)/1deg);
$s1: round(saturation($c1));
$l1: round(lightness($c1))

Note that we’ve rounded the results of the hue(), saturation() and lightness() functions as they may return a lot of decimals and we want to keep our generated code clean. We’ve also divided the result of the hue() function by 1deg, as the returned value is a degree value in this case and Edge only supports unit-less values inside the CSS hsl() function. Normally, when using Sass, we can have degree values, not just unit-less ones for the hue inside the hsl() function because Sass treats it as the Sass hsl() function, which gets compiled into a CSS hsl() function with a unit-less hue. But here, we have a dynamic CSS variable inside, so Sass treats this function as the CSS hsl() function that doesn’t get compiled into anything else, so, if the hue has a unit, this doesn’t get removed from the generated CSS.

Now we have that:

  • if the switch is off (--i: 0), our background is
    hsl($h0, $s0, $l0)
  • if the switch is on (--i: 1), our background is
    hsl($h1, $s1, $l1)

We can write our two backgrounds as:

  • if the switch is off (--i: 0),
    hsl(1*$h0 + 0*$h1, 1*$s0 + 0*$s1, 1*$l0 + 1*$l1)
  • if the switch is on (--i: 1),
    hsl(0*$h0 + 1*$h1, 0*$s0 + 1*$s1, 0*$l0 + 1*$l1)

Using the switch variable --i, we can unify the two cases:

--j: calc(1 - var(--i));
background: hsl(calc(var(--j)*#{$h0} + var(--i)*#{$h1}), calc(var(--j)*#{$s0} + var(--i)*#{$s1}), calc(var(--j)*#{$l0} + var(--i)*#{$l1}))

Here, we’ve denoted by --j the complementary value of --i (when --i is 0, --j is 1 and when --i is 1, --j is 0).

Animated gif. Shows how changing the switch value from 0 to 1 changes the background of a box. The background is grey (of hue $h0, saturation $s0 and lightness $l0) when the switch is turned off (its value is zero) and orange (of hue $h1, saturation $s1 and lightness $l1) when the switch is turned on (its value is 1). This means we have a hue value of calc(var(--j)*#{$h0} + var(--i)*#{$h1}), a saturation value of calc(var(--j)*#{$s0} + var(--i)*#{$s1}) and a lightness value of calc(var(--j)*#{$l0} + var(--i)*#{$l1})), where --i is the switch variable.
Switching between two backgrounds (live demo)

The formula above works for switching in between any two HSL values. However, in this particular case, we can simplify it because we have a pure grey when the switch is off (--i: 0).

Purely grey values have equal red, green and blue values when taking into account the RGB model.

When taking into account the HSL model, the hue is irrelevant (our grey looks the same for all hues), the saturation is always 0% and only the lightness matters, determining how light or dark our grey is.

In this situation, we can always keep the hue of the non-grey value (the one we have for the “on” case, $h1).

Since the saturation of any grey value (the one we have for the “off” case, $s0) is always 0%, multiplying it with either 0 or 1 always gives us 0%. So, given the var(--j)*#{$s0} term in our formula is always 0%, we can just ditch it and our saturation formula reduces to the product between the saturation of the “on” case $s1 and the switch variable --i.

This leaves the lightness as the only component where we still need to apply the full formula.

--j: calc(1 - var(--i));
background: hsl($h1, calc(var(--i)*#{$s1}), calc(var(--j)*#{$l0} + var(--i)*#{d1l}))

The above can be tested in this demo.

Similarly, let’s say we want the font-size of some text to be 2rem when our switch is off (--i: 0) and 10vw when the switch is on (--i: 1). Applying the same method, we have:

font-size: calc((1 - var(--i))*2rem + var(--i)*10vw)
Animated gif. Shows how changing the switch value from 0 to 1 changes the font-size.
Switching between two font sizes (live demo)

Alright, let’s now move on to clearing another aspect of this: what is it exactly that causes the switch to flip from on to off or the other way around?

What triggers switching

We have a few options here.

Element-based switching

This means the switch is off for certain elements and on for other elements. For example, this can be determined by parity. Let’s say we want all the even elements to be rotated and have an orange background instead of the initial grey one.

.box { --i: 0; --j: calc(1 - var(--i)); transform: rotate(calc(var(--i)*30deg)); background: hsl($h1, calc(var(--i)*#{$s1}), calc(var(--j)*#{$l0} + var(--i)*#{$l1})); &:nth-child(2n) { --i: 1 }
}
Screenshot. Shows a bunch of squares in a row, the even ones being rotated and having an orange background instead of the initial grey one. This is achieved by making both the transform and the background properties depend on a switch variable --i that changes with parity: it's 0 initially, but then we change it to 1 for even items.
Switching triggered by item parity (live demo, not fully functional in Edge due to calc() not working for angle values)

In the parity case, we flip the switch on for every second item (:nth-child(2n)), but we can also flip it on for every seventh item (:nth-child(7n)), for the first two items (:nth-child(-n + 2)), for all items except the first and last two (:nth-child(n + 3):nth-last-child(n + 3)). We can also flip it on just for headings or just for elements that have a certain attribute.

State-based switching

This means the switch is off when the element itself (or a parent or one of its previous siblings) is in one state and off when it’s another state. In the interactive examples from the previous section, the switch was flipped when a checkbox before our element got checked or unchecked.

We can also have something like a white link that scales up and turns orange when focused or hovered:

$c: #f90; $h: round(hue($c)/1deg);
$s: round(saturation($c));
$l: round(lightness($c)); a { --i: 0; transform: scale(calc(1 + var(--i)*.25)); color: hsl($h, $s, calc(var(--i)*#{$l} + (1 - var(--i))*100%)); &:focus, &:hover { --i: 1 }
}

Since white is any hsl() value with a lightness of 100% (the hue and saturation are irrelevant), we can simplify things by always keeping the hue and saturation of the :focus/ :hover state and only changing the lightness.

Animated gif. Shows a white link that grows and turns orange when hovered or focused.
Switching triggered by state change (live demo, not fully functional in Edge due to calc() values not being supported inside scale() functions)

Media query-based switching

Another possibility is that switching is triggered by a media query, for example, when the orientation changes or when going from one viewport range to another.

Let’s say we have a white heading with a font-size of 1rem up to 320px, but then it turns orange ($c) and the font-size becomes 5vw and starts scaling with the viewport width.

h5 { --i: 0; color: hsl($h, $s, calc(var(--i)*#{$l} + (1 - var(--i))*100%)); font-size: calc(var(--i)*5vw + (1 - var(--i))*1rem); @media (min-width: 320px) { --i: 1 }
}
Animated gif. Shows a heading that's white and has a fixed font-size up to 320px, but as we resize the viewport above that, it becomes orange and its font-size starts scaling with the viewport width.
Switching triggered by viewport change (live demo)

Coding a more complex example from scratch

The example we dissect here is that of the expanding search shown at the beginning of this article, inspired by this Pen, which you should really check out because the code is pretty damn clever.

Animated gif. Shows a green button with a magnifier icon. Clicking this button makes it slide right and its background to turn red while a text search field slides out of it to the left and the magnifier morphs into a close (crossmark) icon.
Expanding search.

Note that from a usability point of view, having such a search box on a website may not be the best idea as one would normally expect the button following the search box to trigger the search, not close the search bar, but it’s still an interesting coding exercise, which is why I’ve chosen to dissect it here.

To begin with, my idea was to do it using only form elements. So, the HTML structure looks like this:

<input id='search-btn' type='checkbox'/>
<label for='search-btn'>Show search bar</label>
<input id='search-bar' type='text' placeholder='Search...'/>

What we do here is initially hide the text input and then reveal it when the checkbox before it gets checked — let’s dive into how that works!

First off, we use a basic reset and set a flex layout on the container of our input and label elements. In our case, this container is the body, but it could be another element as well. We also absolutely position the checkbox and move it out of sight (outside the viewport).

*, :before, :after { box-sizing: border-box; margin: 0; padding: 0; font: inherit
} html { overflow-x: hidden } body { display: flex; align-items: center; justify-content: center; margin: 0 auto; min-width: 400px; min-height: 100vh; background: #252525
} [id='search-btn'] { position: absolute; left: -100vh
}

So far, so good…

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

So what? We have to admit it’s not exciting at all, so let’s move on to the next step!

We turn the checkbox label into a big round green button and move its text content out of sight using a big negative-valued text-indent and overflow: hidden.

$btn-d: 5em; /* same as before */ [for='search-btn'] { overflow: hidden; width: $btn-d; height: $btn-d; border-radius: 50%; box-shadow: 0 0 1.5em rgba(#000, .4); background: #d9eb52; text-indent: -100vw; cursor: pointer;
}

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

Next, we polish the actual search bar by:

  • giving it explicit dimensions
  • providing a background for its normal state
  • defining a different background and a glow for its focused state
  • rounding the corners on the left side using a border-radius that equals half its height
  • Cleaning up the placeholder a bit
$btn-d: 5em;
$bar-w: 4*$btn-d;
$bar-h: .65*$btn-d;
$bar-r: .5*$bar-h;
$bar-c: #ffeacc; /* same as before */ [id='search-bar'] { border: none; padding: 0 1em; width: $bar-w; height: $bar-h; border-radius: $bar-r 0 0 $bar-r; background: #3f324d; color: #fff; font: 1em century gothic, verdana, arial, sans-serif; &::placeholder { opacity: .5; color: inherit; font-size: .875em; letter-spacing: 1px; text-shadow: 0 0 1px, 0 0 2px } &:focus { outline: none; box-shadow: 0 0 1.5em $bar-c, 0 1.25em 1.5em rgba(#000, .2); background: $bar-c; color: #000; }
}

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

At this point, the right edge of the search bar coincides with the left edge of the button. However, we want a bit of overlap — let’s say an overlap such that the right edge of the search bar coincides with the button’s vertical midline. Given that we have a flexbox layout with align-items: center on the container (the body in our case), the assembly made up of our two items (the bar and the button) remains middle-aligned horizontally even if we set a margin on one or on the other or on both in between those items. (On the left of the leftmost item or on the right of the rightmost item is a different story, but we won’t be getting into that now.)

Illustration showing the bar plus button assembly in the initial state (bar's right edge coinciding with the button's left edge) vs. the overlap state (the bar's right edge coincides with the button's vertical midline). In both cases, the assembly is middle aligned.
Creating overlap, keeping alignment (live demo).

That’s an overlap of .5*$btn-d minus half a button diameter, which is equivalent to the button’s radius. We set this as a negative margin-right on the bar. We also adjust the padding on the right of the bar so that we compensate for the overlap:

$btn-d: 5em;
$btn-r: .5*$btn-d; /* same as before */ [id='search-bar'] { /* same as before */ margin-right: -$btn-r; padding: 0 calc(#{$btn-r} + 1em) 0 1em;
}

We now have the bar and the button in the positions for the expanded state:

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

Except the bar follows the button in DOM order, so it’s placed on top of it, when we actually want the button on top. Fortunately, this has an easy fix (at least for now — it won’t be enough later, but let’s deal with one issue at a time).

[for='search-btn'] { /* same as before */ position: relative;
}

Now that we’ve given the button a non-static position value, it’s on top of the bar:

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

In this state, the total width of the bar and button assembly is the bar width $bar-w plus the button’s radius $btn-r (which is half the button diameter $btn-d) because we have an overlap for half the button. In the collapsed state, the total width of the assembly is just the button diameter $btn-d.

Illustration showing the bar plus button assembly in the expanded state (the bar's right edge coincides with the button's vertical midline) and in the collapsed state (the bar is collapsed and the assembly is reduced to just the button). In both cases, the assembly is middle aligned.
Expanded vs. collapsed state (live).

Since we want to keep the same central axis when going from the expanded to the collapsed state, we need to shift the button to the left by half the assembly width in the expanded state (.5*($bar-w + $btn-r)) minus the button’s radius ($btn-r).

We call this shift $x and we use it with minus on the button (since we shift the button to the left and left is the negative direction of the x axis). Since we want the bar to collapse into the button, we set the same shift $x on it, but in the positive direction (as we shift the bar to the right of the x axis).

We’re in the collapsed state when the checkbox isn’t checked and in the expanded state when it isn’t. This means our bar and button are shifted with a CSS transform when the checkbox isn’t checked and in the position we currently have them in (no transform) when the checkbox is checked.

In order to do this, we set a variable --i on the elements following our checkbox — the button (created with the label for the checkbox) and the search bar. This variable is 0 in the collapsed state (when both elements are shifted and the checkbox isn’t checked) and 1 in the expanded state (when our bar and button are in the positions they currently occupy, no shift, and the checkbox is checked).

$x: .5*($bar-w + $btn-r) - $btn-r; [id='search-btn'] { position: absolute; left: -100vw; ~ * { --i: 0; --j: calc(1 - var(--i)) /* 1 when --i is 0, 0 when --i is 1 */ } &:checked ~ * { --i: 1 }
} [for='search-btn'] { /* same as before */ /* if --i is 0, --j is 1 => our translation amount is -$x * if --i is 1, --j is 0 => our translation amount is 0 */ transform: translate(calc(var(--j)*#{-$x}));
} [id='search-bar'] { /* same as before */ /* if --i is 0, --j is 1 => our translation amount is $x * if --i is 1, --j is 0 => our translation amount is 0 */ transform: translate(calc(var(--j)*#{$x}));
}

And we now have something interactive! Clicking the button toggles the checkbox state (because the button has been created using the label of the checkbox).

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

Except now the button is a bit difficult to click since it’s under the text input again (because we’ve set a transform on the bar and this establishes a stacking context). The fix is pretty straightforward — we need to add a z-index to the button and this moves it above the bar.

[for='search-btn'] { /* same as before */ z-index: 1;
}

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

But we still have another bigger problem: we can see the bar coming out from under the button on the right side. In order to fix this, we set clip-path with an inset() value on the bar. This specifies a clipping rectangle with the help of the distances from the top, right, bottom and left edges of the element’s border-box. Everything outside this clipping rectangle gets cut out and only what’s inside is displayed.

Illustration showing what the four values of the inset() function represent. The first one is the offset of the top edge of the clipping rectangle with respect to the top edge of the border-box. The second one is the offset of the right edge of the clipping rectangle with respect to the right edge of the border-box. The third one is the offset of the bottom edge of the clipping rectangle with respect to the bottom edge of the border-box. The fourth one is the offset of the left edge of the clipping rectangle with respect to the left edge of the border-box. Everything outside the
How the inset() function works (live).

In the illustration above, each distance is going inward from the edges of the border-box. In this case, they’re positive. But they can also go outwards, in which case they’re negative and the corresponding edges of the clipping rectangle are outside the element’s border-box.

At first, you may think we’d have no reason to ever do that, but in our particular case, we do!

We want the distances from the top (dt), bottom (db) and left (dl) to be negative and big enough to contain the box-shadow that extends outside the element’s border-box in the :focus state as we don’t want it to get clipped out. So the solution is to create a clipping rectangle with edges outside the element’s border-box in these three directions.

The distance from the right (dr) is the full bar width $bar-w minus a button radius $btn-r in the collapsed case (checkbox not checked, --i: 0) and 0 in the expanded case (checkbox checked, --i: 1).

$out-d: -3em; [id='search-bar'] { /* same as before */ clip-path: inset($out-d calc(var(--j)*#{$bar-w - $btn-r}) $out-d $out-d);
}

We now have a search bar and button assembly that expands and collapses on clicking the button.

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

Since we don’t want an abrupt change in between the two states, we use a transition:

[id='search-btn'] { /* same as before */ ~ * { /* same as before */ transition: .65s; }
}

We also want our button’s background to be green in the collapsed case (checkbox not checked, --i: 0) and pink in the expanded case (checkbox checked, --i: 1). For this, we use the same technique as before:

[for='search-btn'] { /* same as before */ $c0: #d9eb52; // green for collapsed state $c1: #dd1d6a; // pink for expanded state $h0: round(hue($c0)/1deg); $s0: round(saturation($c0)); $l0: round(lightness($c0)); $h1: round(hue($c1)/1deg); $s1: round(saturation($c1)); $l1: round(lightness($c1)); background: hsl(calc(var(--j)*#{$h0} + var(--i)*#{$h1}), calc(var(--j)*#{$s0} + var(--i)*#{$s1}), calc(var(--j)*#{$l0} + var(--i)*#{$l1}));
}

Now we’re getting somewhere!

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

What we still need to do is create the icon that morphs between a magnifier in the collapsed state and an “x” in the expanded state to indicate a closing action. We do this with the :before and :after pseudo-elements. We begin by deciding on a diameter for the magnifier and how much of this diameter the width of the icon lines represent.

$ico-d: .5*$bar-h;
$ico-f: .125;
$ico-w: $ico-f*$ico-d;

We absolutely position both pseudo-elements in the middle of the button taking their dimensions into account. We then make them inherit their parent’s transition. We give the :before a background, as this will be the handle of our magnifier, make the :after round with border-radius and give it an inset box-shadow.

[for='search-btn'] { /* same as before */ &:before, &:after { position: absolute; top: 50%; left: 50%; margin: -.5*$ico-d; width: $ico-d; height: $ico-d; transition: inherit; content: '' } &:before { margin-top: -.4*$ico-w; height: $ico-w; background: currentColor } &:after { border-radius: 50%; box-shadow: 0 0 0 $ico-w currentColor } }

We can now see the magnifier components on the button:

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

In order to make our icon to look more like a magnifier, we translate both of its components outwards by a quarter of the magnifier’s diameter. This means translating the handle to the right, in the positive direction of the x axis by .25*$ico-d and the main part to the left, in the negative direction of the x axis by the same .25*$ico-d.

We also scale the handle (the :before pseudo-element) horizontally to half its width with respect to its right edge (which means a transform-origin of 100% along the x axis).

We only want this to happen in the collapsed state (checkbox not checked, --i is 0 and, consequently --j is 1), so we multiply the translation amounts by --j and also use --j to condition the scaling factor:

[for='search-btn'] { /* same as before */ &:before { /* same as before */ height: $ico-w; transform: /* collapsed: not checked, --i is 0, --j is 1 * translation amount is 1*.25*$d = .25*$d * expanded: checked, --i is 1, --j is 0 * translation amount is 0*.25*$d = 0 */ translate(calc(var(--j)*#{.25*$ico-d})) /* collapsed: not checked, --i is 0, --j is 1 * scaling factor is 1 - 1*.5 = 1 - .5 = .5 * expanded: checked, --i is 1, --j is 0 * scaling factor is 1 - 0*.5 = 1 - 0 = 1 */ scalex(calc(1 - var(--j)*.5)) } &:after { /* same as before */ transform: translate(calc(var(--j)*#{-.25*$ico-d})) } }

We now have thew magnifier icon in the collapsed state:

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

Since we want both icon components to be rotated by 45deg, we add this rotation on the button itself:

[for='search-btn'] { /* same as before */ transform: translate(calc(var(--j)*#{-$x})) rotate(45deg);
}

Now we have the look we want for the collapsed state:

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

This still leaves the expanded state, where we need to turn the round :after pseudo-element into a line. We do this by scaling it down along the x axis and bringing its border-radius from 50% to 0%. The scaling factor we use is the ratio between the width $ico-w of the line we want to get and the diameter $ico-d of the circle it forms in the collapsed state. We’ve called this ratio $ico-f.

Since we only want to do this in the expanded state, when the checkbox is checked and --i is 1, we make both the scaling factor and the border-radius depend on --i and --j:

$ico-d: .5*$bar-h;
$ico-f: .125;
$ico-w: $ico-f*$ico-d; [for='search-btn'] { /* same as before */ &:after{ /* same as before */ /* collapsed: not checked, --i is 0, --j is 1 * border-radius is 1*50% = 50% * expanded: checked, --i is 1, --j is 0 * border-radius is 0*50% = 0 */ border-radius: calc(var(--j)*50%); transform: translate(calc(var(--j)*#{-.25*$ico-d})) /* collapsed: not checked, --i is 0, --j is 1 * scaling factor is 1 + 0*$ico-f = 1 * expanded: checked, --i is 1, --j is 0 * scaling factor is 0 + 1*$ico-f = $ico-f */ scalex(calc(1 - var(--j)*.5)) }
}

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

Hmm, almost, but not quite. Scaling has also shrunk our inset box-shadow along the x axis, so let’s fix that with a second inset shadow that we only get in the expanded state (when the checkbox is checked and --i is 1) and therefore, its spread and alpha depend on --i:

$ico-d: .5*$bar-h;
$ico-f: .125;
$ico-w: $ico-f*$ico-d; [for='search-btn'] { /* same as before */ --hsl: 0, 0%, 0%; color: HSL(var(--hsl)); &:after{ /* same as before */ box-shadow: inset 0 0 0 $ico-w currentcolor, /* collapsed: not checked, --i is 0, --j is 1 * spread radius is 0*.5*$ico-d = 0 * alpha is 0 * expanded: checked, --i is 1, --j is 0 * spread radius is 1*.5*$ico-d = .5*$ico-d * alpha is 1 */ inset 0 0 0 calc(var(--i)*#{.5*$ico-d}) HSLA(var(--hsl), var(--i)) }
}

This gives us our final result!

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

A few more quick examples

The following are a few more demos that use the same technique. We won’t be building these from scratch — we’ll merely go through the basic ideas behind them.

Responsive banners

On the left, a screenshot of the wide screen scenario. In the middle, a screenshot of the normal screen scenario. On the right, a screenshot of the narrow screen scenario.
Screenshot collage (live demo, not fully functional in Edge due to using a calc() value for font-size).

In this case, our actual elements are the smaller rectangles in front, while the number squares and the bigger rectangles in the back are created with the :before and :after pseudo-elements, respectively.

The backgrounds of the number squares are individual and set using a stop list variable --slist that’s different for each item.

<p style='--slist: #51a9ad, #438c92'><!-- 1st paragraph text --></p>
<p style='--slist: #ebb134, #c2912a'><!-- 2nd paragraph text --></p>
<p style='--slist: #db4453, #a8343f'><!-- 3rd paragraph text --></p>
<p style='--slist: #7eb138, #6d982d'><!-- 4th paragraph text --></p>

The things that influence the styles on the banners are the parity and whether we’re in the wide, normal or narrow case. These give us our switch variables:

html { --narr: 0; --comp: calc(1 - var(--narr)); --wide: 1; @media (max-width: 36em) { --wide: 0 } @media (max-width: 20em) { --narr: 1 }
} p { --parity: 0; &:nth-child(2n) { --parity: 1 }
}

The number squares are absolutely positioned and their placement depends on parity. If the --parity switch is off (0), then they’re on the left. If it’s on (1), then they’re on the right.

A value of left: 0% aligns with the left edge of the number square along the left edge of its parent, while a value of left: 100% aligns its left edge along the parent’s right edge.

In order to have the right edge of the number square aligned with the right edge of its parent, we need to subtract its own width out of the previous 100% value. (Remember that % values in the case of offsets are relative to the parent’s dimensions.)

left: calc(var(--parity)*(100% - #{$num-d}))

…where $num-d is the size of the numbering square.

In the wide screen case, we also push the numbering outwards by 1em — this means subtracting 1em from the offset we have so far for odd items (having the --parity switch off) and adding 1em to the offset we have so far for even items (having the --parity switch on).

Now the question here is… how do we switch the sign? The simplest way to do it is by using the powers of -1. Sadly, we don’t have a power function (or a power operator) in CSS, even though it would be immensely useful in this case:

/* * for --parity: 0, we have pow(-1, 0) = +1 * for --parity: 1, we have pow(-1, 1) = -1 */
pow(-1, var(--parity))

This means we have to make it work with what we do have (addition, subtraction, multiplication and division) and that leads to a weird little formula… but, hey, it works!

/* * for --parity: 0, we have 1 - 2*0 = 1 - 0 = +1 * for --parity: 1, we have 1 - 2*1 = 1 - 2 = -1 */
--sign: calc(1 - 2*var(--parity))

This way, our final formula for the left offset, taking into account both the parity and whether we’re in the wide case (--wide: 1) or not (--wide: 0), becomes:

left: calc(var(--parity)*(100% - #{$num-d}) - var(--wide)*var(--sign)*1em)

We also control the width of the paragraphs with these variables and max-width as we want it to have an upper limit and only fully cover its parent horizontally in the narrow case (--narr: 1):

width: calc(var(--comp)*80% + var(--narr)*100%);
max-width: 35em;

The font-size also depends on whether we’re in the narrow case (--narr: 1) or not (--narr: 0):

calc(.5rem + var(--comp)*.5rem + var(--narr)*2vw)

…and so do the horizontal offsets for the :after pseudo-element (the bigger rectangle in the back) as they’re 0 in the narrow case (--narr: 1) and a non-zero offset $off-x otherwise (--narr: 0):

right: calc(var(--comp)*#{$off-x}); left: calc(var(--comp)*#{$off-x});

Hover and focus effects

Animated gif. Shows red diagonal sliding bands covering the white button underneath the black text on hover/focus. On mouseout/ blur, the bands slide out the other way, not the way they entered.
Effect recording (live demo, not fully functional in Edge due to nested calc() bug).

This effect is created with a link element and its two pseudo-elements sliding diagonally on the :hover and :focus states. The link’s dimensions are fixed and so are those of its pseudo-elements, set to the diagonal of their parent $btn-d (computed as the hypotenuse in the right triangle formed by a width and a height) horizontally and the parent’s height vertically.

The :before is positioned such that its bottom left corner coincides to that of its parent, while the :after is positioned such that its top right corner coincides with that of its parent. Since both should have the same height as their parent, the vertical placement is resolved by setting top: 0 and bottom: 0. The horizontal placement is handled in the exact same way as in the previous example, using --i as the switch variable that changes value between the two pseudo-elements and --j, its complementary (calc(1 - var(--i))):

left: calc(var(--j)*(100% - #{$btn-d}))

We set the transform-origin of the :before to its left-bottom corner (0% 100%) and :after to its right-top corner (100% 0%), again, with the help of the switch --i and its complementary --j:

transform-origin: calc(var(--j)*100%) calc(var(--i)*100%)

We rotate both pseudo-elements to the angle between the diagonal and the horizontal $btn-a (also computed from the triangle formed by a height and a width, as the arctangent of the ratio between the two). With this rotation, the horizontal edges meet along the diagonal.

We then shift them outwards by their own width. This means we’ll use a different sign for each of the two, again depending on the switch variable that changes value in between the :before and :after, just like in the previous example with the banners:

transform: rotate($btn-a) translate(calc((1 - 2*var(--i))*100%))

In the :hover and :focus states, this translation needs to go back to 0. This means we multiply the amount of the translation above by the complementary --q of the switch variable --p that’s 0 in the normal state and 1 in the :hover or :focus state:

transform: rotate($btn-a) translate(calc(var(--q)*(1 - 2*var(--i))*100%))

In order to make the pseudo-elements slide out the other way (not back the way they came in) on mouse-out or being out of focus, we set the switch variable --i to the value of --p for :before and to the value of --q for :after, reverse the sign of the translation, and make sure we only transition the transform property.

Responsive infographic

On the left, a screenshot of the wide screen scenario. We have a three row, two column grid with the third row collapsed (height zero). The first level heading occupies either the column on the right (for odd items) or the one on the left (for even items). The second level heading is on the other column and on the first row, while the paragraph text is below the second level heading on the second row. On the right, a screenshot of the narrower scenario. In this case, the third row has a height enough to fit the paragraph text, but the second column is collapsed. The first and second level heading occupy the first and second row respectively.
Screenshot collage with the grid lines and gaps highlighted (live demo, no Edge support due to CSS variable and calc() bugs).

In this case, we have a three-row, two-column grid for each item (article element), with the third row collapsed in the wide screen scenario and the second column collapsed in the narrow screen scenario. In the wide screen scenario, the widths of the columns depend on the parity. In the narrow screen scenario, the first column spans the entire content-box of the element and the second one has width 0. We also have a gap in between the columns, but only in the wide screen scenario.

// formulas for the columns in the wide screen case, where
// $col-a-wide is for second level heading + paragraph
// $col-b-wide is for the first level heading
$col-1-wide: calc(var(--q)*#{$col-a-wide} + var(--p)*#{$col-b-wide});
$col-2-wide: calc(var(--q)*#{$col-b-wide} + var(--p)*#{$col-a-wide}); // formulas for the general case, combining the wide and normal scenarios
$row-1: calc(var(--i)*#{$row-1-wide} + var(--j)*#{$row-1-norm});
$row-2: calc(var(--i)*#{$row-2-wide} + var(--j)*#{$row-2-norm});
$row-3: minmax(0, auto);
$col-1: calc(var(--i)*#{$col-1-wide} + var(--j)*#{$col-1-norm});
$col-2: calc(var(--i)*#{$col-2-wide}); $art-g: calc(var(--i)*#{$art-g-wide}); html { --i: var(--wide, 1); // 1 in the wide screen case --j: calc(1 - var(--i)); @media (max-width: $art-w-wide + 2rem) { --wide: 0 }
} article { --p: var(--parity, 0); --q: calc(1 - var(--p)); --s: calc(1 - 2*var(--p)); display: grid; grid-template: #{$row-1} #{$row-2} #{$row-3}/ #{$col-1} #{$col-2}; grid-gap: 0 $art-g; grid-auto-flow: column dense; &:nth-child(2n) { --parity: 1 }
}

Since we’ve set grid-auto-flow: column dense, we can get away with only setting the first level heading to cover an entire column (second one for odd items and first one for even items) in the wide screen case and let the second level heading and the paragraph text fill the first free available cells.

// wide case, odd items: --i is 1, --p is 0, --q is 1
// we're on column 1 + 1*1 = 2
// wide case, even items: --i is 1, --p is 1, --q is 0
// we're on column 1 + 1*0 = 1
// narrow case: --i is 0, so var(--i)*var(--q) is 0 and we're on column 1 + 0 = 1
grid-column: calc(1 + var(--i)*var(--q)); // always start from the first row
// span 1 + 2*1 = 3 rows in the wide screen case (--i: 1)
// span 1 + 2*0 = 1 row otherwise (--i: 0)
grid-row: 1/ span calc(1 + 2*var(--i));

For each item, a few other properties depend on whether we’re in the wide screen scenario or not.

The vertical margin, vertical and horizontal padding values, box-shadow offsets and blur are all bigger in the wide screen case:

$art-mv: calc(var(--i)*#{$art-mv-wide} + var(--j)*#{$art-mv-norm});
$art-pv: calc(var(--i)*#{$art-pv-wide} + var(--j)*#{$art-p-norm});
$art-ph: calc(var(--i)*#{$art-ph-wide} + var(--j)*#{$art-p-norm});
$art-sh: calc(var(--i)*#{$art-sh-wide} + var(--j)*#{$art-sh-norm}); article { /* other styles */ margin: $art-mv auto; padding: $art-pv $art-ph; box-shadow: $art-sh $art-sh calc(3*#{$art-sh}) rgba(#000, .5);
}

We have a non-zero border-width and border-radius in the wide screen case:

$art-b: calc(var(--i)*#{$art-b-wide});
$art-r: calc(var(--i)*#{$art-r-wide}); article { /* other styles */ border: solid $art-b transparent; border-radius: $art-r;
}

In the wide screen scenario, we limit the items’ width, but let it be 100% otherwise.

$art-w: calc(var(--i)*#{$art-w-wide} + var(--j)*#{$art-w-norm}); article { /* other styles */ width: $art-w;
}

The direction of the padding-box gradient also changes with the parity:

background: linear-gradient(calc(var(--s)*90deg), #e6e6e6, #ececec) padding-box, linear-gradient(to right bottom, #fff, #c8c8c8) border-box;

In a similar manner, margin, border-width, padding, width, border-radius, background gradient direction, font-size or line-height for the headings and the paragraph text also depend on whether we’re in the wide screen scenario or not (and, in the case of the first level heading’s border-radius or background gradient direction, also on the parity).

The post DRY Switching with CSS Variables: The Difference of One Declaration appeared first on CSS-Tricks.

1 Element CSS Rainbow Gradient Infinity

I first got the idea to CSS something of the kind when I saw this gradient infinity logo by Infographic Paradise:

Original illustration. Shows a thick infinity symbol with a rainbow gradient filling its two loops and some highlights over this gradient.
The original gradient infinity.

After four hours and some twenty minutes, of which over four hours were spent on tweaking positioning, edges and highlights… I finally had the result below:

Screenshot of my version. Shows a thick infinity symbol with a rainbow gradient filling its two loops and some highlights over this gradient.
My version of the rainbow gradient infinity.

The gradient doesn’t look like in the original illustration, as I chose to generate the rainbow logically instead of using the Dev Tools picker or something like that, but other than that, I think I got pretty close—let’s see how I did that!

Markup

As you’ve probably already guessed from the title, the HTML is just one element:

<div class='∞'></div>

Styling

Deciding on the approach

The first idea that might come to mind when seeing the above would be using conic gradients as border images. Unfortunately, border-image and border-radius don’t play well together, as illustrated by the interactive demo below:

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

Whenever we set a border-image, border-radius just gets ignored, so using the two together is sadly not an option.

So the approach we take here is using conic-gradient() backgrounds and then getting rid of the part in the middle with the help of a mask. Let’s see how that works!

Creating the two ∞ halves

We first decide on an outer diameter.

$do: 12.5em;

We create the two halves of the infinity symbol using the ::before and ::after pseudo-elements of our .∞ element. In order to place these two pseudo-elements next to one another, we use a flex layout on their parent (the infinity element .∞). Each of these has both the width and the height equal to the outer diameter $do. We also round them with a border-radius of 50% and we give them a dummy background so we can see them.

.∞ { display: flex; &:before, &:after { width: $do; height: $do; border-radius: 50%; background: #000; content: ''; }
}

We’ve also placed the .∞ element in the middle of its parent (the body in this case) both vertically and horizontally by using the flexbox approach.

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

How conic-gradient() works

In order to create the conic-gradient() backgrounds for the two haves, we must first understand how the conic-gradient() function works.

If inside the conic-gradient() function we have a list of stops without explicit positions, then the first is taken to be at 0% (or 0deg, same thing), the last is taken to be at 100% (or 360deg), while all those left are distributed evenly in the [0%, 100%] interval.

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

If we have just 2 stops, it’s simple. The first is at 0%, the second (and last) at 100% and there are no other stops in between.

If we have 3 stops, the first is at 0%, the last (third) at 100%, while the second is dead in the middle of the [0%, 100%] interval, at 50%.

If we have 4 stops, the first is at 0%, the last (fourth) at 100%, while the second and third split the [0%, 100%] interval into 3 equal intervals, being positioned at 33.(3)% and 66.(6)% respectively.

If we have 5 stops, the first is at 0%, the last (fifth) at 100%, while the second, third and fourth split the [0%, 100%] interval into 4 equal intervals being positioned at 25%, 50% and 75% respectively.

If we have 6 stops, the first is at 0%, the last (sixth) at 100%, while the second, third, fourth and fifth split the [0%, 100%] interval into 5 equal intervals being positioned at 20%, 40%, 60% and 80% respectively.

In general, if we have n stops, the first is at 0%, the last at 100%, while the ones in between split the [0%, 100%] interval into n-1 eqial intervals spanning 100%/(n-1) each. If we give the stops 0-based indices, then each one of them is positioned at i*100%/(n-1).

For the first one, i is 0, which gives us 0*100%/(n-1) = 0%.

For the last (n-th) one, i is n-1, which gives us (n-1)*100%/(n-1) = 100%.

Here, we choose to use 9 stops which means we split the [0%, 100%] interval into 8 equal intervals.

Alright, but how do we get the stop list?

The hsl() stops

Well, for simplicity, we choose to generate it as a list of HSL values. We keep the saturation and the lightness fixed and we vary the hue. The hue is an angle value that goes from 0 to 360, as we can see here:

Hue scale from 0 to 360 in the HSB/HSL models.
Visual representation of the hue scale from 0 to 360 (saturation and lightness being kept constant).

With this in mind, we can construct a list of hsl() stops with fixed saturation and lightness and varying hue if we know the start hue $hue-start, the hue range $hue-range (this is the end hue minus the start hue) and the number of stops $num-stops.

Let’s say we keep the saturation and the lightness fixed at 85% and 57%, respectively (arbitrary values that can probably be tweaked for better results) and, for example, we might go from a start hue of 240 to an end hue of 300 and use 4 stops.

In order to generate this list of stops, we use a get-stops() function that takes these three things as arguments:

@function get-stops($hue-start, $hue-range, $num-stops) {}

We create the list of stops $list which is originally empty (and which we’ll return at the end after we populate it). We also compute the span of one of the equal intervals our stops split the full start to end interval into ($unit).

@function get-stops($hue-start, $hue-range, $num-stops) { $list: (); $unit: $hue-range/($num-stops - 1); /* populate the list of stops $list */ @return $list
}

In order to populate our $list, we loop through the stops, compute the current hue, use the current hue to generate the hsl() value at that stop and then then add it to the list of stops:

@for $i from 0 to $num-stops { $hue-curr: $hue-start + $i*$unit; $list: $list, hsl($hue-curr, 85%, 57%);
}

We can now use the stop list this function returns for any kind of gradient, as it can be seen from the usage examples for this function shown in the interactive demo below (navigation works both by using the previous/next buttons on the sides as well as the arrow keys and the PgDn/ PgUp keys):

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

Note how, when our range passes one end of the [0, 360] interval, it continues from the other end. For example, when the start hue is 30 and the range is -210 (the fourth example), we can only go down to 0, so then we continue going down from 360.

Conic gradients for our two halves

Alright, but how do we determine the $hue-start and the $hue-range for our particular case?

In the original image, we draw a line in between the central points of the two halves of the loop and, starting from this line, going clockwise in both cases, we see where we start from and where we end up in the [0, 360] hue interval and what other hues we pass through.

Original illustration, annotated. We've marked out the central points of the two halves, connected them with a line and used this line as the start for going around each of the two halves in the clockwise direction.
We start from the line connecting the central points of the two halves and we go around them in the clockwise direction.

To simplify things, we consider we pass through the whole [0, 360] hue scale going along our infinity symbol. This means the range for each half is 180 (half of 360) in absolute value.

Hue scale from 0 to 360 in the HSB/HSL models, with saturation and lightness fixed at 100% and 50% respectively. Red corresponds to a hue of 0/ 360, yellow to a hue of 60, lime to a hue of 120, cyan to a hue of 180, blue to a hue of 240, magenta to a hue of 300.
Keywords to hue values correspondence for saturation and lightness fixed at 100% and 50% respectively.

On the left half, we start from something that looks like it’s in between some kind of cyan (hue 180) and some kind of lime (hue 120), so we take the start hue to be the average of the hues of these two (180 + 120)/2 = 150.

Original illustration, annotated. For the left half, our start hue is 150 (something between a kind of cyan and a kind of lime), we pass through yellows, which are around 60 in hue and end up at a kind of red, 180 away from the start, so at 330.
The plan for the left half.

We get to some kind of red, which is 180 away from the start value, so at 330, whether we subtract or add 180:

(150 - 180 + 360)%360 = (150 + 180 + 360)%360 = 330

So… do we go up or down? Well, we pass through yellows which are around 60 on the hue scale, so that’s going down from 150, not up. Going down means our range is negative (-180).

Original illustration, annotated. For the right half, our start hue is 150 (something between a kind of cyan and a kind of lime), we pass through blues, which are around 240 in hue and end up at a kind of red, 180 away from the start, so at 330.
The plan for the right half.

On the right half, we also start from the same hue in between cyan and lime (150) and we also end at the same kind of red (330), but this time we pass through blues, which are around 240, meaning we go up from our start hue of 150, so our range is positive in this case (180).

As far as the number of stops goes, 9 should suffice.

Now update our code using the values for the left half as the defaults for our function:

@function get-stops($hue-start: 150, $hue-range: -180, $num-stops: 9) { /* same as before */
} .∞ { display: flex; &:before, &:after { /* same as before */ background: conic-gradient(get-stops()); } &:after { background: conic-gradient(get-stops(150, 180)); }
}

And now our two discs have conic-gradient() backgrounds:

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

However, we don’t want these conic gradients to start from the top.

For the first disc, we want it to start from the right—that’s at 90° from the top in the clockwise (positive) direction. For the second disc, we want it to start from the left—that’s at 90° from the top in the other (negative) direction, which is equivalent to 270° from the top in the clockwise direction (because negative angles don’t appear to work from some reason).

The conic gradient for the first (left) half starts from the right, which means an offset of 90° in the clockwise (positive) direction from the top. The conic gradient for the second (right) half starts from the left, which means an offset of 270° in the clockwise (positive) direction (and of 90° in the negative direction) from the top.
Angular offsets from the top for our two halves.

Let’s modify our code to achieve this:

.∞ { display: flex; &:before, &:after { /* same as before */ background: conic-gradient(from 90deg, get-stops()); } &:after { background: conic-gradient(from 270deg, get-stops(150, 180)); }
}

So far, so good!

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

From 🥧 to 🍩

The next step is to cut holes out of our two halves. We do this with a mask or, more precisely, with a radial-gradient() one. This cuts out Edge support for now, but since it’s something that’s in development, it’s probably going to be a cross-browser solution at some point in the not too far future.

Remember that CSS gradient masks are alpha masks by default (and only Firefox currently allows changing this via mask-mode), meaning that only the alpha channel matters. Overlaying the mask over our element makes every pixel of this element use the alpha channel of the corresponding pixel of the mask. If the mask pixel is completely transparent (its alpha value is 0), then so will the corresponding pixel of the element.

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

In order to create the mask, we compute the outer radius $ro (half the outer diameter $do) and the inner radius $ri (a fraction of the outer radius $ro).

$ro: .5*$do;
$ri: .52*$ro;
$m: radial-gradient(transparent $ri, red 0);

We then set the mask on our two halves:

.∞ { /* same as before */ &:before, &:after { /* same as before */ mask: $m; }
}

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

This looks perfect in Firefox, but the edges of radial gradients with abrupt transitions from one stop to another look ugly in Chrome and, consequently, so do the inner edges of our rings.

Screenshot. Shows a close-up of the inner edge of the right half in Chrome. These inner edges look jagged and ugly in Chrome.
Close-up of the inner edge of the right half in Chrome.

The fix here would be not to have an abrupt transition between stops, but spread it out over a small distance, let’s say half a pixel:

$m: radial-gradient(transparent calc(#{$ri} - .5px), red $ri);

We now got rid of the jagged edges in Chrome:

Screenshot. Shows a close-up of the inner edge of the right half in Chrome after spreading out the transition between stops over half a pixel. These inner edges now look blurry and smoother in Chrome.
Close-up of the inner edge of the right half in Chrome after spreading out the transition between stops over half a pixel.

The following step is to offset the two halves such that they actually form an infinity symbol. The visible circular strips both have the same width, the difference between the outer radius $ro and the inner radius $ri. This means we need to shift each laterally by half this difference $ri - $ri.

.∞ { /* same as before */ &:before, &:after { /* same as before */ margin: 0 (-.5*($ro - $ri)); }
}

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

Intersecting halves

We’re getting closer, but we still have a very big problem here. We don’t want the right part of the loop to be completely over the left one. Instead, we want the top half of the right part to be over that of the left part and the bottom half of the left part to be over that of the right part.

So how do we achieve that?

We take a similar approach to that presented in an older article: using 3D!

In order to better understand how this works, consider the two card example below. When we rotate them around their x axes, they’re not in the plane of the screen anymore. A positive rotation brings the bottom forward and pushes the top back. A negative rotation brings the top forward and pushes the bottom back.

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

Note that the demo above doesn’t work in Edge.

So if we give the left one a positive rotation and the right one a negative rotation, then the top half of the right one appears in front of the top half of the left one and the other way around for the bottom halves.

Addiing perspective makes what’s closer to our eyes appears bigger and what’s further away appears smaller and we use way smaller angles. Without it, we have the 3D plane intersection without the 3D appearance.

Note that both our halves need to be in the same 3D context, something that’s achieved by setting transform-style: preserve-3d on the .∞ element.

.∞ { /* same as before */ transform-style: preserve-3d; &:before, &:after { /* same as before */ transform: rotatex(1deg); } &:after { /* same as before */ transform: rotatex(-1deg); }
}

And now we’re almost there, but not quite:

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

Fine tuning

We have a little reddish strip in the middle because the gradient ends and the intersection line don’t quite match:

Screenshot. Shows a close-up of the intersection of the two halves. In theory, the intersection line should match the start/ end line of the conic gradients, but this isn't the case in practice, so we're still seeing a strip of red along it, even though the red side should be behind the plane of the screen and not visible.
Close-up of small issue at the intersection of the two halves.

A pretty ugly, but efficient fix is to add a 1px translation before the rotation on the right part (the ::after pseudo-element):

.∞:after { transform: translate(1px) rotatex(-1deg) }

Much better!

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

This still isn’t perfect though. Since the inner edges of our two rings are a bit blurry, the transition in between them and the crisp outer ones looks a bit odd, so maybe we can do better there:

Screenshot. Shows a close-up of the area around the intersection of the two halves, where the crisp outer edges meet the blurry inner ones, which looks odd.
Close-up of continuity issue (crisp outer edges meeting blurry inner ones).

A quick fix here would be to add a radial-gradient() cover on each of the two halves. This cover is transparent white for most of the unmasked part of the two halves and goes to solid white along both their inner and outer edges such that we have nice continuity:

$gc: radial-gradient(#fff $ri, rgba(#fff, 0) calc(#{$ri} + 1px), rgba(#fff, 0) calc(#{$ro} - 1px), #fff calc(#{$ro} - .5px)); .∞ { /* same as before */ &:before, &:after { /* same as before */ background: $gc, conic-gradient(from 90deg, get-stops()); } &:after { /* same as before */ background: $gc, conic-gradient(from 270deg, get-stops(150, 180)); }
}

The benefit becomes more obvious once we add a dark background to the body:

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

Now it looks better even when zooming in:

Screenshot. Shows a close-up of the area around the intersection of the two halves, we don't have the same sharp contrast between inner and outer edges, not even when zooming in.
No more sharp contrast between inner and outer edges.

The final result

Finally, we add some prettifying touches by layering some more subtle radial gradient highlights over the two halves. This was the part that took me the most because it involved the least amount of logic and the most amount of trial and error. At this point, I just layered the original image underneath the .∞ element, made the two halves semi-transparent and started adding gradients and tweaking them until they pretty much matched the highlights. And you can see when I got sick of it because that’s when the position values become rougher approximations with fewer decimals.

Another cool touch would be drop shadows on the whole thing using a filter on the body. Sadly, this breaks the 3D intersection effect in Firefox, which means we cannot add it there, too.

@supports not (-moz-transform: scale(2)) { filter: drop-shadow(.25em .25em .25em #000) drop-shadow(.25em .25em .5em #000);
}

We now have the final static result!

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

Spicing it up with animation!

When I first shared this demo, I got asked about animating it. I initially thought this would be complicated, but then it hit me that, thanks to Houdini, it doesn’t have to be!

As mentioned in my previous article, we can animate in between stops, let’s say from a red to a blue. In our case, the saturation and lightness components of the hsl() values used to generate the rainbow gradient stay constant, all that changes is the hue.

For each and every stop, the hue goes from its initial value to its initial value plus 360, thus passing through the whole hue scale in the process. This is equivalent to keeping the initial hue constant and varying an offset. This offset --off is the custom property we animate.

Sadly, this means support is limited to Blink browsers with the Experimental Web Platform features flag enabled.

Screenshot showing the Experimental Web Platform features flag being enabled in Chrome.
The Experimental Web Platform features flag enabled in Chrome.

Still, let’s see how we put it all into code!

For starters, we modify the get-stops() function such that the current hue at any time is the initial hue of the current stop $hue-curr plus our offset --off:

$list: $list, hsl(calc(#{$hue-curr} + var(--off, 0)), 85%, 57%);

Next, we register this custom property:

CSS.registerProperty({ name: '--off', syntax: '<number>', initialValue: 0;
})

And finally, we animate it to 360:

.∞ { /* same as before */ &:before, &:after { /* same as before */ animation: shift 2s linear infinite; }
} @keyframes shift { to { --off: 360 } }

This gives us our animated gradient infinity!

Animated ∞ logo (live demo, Blink only with flag enabled).

That’s it! I hope you’ve enjoyed this dive into what can be done with CSS these days!

The post 1 Element CSS Rainbow Gradient Infinity appeared first on CSS-Tricks.

A Strategy Guide To CSS Custom Properties

CSS preprocessor variables and CSS custom properties (often referred to as “CSS variables”) can do some of the same things, but are not the same.

Practical advice from Mike Riethmuller:

If it is alright to use static variables inside components, when should we use custom properties? Converting existing preprocessor variables to custom properties usually makes little sense. After all, the reason for custom properties is completely different. Custom properties make sense when we have CSS properties that change relative to a condition in the DOM — especially a dynamic condition such as :focus, :hover, media queries or with JavaScript.

Direct Link to Article — Permalink

The post A Strategy Guide To CSS Custom Properties appeared first on CSS-Tricks.

1 HTML Element + 5 CSS Properties = Magic!

Let’s say I told you we can get the results below with just one HTML element and five CSS properties for each. No SVG, no images (save for the background on the root that’s there just to make clear that our one HTML element has some transparent parts), no JavaScript. What would you think that involves?

Screenshots. On the left, a screenshot of equal radial slices of a pie with transparent slices (gaps) in between them. The whole assembly has a top to bottom gradient (orange to purple). On the right, the XOR operation between what we have on the left and a bunch of concentric ripples. Again, the whole assembly has the same top to bottom gradient.
The desired results.

Well, this article is going to explain just how to do this and then also show how to make things fun by adding in some animation.

CSS-ing the Gradient Rays

The HTML is just one <div>.

<div class='rays'></div>

In the CSS, we need to set the dimensions of this element and we need to give it a background so that we can see it. We also make it circular using border-radius:

.rays { width: 80vmin; height: 80vmin; border-radius: 50%; background: linear-gradient(#b53, #f90);
}

And… we’ve already used up four out of five properties to get the result below:

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

So what’s the fifth? mask with a repeating-conic-gradient() value!

Let’s say we want to have 20 rays. This means we need to allocate $p: 100%/20 of the full circle for a ray and the gap after it.

Illustration. Shows how we slice the disc to divide it into equal rays and gaps.
Dividing the disc into rays and gaps (live).

Here we keep the gaps in between rays equal to the rays (so that’s .5*$p for either a ray or a space), but we can make either of them wider or narrower. We want an abrupt change after the ending stop position of the opaque part (the ray), so the starting stop position for the transparent part (the gap) should be equal to or smaller than it. So if the ending stop position for the ray is .5*$p, then the starting stop position for the gap can’t be bigger. However, it can be smaller and that helps us keep things simple because it means we can simply zero it.

SVG illustration. Connects the stop positions from the code to the actual corresponding points on the circle defining the repeating conic gradient.
How repeating-conic-gradient() works (live).
$nr: 20; // number of rays
$p: 100%/$nr; // percent of circle allocated to a ray and gap after .rays { /* same as before */ mask: repeating-conic-gradient(#000 0% .5*$p, transparent 0% $p);
}

Note that, unlike for linear and radial gradients, stop positions for conic gradients cannot be unitless. They need to be either percentages or angular values. This means using something like transparent 0 $p doesn’t work, we need transparent 0% $p (or 0deg instead of 0%, it doesn’t matter which we pick, it just can’t be unitless).

Screenshot of equal radial slices of a pie with transparent slices (gaps) in between them. The whole assembly has a top to bottom gradient (orange to purple).
Gradient rays (live demo, no Edge support).

There are a few things to note here when it comes to support:

  • Edge doesn’t support masking on HTML elements at this point, though this is listed as In Development and a flag for it (that doesn’t do anything for now) has already shown up in about:flags.
    Screenshot showing the about:flags page in Edge, with the 'Enable CSS Masking' flag highlighted.
    The Enable CSS Masking flag in Edge.
  • conic-gradient() is only supported natively by Blink browsers behind the Experimental Web Platform features flag (which can be enabled from chrome://flags or opera://flags). Support is coming to Safari as well, but, until that happens, Safari still relies on the polyfill, just like Firefox.
    Screenshot showing the Experimental Web Platform Features flag being enabled in Chrome.
    The Experimental Web Platform features flag enabled in Chrome.
  • WebKit browsers still need the -webkit- prefix for mask properties on HTML elements. You’d think that’s no problem since we’re using the polyfill which relies on -prefix-free anyway, so, if we use the polyfill, we need to include -prefix-free before that anyway. Sadly, it’s a bit more complicated than that. That’s because -prefix-free works via feature detection, which fails in this case because all browsers do support mask unprefixed… on SVG elements! But we’re using mask on an HTML element here, so we’re in the situation where WebKit browsers need the -webkit- prefix, but -prefix-free won’t add it. So I guess that means we need to add it manually:
    $nr: 20; // number of rays
    $p: 100%/$nr; // percent of circle allocated to a ray and gap after
    $m: repeating-conic-gradient(#000 0% .5*$p, transparent 0% $p); // mask .rays { /* same as before */ -webkit-mask: $m; mask: $m;
    }

    I guess we could also use Autoprefixer, even if we need to include -prefix-free anyway, but using both just for this feels a bit like using a shotgun to kill a fly.

Adding in Animation

One cool thing about conic-gradient() being supported natively in Blink browsers is that we can use CSS variables inside them (we cannot do that when using the polyfill). And CSS variables can now also be animated in Blink browsers with a bit of Houdini magic (we need the Experimental Web Platform features flag to be enabled for that, but we also need it enabled for native conic-gradient() support, so that shouldn’t be a problem).

In order to prepare our code for the animation, we change our masking gradient so that it uses variable alpha values:

$m: repeating-conic-gradient( rgba(#000, var(--a)) 0% .5*$p, rgba(#000, calc(1 - var(--a))) 0% $p);

We then register the alpha --a custom property:

CSS.registerProperty({ name: '--a', syntax: '<number>', initialValue: 1;
})

And finally, we add in an animation in the CSS:

.rays { /* same as before */ animation: a 2s linear infinite alternate;
} @keyframes a { to { --a: 0 } }

This gives us the following result:

Animated gif. We animate the alpha of the gradient stops, such that the rays go from fully opaque to fully transparent, effectively becoming gaps, while the opposite happens for the initial gaps, they go from fully transparent to fully opaque, thus becoming rays. At any moment, the alpha of either of them is 1 minus the alpha of the other, so they complement each other.
Ray alpha animation (live demo, only works in Blink browsers with the Experimental Web Platform features flag enabled).

Meh. Doesn’t look that great. We could however make things more interesting by using multiple alpha values:

$m: repeating-conic-gradient( rgba(#000, var(--a0)) 0%, rgba(#000, var(--a1)) .5*$p, rgba(#000, var(--a2)) 0%, rgba(#000, var(--a3)) $p);

The next step is to register each of these custom properties:

for(let i = 0; i < 4; i++) { CSS.registerProperty({ name: `--a${i}`, syntax: '<number>', initialValue: 1 - ~~(i/2) })
}

And finally, add the animations in the CSS:

.rays { /* same as before */ animation: a 2s infinite alternate; animation-name: a0, a1, a2, a3; animation-timing-function: /* easings from easings.net */ cubic-bezier(.57, .05, .67, .19) /* easeInCubic */, cubic-bezier(.21, .61, .35, 1); /* easeOutCubic */
} @for $i from 0 to 4 { @keyframes a#{$i} { to { --a#{$i}: #{floor($i/2)} } }
}

Note that since we’re setting values to custom properties, we need to interpolate the floor() function.

Animated gif. This time, the alpha of each and every stop (start and end of ray, start and end of gap) is animated independently via its own CSS variable. The alphas at the start and end of the ray both go from 1 to 0, but using different timing functions. The alphas at the start and end of the gap both go from 0 to 1, but, again, using different timing functions.
Multiple ray alpha animations (live demo, only works in Blink browsers with the Experimental Web Platform features flag enabled).

It now looks a bit more interesting, but surely we can do better?

Let’s try using a CSS variable for the stop position between the ray and the gap:

$m: repeating-conic-gradient(#000 0% var(--p), transparent 0% $p);

We then register this variable:

CSS.registerProperty({ name: '--p', syntax: '<percentage>', initialValue: '0%'
})

And we animate it from the CSS using a keyframe animation:

.rays { /* same as before */ animation: p .5s linear infinite alternate
} @keyframes p { to { --p: #{$p} } }

The result is more interesting in this case:

Animated gif. The stop position between the ray an the gap animates from 0 (when the ray is basically reduced to nothing) to the whole percentage $p allocated for a ray and the gap following it (which basically means we don't have a gap anymore) and then back to 0 again.
Alternating ray size animation (live demo, only works in Blink browsers with the Experimental Web Platform features flag enabled).

But we can still spice it up a bit more by flipping the whole thing horizontally in between every iteration, so that it’s always flipped for the reverse ones. This means not flipped when --p goes from 0% to $p and flipped when --p goes back from $p to 0%.

The way we flip an element horizontally is by applying a transform: scalex(-1) to it. Since we want this flip to be applied at the end of the first iteration and then removed at the end of the second (reverse) one, we apply it in a keyframe animation as well—in one with a steps() timing function and double the animation-duration.

 $t: .5s; .rays { /* same as before */ animation: p $t linear infinite alternate, s 2*$t steps(1) infinite;
} @keyframes p { to { --p: #{$p} } } @keyframes s { 50% { transform: scalex(-1); } }

Now we finally have a result that actually looks pretty cool:

Animated gif. We have the same animation as before, plus a horizontal flip at the end of every iteration which creates the illusion of a circular sweep instead of just increasing and then decreasing rays, as the rays seems to now decrease from the start after they got to their maximum size incresing from the end.
Alternating ray size animation with horizontal flip in between iterations (live demo, only works in Blink browsers with the Experimental Web Platform features flag enabled).

CSS-ing Gradient Rays and Ripples

To get the rays and ripples result, we need to add a second gradient to the mask, this time a repeating-radial-gradient().

SVG illustration. Connects the stop positions from the code to the actual corresponding points on the circle defining the repeating radial gradient.
How repeating-radial-gradient() works (live).
$nr: 20;
$p: 100%/$nr;
$stop-list: #000 0% .5*$p, transparent 0% $p;
$m: repeating-conic-gradient($stop-list), repeating-radial-gradient(closest-side, $stop-list); .rays-ripples { /* same as before */ mask: $m;
}

Sadly, using multiple stop positions only works in Blink browsers with the same Experimental Web Platform features flag enabled. And while the conic-gradient() polyfill covers this for the repeating-conic-gradient() part in browsers supporting CSS masking on HTML elements, but not supporting conic gradients natively (Firefox, Safari, Blink browsers without the flag enabled), nothing fixes the problem for the repeating-radial-gradient() part in these browsers.

This means we’re forced to have some repetition in our code:

$nr: 20;
$p: 100%/$nr;
$stop-list: #000, #000 .5*$p, transparent 0%, transparent $p;
$m: repeating-conic-gradient($stop-list), repeating-radial-gradient(closest-side, $stop-list); .rays-ripples { /* same as before */ mask: $m;
}

We’re obviously getting closer, but we’re not quite there yet:

Screenshot. We have the same radial slices with equal gaps in between, and over them, a layer of ripples - concentric rings with gaps equal to their width in between them. The whole thing has a top to bottom gradient (orange to purple) with transparent parts where the gaps of the two layers intersect.
Intermediary result with the two mask layers (live demo, no Edge support).

To get the result we want, we need to use the mask-composite property and set it to exclude:

$m: repeating-conic-gradient($stop-list) exclude, repeating-radial-gradient(closest-side, $stop-list);

Note that mask-composite is only supported in Firefox 53+ for now, though Edge should join in when it finally supports CSS masking on HTML elements.

Screenshot. We have the same result as before, except now we have performed a XOR operation between the two layers (rays and ripples).
XOR rays and ripples (live demo, Firefox 53+ only).

If you think it looks like the rays and the gaps between the rays are not equal, you’re right. This is due to a polyfill issue.

Adding in Animation

Since mask-composite only works in Firefox for now and Firefox doesn’t yet support conic-gradient() natively, we cannot put CSS variables inside the repeating-conic-gradient() (because Firefox still falls back on the polyfill for it and the polyfill doesn’t support CSS variable usage). But we can put them inside the repeating-radial-gradient() and even if we cannot animate them with CSS keyframe animations, we can do so with JavaScript!

Because we’re now putting CSS variables inside the repeating-radial-gradient(), but not inside the repeating-conic-gradient() (as the XOR effect only works via mask-composite, which is only supported in Firefox for now and Firefox doesn’t support conic gradients natively, so it falls back on the polyfill, which doesn’t support CSS variable usage), we cannot use the same $stop-list for both gradient layers of our mask anymore.

But if we have to rewrite our mask without a common $stop-list anyway, we can take this opportunity to use different stop positions for the two gradients:

// for conic gradient
$nc: 20;
$pc: 100%/$nc;
// for radial gradient
$nr: 10;
$pr: 100%/$nr;

The CSS variable we animate is an alpha --a one, just like for the first animation in the rays case. We also introduce the --c0 and --c1 variables because here we cannot have multiple positions per stop and we want to avoid repetition as much as possible:

$m: repeating-conic-gradient(#000 .5*$pc, transparent 0% $pc) exclude, repeating-radial-gradient(closest-side, var(--c0), var(--c0) .5*$pr, var(--c1) 0, var(--c1) $pr); body { --a: 0; /* layout, backgrounds and other irrelevant stuff */
} .xor { /* same as before */ --c0: #{rgba(#000, var(--a))}; --c1: #{rgba(#000, calc(1 - var(--a)))}; mask: $m;
}

The alpha variable --a is the one we animate back and forth (from 0 to 1 and then back to 0 again) with a little bit of vanilla JavaScript. We start by setting a total number of frames NF the animation happens over, a current frame index f and a current animation direction dir:

const NF = 50; let f = 0, dir = 1;

Within an update() function, we update the current frame index f and then we set the current progress value (f/NF) to the current alpha --a. If f has reached either 0 of NF, we change the direction. Then the update() function gets called again on the next refresh.

(function update() { f += dir; document.body.style.setProperty('--a', (f/NF).toFixed(2)); if(!(f%NF)) dir *= -1; requestAnimationFrame(update)
})();

And that’s all for the JavaScript! We now have an animated result:

Animated gif. We animate the alpha of the gradient stops, such that the ripples go from fully opaque to fully transparent, effectively becoming gaps, while the opposite happens for the initial gaps, they go from fully transparent to fully opaque, thus becoming ripples. At any moment, the alpha of either of them is 1 minus the alpha of the other, so they complement each other. In this case, the animation is linear, the alpha changing at the same rate from start to finish.
Ripple alpha animation, linear (live demo, only works in Firefox 53+).

This is a linear animation, the alpha value --a being set to the progress f/NF. But we can change the timing function to something else, as explained in an earlier article I wrote on emulating CSS timing functions with JavaScript.

For example, if we want an ease-in kind of timing function, we set the alpha value to easeIn(f/NF) instead of just f/NF, where we have that easeIn() is:

function easeIn(k, e = 1.675) { return Math.pow(k, e)
}

The result when using an ease-in timing function can be seen in this Pen (working only in Firefox 53+). If you’re interested in how we got this function, it’s all explained in the previously linked article on timing functions.

The exact same approach works for easeOut() or easeInOut():

function easeOut(k, e = 1.675) { return 1 - Math.pow(1 - k, e)
}; function easeInOut(k) { return .5*(Math.sin((k - .5)*Math.PI) + 1)
}

Since we’re using JavaScript anyway, we can make the whole thing interactive, so that the animation only happens on click/tap, for example.

In order to do so, we add a request ID variable (rID), which is initially null, but then takes the value returned by requestAnimationFrame() in the update() function. This enables us to stop the animation with a stopAni() function whenever we want to:

 /* same as before */ let rID = null; function stopAni() { cancelAnimationFrame(rID); rID = null
}; function update() { /* same as before */ if(!(f%NF)) { stopAni(); return } rID = requestAnimationFrame(update)
};

On click, we stop any animation that may be running, reverse the animation direction dir and call the update() function:

addEventListener('click', e => { if(rID) stopAni(); dir *= -1; update()
}, false);

Since we start with the current frame index f being 0, we want to go in the positive direction, towards NF on the first click. And since we’re reversing the direction on every click, it results that the initial value for the direction must be -1 now so that it gets reversed to +1 on the first click.

The result of all the above can be seen in this interactive Pen (working only in Firefox 53+).

We could also use a different alpha variable for each stop, just like we did in the case of the rays:

$m: repeating-conic-gradient(#000 .5*$pc, transparent 0% $pc) exclude, repeating-radial-gradient(closest-side, rgba(#000, var(--a0)), rgba(#000, var(--a1)) .5*$pr, rgba(#000, var(--a2)) 0, rgba(#000, var(--a3)) $pr);

In the JavaScript, we have the ease-in and ease-out timing functions:

const TFN = { 'ease-in': function(k, e = 1.675) { return Math.pow(k, e) }, 'ease-out': function(k, e = 1.675) { return 1 - Math.pow(1 - k, e) }
};

In the update() function, the only difference from the first animated demo is that we don’t change the value of just one CSS variable—we now have four to take care of: --a0, --a1, --a2, --a3. We do this within a loop, using the ease-in function for the ones at even indices and the ease-out function for the others. For the first two, the progress is given by f/NF, while for the last two, the progress is given by 1 - f/NF. Putting all of this into one formula, we have:

(function update() { f += dir; for(var i = 0; i < 4; i++) { let j = ~~(i/2); document.body.style.setProperty( `--a${i}`, TFN[i%2 ? 'ease-out' : 'ease-in'](j + Math.pow(-1, j)*f/NF).toFixed(2) ) } if(!(f%NF)) dir *= -1; requestAnimationFrame(update)
})();

The result can be seen below:

Animated gif. This time, the alpha of each and every stop (start and end of ripple, start and end of gap) is animated independently via its own CSS variable. The alphas at the start and end of the ripple both go from 1 to 0, but using different timing functions. The alphas at the start and end of the gap both go from 0 to 1, but, again, using different timing functions.
Multiple ripple alpha animations (live demo, only works in Firefox 53+).

Just like for conic gradients, we can also animate the stop position between the opaque and the transparent part of the masking radial gradient. To do so, we use a CSS variable --p for the progress of this stop position:

$m: repeating-conic-gradient(#000 .5*$pc, transparent 0% $pc) exclude, repeating-radial-gradient(closest-side, #000, #000 calc(var(--p)*#{$pr}), transparent 0, transparent $pr);

The JavaScript is almost identical to that for the first alpha animation, except we don’t update an alpha --a variable, but a stop progress --p variable and we use an ease-in-out kind of function:

/* same as before */ function easeInOut(k) { return .5*(Math.sin((k - .5)*Math.PI) + 1)
}; (function update() { f += dir; document.body.style.setProperty('--p', easeInOut(f/NF).toFixed(2)); /* same as before */
})();
Animated gif. The stop position between the ripple an the gap animates from 0 (when the ripple is basically reduced to nothing) to the whole percentage $pr allocated for a ripple and the gap following it (which basically means we don't have a gap anymore) and then back to 0 again.
Alternating ripple size animation (live demo, only works in Firefox 53+).

We can make the effect more interesting if we add a transparent strip before the opaque one and we also animate the progress of the stop position --p0 where we go from this transparent strip to the opaque one:

$m: repeating-conic-gradient(#000 .5*$pc, transparent 0% $pc) exclude, repeating-radial-gradient(closest-side, transparent, transparent calc(var(--p0)*#{$pr}), #000, #000 calc(var(--p1)*#{$pr}), transparent 0, transparent $pr);

In the JavaScript, we now need to animate two CSS variables: --p0 and --p1. We use an ease-in timing function for the first and an ease-out for the second one. We also don’t reverse the animation direction anymore:

const NF = 120, TFN = { 'ease-in': function(k, e = 1.675) { return Math.pow(k, e) }, 'ease-out': function(k, e = 1.675) { return 1 - Math.pow(1 - k, e) } }; let f = 0; (function update() { f = (f + 1)%NF; for(var i = 0; i < 2; i++) document.body.style.setProperty(`--p${i}`, TFN[i ? 'ease-out' : 'ease-in'](f/NF); requestAnimationFrame(update)
})();

This gives us a pretty interesting result:

Animated gif. We now have one extra transparent circular strip before the opaque and transparent ones we previously had. Initially, both the start and end stop positions of this first strip and the following opaque one are 0, so they're both reduced to nothing and the whole space is occupied by the last transparent strip. The end stop positions of both strips then animate from 0 to the whole percentage $pr allocated for one repetition of our radial gradient, but with different timing functions. The end stop position of the first opaque strip animates slowly at first and faster towards the end (ease-in), while the end stop position of the opaque strip animates faster at first and slower towards the end (ease-out). This makes the opaque strip in the middle grow from nothing at first as its end stop position increases faster than that of the first transparent strip (which determines the start stop position of the opaque strip), then shrink back to nothing as its end stop position ends up being equal to $pr, just like the end stop position of the first transparent strip. The whole cycle then repeats itself.
Double ripple size animation (live demo, only works in Firefox 53+).

The post 1 HTML Element + 5 CSS Properties = Magic! appeared first on CSS-Tricks.

Theming With Variables: Globals and Locals

Cliff Pyles contributed to this post.

Setting CSS variables to theme a design system can be tricky: if they are too scoped, the system will lose consistency. If they are too global, you lose granularity.

Maybe we can fix both issues. I’d like to try to boil design system variables down to two types: Global and Component variables. Global variables will give us consistency across components. Component variables will give us granularity and isolation. Let me show you how to do it by taking a fairly simple component as an example.

Heads up, I’ll be using CSS variables for this article but the concept applies to preprocessor variables as well.

Global-scoped variables

System-wide variables are general concepts defined to keep consistency across your components.

Starting with an .alert component as an example, let’s say we want to keep consistency for all of our spaces on margins and paddings. We can first define global spacers:

:root { --spacer-sm: .5rem; --spacer-md: 1rem; --spacer-lg: 2rem;
}

And then use on our components:

/* Defines the btn component */
.btn { padding: var(--spacer-sm) var(--spacer-md);
} /* Defines the alert component */
.alert { padding: var(--spacer-sm) var(--spacer-md);
}

The main benefits of this approach are:

  • It generates a single source of truth for spacers, and a single point for the author using our system to customize it.
  • It achieves consistency since every component follows the same spacing.
  • It produces a common point of reference for designers and developers to work from. As long as the designers follow the same spacing restrictions, the translation to code is seamless.

But it also presents a few problems:

  • The system loses modularity by generating a dependency tree. Since components depend on global variables, they are no longer isolated.
  • It doesn’t allow authors to customize a single component without overwriting the CSS. For example, to change the padding of the alert without generating a system wide shift, they’d have to overwrite the alert component:
.alert { padding-left: 1rem; padding-right: 1rem;
}

Chris Coyier explains the idea of theming with global variables using custom elements in this article.

Component-scoped variables

As Robin Rendle explain in his article, component variables are scoped to each module. If we generate the alert with these variables, we’d get:

.alert { --alert-color: #222; color: var(--alert-color); border-color: var(--alert-color);
}

The main advantages are:

  • It creates a modular system with isolated components.
  • Authors get granular control over components without overwriting them. They’d just redefine the value of the variable.

There is no way to keep consistency across components or to make a system wide change following this method.

Let’s see how we can get the best of both worlds!

The two-tier theming system

The solution is a two-layer theming system where global variables always inform component variables. Each one of those layers follow a set of very specific rules.

First tier: Global variables

The main reason to have global variables is to maintain consistency, and they adhere to these rules:

  • They are prefixed with the word global and follow the formula --global--concept--modifier--state--PropertyCamelCase
    • a concept is something like a spacer or main-title
    • a state is something like hover, or expanded
    • a modifier is something like sm, or lg
    • and a PropertyCamelCase is something like BackgroundColor or FontSize
  • They are concepts, never tied to an element or component
    • this is wrong: --global-h1-font-size
    • this is right: --global--main-title--FontSize

For example, a global variable setup would look like:

:root { /* --global--concept--size */ --global--spacer--sm: .5rem; --global--spacer--md: 1rem; --global--spacer--lg: 2rem; /* --global--concept--PropertyCamelCase */ --global--main-title--FontSize: 2rem; --global--secondary-title--FontSize: 1.8rem; --global--body--FontSize: 1rem; /* --global--state--PropertyCamelCase */ --global--hover--BackgroundColor: #ccc;
}

Second tier: Component variables

The second layer is scoped to theme-able component properties and follow these rules:

  • Assuming we are writing BEM, they follow this formula: --block__element--modifier--state--PropertyCamelCase
    • The block__element--modifier the selector name is something like alert__actions or alert--primary
    • a state is something like hover or active
    • and if you are not writing BEM class names the same principles apply, just replace the block__element--modifier with your classname
  • The value of component scoped variables is always defined by a global variable
  • A component variable always has a default value as a fallback in case the component doesn’t have the dependency on the global variables

For example:

.alert { /* Component scoped variables are always defined by global variables */ --alert--Padding: var(--global--spacer--md); --alert--primary--BackgroundColor: var(--global--primary-color); --alert__title--FontSize: var(--global--secondary-title--FontSize); /* --block--PropertyCamelCase */ padding: var(--alert--Padding, 1rem); /* Sets the fallback to 1rem. */
} /* --block--state--PropertyCamelCase */
.alert--primary { background-color: var(--alert--primary--BackgroundColor, #ccc);
} /* --block__element--PropertyCamelCase */
.alert__title { font-size: var(--alert__title--FontSize, 1.8rem);
}

You’ll notice that we are defining locally-scoped variables with global variables. This is key for the system to work since it allows authors to theme the system as a whole. For example, if they want to change the primary color across all components they just need to redefine --global--primary-color.

On the other hand each component variable has a default value so a component can stand on its own, it doesn’t depend on anything and authors can use it in isolation.

This setup allows for consistency across components, it generates a common language between designers and developers since we can set the same global variables in Sketch as bumpers for designers, and it gives granular control to authors.

Why does this system work?

In an ideal world, we as creators of a design system, expect “authors” or users of our system to implement it without modifications, but of course, the world is not ideal and that never happens.

If we allow authors to easily theme the system without having to overwrite CSS, we’ll not only make their lives easier but also reduce the risk of breaking modules. At the end of the day, a maintainable system is a good system.

The two-tier theming system generates modular and isolated components where authors have the possibility to customize them at a global and at a component level. For example:

:root { /* Changes the secondary title size across the system */ --global--secondary-title--FontSize: 2rem;
} .alert { /* Changes the padding on the alert only */ --alert--Padding: 3rem;
}

What values should became variables?

CSS variables open windows to the code. The more we allow authors in, the more vulnerable the system is to implementation issues.

To keep consistency, set global variables for everything except layout values; you wouldn’t want authors to break the layout. And as a general rule, I’d recommend allowing access to components for everything you are willing to give support.

For the next version of PatternFly, an open source design system I work on, we’ll allow customization for almost everything that’s not layout related: colors, spacer, typography treatment, shadows, etc.

Putting everything together

To show this concept in action I’ve created a CodePen project:

Global variables are nestled in _global-variables.scss. They are the base to keep consistency across the system and will allow the author to make global changes.

There are two components: alert and button. They are isolated and modular entities with scoped variables that allow authors to fine tune components.

Remember that authors will use our system as a dependency in their project. By letting them modify the look and feel of the system through CSS variables, we are creating a solid code base that’s easier to maintain for the creators of the system and better to implement, modify, and upgrade to authors using the system.

For example, if an author wants to:

  • change the primary color to pink across the system;
  • change the danger color to orange just on the buttons;
  • and change the padding left to 2.3rem only on the alert…

…then this is how it’s done:

:root { // Changes the primary color on both the alert and the button --global--primary--Color: hotpink;
} .button { // Changes the danger color on the button only without affecting the alert --button--danger--BackgroundColor: orange; --button--danger--hover--BorderColor: darkgoldenrod;
} .alert { // Changes the padding left on the alert only without affecting the button --alert--PaddingLeft: 2.3rem;
}

The design system code base is intact and it’s just a better dependency to have.

I am aware that this is just one way to do it and I am sure there are other ways to successfully set up variables on a system. Please let me know what you think on the comments or send me a tweet. I’d love to hear about what you are doing and learn from it.

The post Theming With Variables: Globals and Locals appeared first on CSS-Tricks.

What Houdini Means for Animating Transforms

I’ve been playing with CSS transforms for over five years and one thing that has always bugged me was that I couldn’t animate the components of a transform chain individually. This article is going to explain the problem, the old workaround, the new magic Houdini solution and, finally, will offer you a feast of eye candy through better looking examples than those used to illustrate concepts.

The Problem

In order to better understand the issue at hand, let’s consider the example of a box we move horizontally across the screen. This means one div as far as the HTML goes:

<div class="box"></div>

The CSS is also pretty straightforward. We give this box dimensions, a background and position it in the middle horizontally with a margin.

$d: 4em; .box { margin: .25*$d auto; width: $d; height: $d; background: #f90;
}

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

Next, with the help of a translation along the x axis, we move it by half a viewport (50vw) to the left (in the negative direction of the x axis, the positive one being towards the right):

transform: translate(-50vw);

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

Now the left half of the box is outside the screen. Decreasing the absolute amount of translation by half its edge length puts it fully within the viewport while decreasing it by anything more, let’s say a full edge length (which is $d or 100%—remember that % values in translate() functions are relative to the dimensions of the element being translated), makes it not even touch the left edge of the viewport anymore.

transform: translate(calc(-1*(50vw - 100%)));

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

This is going to be our initial animation position.

We then create a set of @keyframes to move the box to the symmetrical position with respect to the initial one with no translation and reference them when setting the animation:

$t: 1.5s; .box { /* same styles as before */ animation: move $t ease-in-out infinite alternate;
} @keyframes move { to { transform: translate(calc(50vw - 100%)); }
}

This all works as expected, giving us a box that moves from left to right and back:

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

But this is a pretty boring animation, so let’s make it more interesting. Let’s say we want the box to be scaled down to a factor of .1 when it’s in the middle and have its normal size at the two ends. We could add one more keyframe:

50% { transform: scale(.1); }

The box now also scales (demo), but, since we’ve added an extra keyframe, the timing function is not applied for the whole animation anymore—just for the portions in between keyframes. This makes our translation slow in the middle (at 50%) as we now also have a keyframe there. So we need to tweak the timing function, both in the animation value and in the @keyframes. In our case, since we want to have an ease-in-out overall, we can split it into one ease-in and one ease-out.

.box { animation: move $t ease-in infinite alternate;
} @keyframes move { 50% { transform: scale(.1); animation-timing-function: ease-out; } to { transform: translate(calc(50vw - 100%)); }
}

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

Now all works fine, but what if we wanted different timing functions for the translation and scaling? The timing functions we’ve set mean the animation is slower at the beginning, faster in the middle and then slower again at the end. What if we wanted this to apply just to the translation, but not to the scale? What if we wanted the scaling to happen fast at the beginning, when it goes from 1 towards .1, slow in the middle when it’s around .1 and then fast again at the end when it goes back to 1?

SVG illustration. Shows the timeline, highlighting the 0%, 50% and 100% keyframes. At 0%, we want the translation to start slowly, but the scaling to start fast. At 50%, we want the translation to be at its fastest, while the scaling would be at its slowest. At 100%, the translation ends slowly, while the scaling ends fast.
The animation timeline (live).

Well, it’s just not possible to set different timing functions for different transform functions in the same chain. We cannot make the translation slow and the scaling fast at the beginning or the other way around in the middle. At least, not while what we animate is the transform property and they’re part of the same transform chain.

The Old Workaround

There are of course ways of going around this issue. Traditionally, the solution has been to split the transform (and consequently, the animation) over multiple elements. This gives us the following structure:

<div class="wrap"> <div class="box"></div>
</div>

We move the width property on the wrapper. Since div elements are block elements by default, this will also determine the width of its .box child without us having to set it explicitly. We keep the height on the .box however, as the height of a child (the .box in this case) also determines the height of its parent (the wrapper in this case).

We also move up the margin, transform and animation properties. In addition to this, we switch back to an ease-in-out timing function for this animation. We also modify the move set of @keyframes to what it was initially, so that we get rid of the scale().

.wrap { margin: .25*$d calc(50% - #{.5*$d}); width: $d; transform: translate(calc(-1*(50vw - 100%))); animation: move $t ease-in-out infinite alternate;
} @keyframes move { to { transform: translate(calc(50vw - 100%)); }
}

We create another set of @keyframes which we use for the actual .box element. This is an alternating animation of half the duration of the one producing the oscillatory motion.

.box { height: $d; background: #f90; animation: size .5*$t ease-out infinite alternate;
} @keyframes size { to { transform: scale(.1); } }

We now have the result we wanted:

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

This is a solid workaround that doesn’t add too much extra code, not to mention the fact that, in this particular case, we don’t really need two elements, we could do with just one and one of its pseudo-elements. But if our transform chain gets longer, we have no choice but to add extra elements. And, in 2018, we can do better than that!

The Houdini Solution

Some of you may already know that CSS variables are not animatable (and I guess anyone who didn’t just found out). If we try to use them in an animation, they just flip from one value to the other when half the time in between has elapsed.

Consider the initial example of the oscillating box (no scaling involved). Let’s say we try to animate it using a custom property --x:

.box { /* same styles as before */ transform: translate(var(--x, calc(-1*(50vw - #{$d})))); animation: move $t ease-in-out infinite alternate
} @keyframes move { to { --x: calc(50vw - #{$d}) } }

Sadly, this just results in a flip at 50%, the official reason being that browsers cannot know the type of the custom property (which doesn’t make sense to me, but I guess that doesn’t really matter).

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

But we can forget about all of this because now Houdini has entered the picture and we can register such custom properties so that we explicitly give them a type (the syntax).

For more info on this, check out the talk and slides by Serg Hospodarets.

CSS.registerProperty({ name: '--x', syntax: '<length>', initialValue: 0
});

We’ve set the initialValue to 0, because we have to set it to something and that something has to be a computationally independent value—that is, it cannot depend on anything we can set or change in the CSS and, given the initial and final translation values depend on the box dimensions, which we set in the CSS, calc(-1*(50vw - 100%)) is not valid here. It doesn’t even work to set --x to calc(-1*(50vw - 100%)), we need to use calc(-1*(50vw - #{$d})) instead.

$d: 4em;
$t: 1.5s; .box { margin: .25*$d auto; width: $d; height: $d; --x: calc(-1*(50vw - #{$d})); transform: translate(var(--x)); background: #f90; animation: move $t ease-in-out infinite alternate;
} @keyframes move { to { --x: calc(50vw - #{$d}); } }
Animated gif. Shows a square box oscillating horizontally from left to right and back. The motion is slow at the left and right ends and faster in the middle.
The simple oscillating box we get using the new method (live demo, needs Houdini support).

For now, this only works in Blink browsers behind the Experimental Web Platform features flag. This can be enabled from chrome://flags (or, if you’re using Opera, opera://flags):

Screenshot showing the Experimental Web Platform features flag being enabled in Chrome.
The Experimental Web Platform features flag enabled in Chrome.

In all other browsers, we still see the flip at 50%.

Applying this to our oscillating and scaling demo means we introduce two custom properties we register and animate—one is the translation amount along the x axis (--x) and the other one is the uniform scaling factor (--f).

CSS.registerProperty({ /* same as before */ }); CSS.registerProperty({ name: '--f', syntax: '<number>', initialValue: 1
});

The relevant CSS is as follows:

.box { --x: calc(-1*(50vw - #{$d})); transform: translate(var(--x)) scale(var(--f)); animation: move $t ease-in-out infinite alternate, size .5*$t ease-out infinite alternate;
} @keyframes move { to { --x: calc(50vw - #{$d}); } } @keyframes size { to { --f: .1 } }
Animated gif. Shows the same oscillating box from before now also scaling down to 10% when it's right in the middle. The scaling is fast at the beginning and the end and slow in the middle.
The oscillating and scaling with the new method (live demo, needs Houdini support).

Better Looking Stuff

A simple oscillating and scaling square isn’t the most exciting thing though, so let’s see nicer demos!

Screenshots of the two demos we dissect here. Left: a rotating wavy rainbow grid of cubes. Right: bouncing square.
More interesting examples. Left: rotating wavy grid of cubes. Right: bouncing square.

The 3D version

Going from 2D to 3D, the square becomes a cube and, since just one cube isn’t interesting enough, let’s have a whole grid of them!

We consider the body to be our scene. In this scene, we have a 3D assembly of cubes (.a3d). These cubes are distributed on a grid of nr rows and nc columns:

- var nr = 13, nc = 13;
- var n = nr*nc; .a3d while n-- .cube - var n6hedron= 6; // cube always has 6 faces while n6hedron-- .cube__face

The first thing we do is a few basic styles to create a scene with a perspective, put the whole assembly in the middle and put each cube face into its place. We won’t be going into the details of how to build a CSS cube because I’ve already dedicated a very detailed article to this topic, so if you need a recap, check that one out!

The result so far can be seen below – all the cubes stacked up in the middle of the scene:

Screenshot. Shows all cubes (as wireframes) in the same position in the middle of the scene, making it look as if there's only one wireframe.
All the cubes stacked up in the middle (live demo).

For all these cubes, their front half is in front of the plane of the screen and their back half is behind the plane of the screen. In the plane of the screen, we have a square section of our cube. This square is identical to the ones representing the cube faces.

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

Next, we set the column (--i) and row (--j) indices on groups of cubes. Initially, we set both these indices to 0 for all cubes.

.cube { --i: 0; --j: 0;
}

Since we have a number of cubes equal to the number of columns (nc) on every row, we then set the row index to 1 for all cubes after the first nc ones. Then, for all cubes after the first 2*nc ones, we set the row index to 2. And so on, until we’ve covered all nr rows:

style | .cube:nth-child(n + #{1*nc + 1}) { --j: 1 } | .cube:nth-child(n + #{2*nc + 1}) { --j: 2 } //- and so on | .cube:nth-child(n + #{(nr - 1)*nc + 1}) { --j: #{nr - 1} }

We can compact this in a loop:

style - for(var i = 1; i < nr; i++) { | .cube:nth-child(n + #{i*nc + 1}) { --j: #{i} } -}

Afterwards, we move on to setting the column indices. For the columns, we always need to skip a number of cubes equal to nc - 1 before we encounter another cube with the same index. So, for every cube, the nc-th cube after it is going to have the same index and we’re going to have nc such groups of cubes.

(We only need to set the index to the last nc - 1, because all cubes have the column index set to 0 initially, so we can skip the first group containing the cubes for which the column index is 0 – no need to set --i again to the same value it already has.)

style | .cube:nth-child(#{nc}n + 2) { --i: 1 } | .cube:nth-child(#{nc}n + 3) { --i: 2 } //- and so on | .cube:nth-child(#{nc}n + #{nc}) { --i: #{nc - 1} }

This, too, can be compacted in a loop:

style - for(var i = 1; i < nc; i++) { | .cube:nth-child(#{nc}n + #{i + 1}) { --i: #{i} } -}

Now that we have all the row and column indices set, we can distribute these cubes on a 2D grid in the plane of the screen using a 2D translate() transform, according to the illustration below, where each cube is represented by its square section in the plane of the screen and the distances are measured in between transform-origin points (which are, by default, at 50% 50% 0, so dead in the middle of the square cube sections from the plane of the screen):

SVG illustration. Shows how to create a basic grid of square, vertical cube sections with nc columns and nr rows starting from the position of the top left item. The top left item is on the first column (of index <code>0</code>) and on the first row (of index <code>0</code>). All items on the second column (of index <code>1</code>) are offset horizontally by and edge length. All items on the third column (of index <code>2</code>) are offset horizontally by two edge lengths. In general, all items on the column of index <code>i</code> are offset horizontally by <code>i</code> edge lengths. All items on the last column (of index <code>nc - 1</code>) are offset horizontally by <code>nc - 1</code> edge lengths. All items on the second row (of index <code>1</code>) are offset vertically by and edge length. All items on the third row (of index <code>2</code>) are offset vertically by two edge lengths. In general, all items on the row of index <code>j</code> are offset vertically by <code>j</code> edge lengths. All items on the last row (of index <code>nr - 1</code>) are offset vertically by <code>nr - 1</code> edge lengths.”/><figcaption>How to create a basic grid starting from the position of the top left item (live).</figcaption></figure>
<pre rel=/* $l is the cube edge length */ .cube { /* same as before */ --x: calc(var(--i)*#{$l}); --y: calc(var(--j)*#{$l}); transform: translate(var(--x), var(--y)); }

This gives us a grid, but it’s not in the middle of the screen.

Screenshot. Shows the grid with nc columns and nr rows, with cubes repersented as wireframes. The midpoint of the top left cube of the rectangular grid is dead in the middle of the screen..
The grid, having the midpoint of the top left cube in the middle of the screen (live demo).

Right now, it’s the central point of the top left cube that’s in the middle of the screen, as highlighted in the demo above. What we want is for the grid to be in the middle, meaning that we need to shift all cubes left and up (in the negative direction of both the x and y axes) by the horizontal and vertical differences between half the grid dimensions (calc(.5*var(--nc)*#{$l}) and calc(.5*var(--nr)*#{$l}), respectively) and the distances between the top left corner of the grid and the midpoint of the top left cube’s vertical cross-section in the plane of the screen (these distances are each half the cube edge, or .5*$l).

The difference between the position of the grid midpoint and the top left item midpoint (live).

Subtracting these differences from the previous amounts, our code becomes:

.cube { /* same as before */ --x: calc(var(--i)*#{$l} - (.5*var(--nc)*#{$l} - .5*#{$l})); --y: calc(var(--j)*#{$l} - (.5*var(--nr)*#{$l} - .5*#{$l}));
}

Or even better:

.cube { /* same as before */ --x: calc((var(--i) - .5*(var(--nc) - 1))*#{$l})); --y: calc((var(--j) - .5*(var(--nr) - 1))*#{$l}));
}

We also need to make sure we set the --nc and --nr custom properties:

- var nr = 13, nc = 13;
- var n = nr*nc; //- same as before
.a3d(style=`--nc: ${nc}; --nr: ${nr}`) //- same as before

This gives us a grid that’s in the middle of the viewport:

Screenshot. Shows a grid of cube wireframes right in the middle.
The grid is now in the middle (live).

We’ve also made the cube edge length $l smaller so that the grid fits within the viewport.

Alternatively, we can go for a CSS variable --l instead so that we can control the edge length depending on the number of columns and rows. The first step here is setting the maximum of the two to a --nmax variable:

- var nr = 13, nc = 13;
- var n = nr*nc; //- same as before
.a3d(style=`--nc: ${nc}; --nr: ${nr}; --max: ${Math.max(nc, nr)}`) //- same as before

Then, we set the edge length (--l) to something like 80% (completely arbitrary value) of the minimum viewport dimension over this maximum (--max):

.cube { /* same as before */ --l: calc(80vmin/var(--max));
}

Finally, we update the cube and face transforms, the face dimensions and margin to use --l instead of $l:

.cube { /* same as before */ --l: calc(80vmin/var(--max)); --x: calc((var(--i) - .5*(var(--nc) - 1))*var(--l)); --y: calc((var(--j) - .5*(var(--nr) - 1))*var(--l)); &__face { /* same as before */ margin: calc(-.5*var(--l)); width: var(--l); height: var(--l); transform: rotate3d(var(--i), var(--j), 0, calc(var(--m, 1)*#{$ba4gon})) translatez(calc(.5*var(--l))); }
}

Now we have a nice responsive grid!

Animated gif. Shows the previously created grid scaling with the viewport.
The grid is now in the middle and responsive such that it always fits within the viewport (live).

But it’s an ugly one, so let’s turn it into a pretty rainbow by making the color of each cube depend on its column index (--i):

.cube { /* same as before */ color: hsl(calc(var(--i)*360/var(--nc)), 65%, 65%);
}
Screenshot. The assembly wireframe has now a rainbow look, with every column of cubes having a different hue.
The rainbow grid (live demo).

We’ve also made the scene background dark so that we have better contrast with the now lighter cube edges.

To spice things up even further, we add a row rotation around the y axis depending on the row index (--j):

.cube { /* same as before */ transform: rotateY(calc(var(--j)*90deg/var(--nr))) translate(var(--x), var(--y));
}
Screenshot. The assembly wireframe now appears twisted, with every row being rotated at a different angle, increasing from top to bottom.
The twisted grid (live demo).

We’ve also decreased the cube edge length --l and increased the perspective value in order to allow this twisted grid to fit in.

Now comes the fun part! For every cube, we animate its position back and forth along the z axis by half the grid width (we make the translate() a translate3d() and use an additional custom property --z that goes between calc(.5*var(--nc)*var(--l)) and calc(-.5*var(--nc)*var(--l))) and its size (via a uniform scale3d() of factor --f that goes between 1 and .1). This is pretty much the same thing we did for the square in our original example, except the motion now happens along the z axis, not along the x axis and the scaling happens in 3D, not just in 2D.

$t: 1s; .cube { /* same as before */ --z: calc(var(--m)*.5*var(--nc)*var(--l)); transform: rotateY(calc(var(--j)*90deg/var(--nr))) translate3d(var(--x), var(--y), var(--z)) scale3d(var(--f), var(--f), var(--f)); animation: a $t ease-in-out infinite alternate; animation-name: move, zoom; animation-duration: $t, .5*$t;
} @keyframes move { to { --m: -1 } } @keyframes zoom { to { --f: .1 } }

This doesn’t do anything until we register the multiplier --m and the scaling factor --f to give them a type and an initial value:

CSS.registerProperty({ name: '--m', syntax: '<number>', initialValue: 1
}); CSS.registerProperty({ name: '--f', syntax: '<number>', initialValue: 1
});
Animated gif. Every cube now moves back and forth along its own z axis (post row rotation), between half a grid width behind its xOy plane and half a grid width in front of its xOy plane. Each cube also scales along all three axes, going from its initial size to a tenth of it along each axis and then back to its initial size.
The animated grid (live demo, needs Houdini support).

At this point, all cubes animate at the same time. To make things more interesting, we add a delay that depends on both the column and row index:

animation-delay: calc((var(--i) + var(--j))*#{-2*$t}/(var(--nc) + var(--nr)));
Screenshot
The waving grid effect (live).

The final touch is to add a rotation on the 3D assembly:

.a3d { top: 50%; left: 50%; animation: ry 8s linear infinite;
} @keyframes ry { to { transform: rotateY(1turn); } }

We also make the faces opaque by giving them a black background and we have the final result:

Animated gif. Now the cube faces are opaque (we've given them a black background) whole assembly rotates around its y axis, making the animation more interesting.
The final result (live demo, needs Houdini support).

The performance for this is pretty bad, as it can be seen from the GIF recording above, but it’s still interesting to see how far we can push things.

Hopping Square

I came across the original in a comment to another article and, as soon as I saw the code, I thought it was the perfect candidate for a makeover using some Houdini magic!

Let’s start by understanding what is happening in the original code.

In the HTML, we have nine divs.


<div class="frame"> <div class="center"> <div class="down"> <div class="up"> <div class="squeeze"> <div class="rotate-in"> <div class="rotate-out"> <div class="square"></div> </div> </div> </div> </div> </div> <div class="shadow"></div> </div>
</div>

Now, this animation is a lot more complex than anything I could ever come up with, but, even so, nine elements seems to be overkill. So let’s take a look at the CSS, see what they’re each used for and see how much we can simplify the code in preparation for switching to the Houdini-powered solution.

Let’s start with the animated elements. The .down and .up elements each have an animation related to moving the square vertically:

/* original */
.down { position: relative; animation: down $duration ease-in infinite both; .up { animation: up $duration ease-in-out infinite both; /* the rest */ }
} @keyframes down { 0% { transform: translateY(-100px); } 20%, 100% { transform: translateY(0); }
} @keyframes up { 0%, 75% { transform: translateY(0); } 100% { transform: translateY(-100px); }
}

With @keyframes and animations on both elements having the same duration, we can pull off a make-one-out-of-two trick.

In the case of the first set of @keyframes, all the action (going from -100px to 0) happens in the [0%, 20%] interval, while, in the case of the second one, all the action (going from 0 to -100px) happens in the [75%, 100%] interval. These two intervals don’t intersect. Because of this and because both animations have the same duration we can add up the translation values at each keyframe.

  • at 0%, we have -100px from the first set of @keyframes and 0 from the second, which gives us -100px
  • at 20%, we have 0 from the first set of @keyframes and 0 from the second (as we have 0 for any frame from 0% to 75%), which gives us 0
  • at 75%, we have 0 from the first set of @keyframes (as we have 0 for any frame from 20% to 100%) and 0 from the second, which gives us 0
  • at 100%, we have 0 from the first set of @keyframes and -100px from the second, which gives us -100px

Our new code is as follows. We have removed the animation-fill-mode from the shorthand as it doesn’t do anything in this case since our animation loops infinitely, has a non-zero duration and no delay:

/* new */
.jump { position: relative; transform: translateY(-100px); animation: jump $duration ease-in infinite; /* the rest */
} @keyframes jump { 20%, 75% { transform: translateY(0); animation-timing-function: ease-in-out; }
}

Note that we have different timing functions for the two animations, so we need to switch between them in the @keyframes. We still have the same effect, but we got rid of one element and one set of @keyframes.

Next, we do the same thing for the .rotate-in and .rotate-out elements and their @keyframes:

/* original */
.rotate-in { animation: rotate-in $duration ease-out infinite both; .rotate-out { animation: rotate-out $duration ease-in infinite both; }
} @keyframes rotate-in { 0% { transform: rotate(-135deg); } 20%, 100% { transform: rotate(0deg); }
} @keyframes rotate-out { 0%, 80% { transform: rotate(0); } 100% { transform: rotate(135deg); }
}

In a similar manner to the previous case, we add up the rotation values for each keyframe.

  • at 0%, we have -135deg from the first set of @keyframes and 0deg from the second, which gives us -135deg
  • at 20%, we have 0deg from the first set of @keyframes and 0deg from the second (as we have 0deg for any frame from 0% to 80%), which gives us 0deg
  • at 80%, we have 0deg from the first set of @keyframes (as we have 0deg for any frame from 20% to 100%) and 0deg from the second, which gives us 0deg
  • at 100%, we have 0deg from the first set of @keyframes and 135deg from the second, which gives us 135deg

This means we can compact things to:

/* new */
.rotate { transform: rotate(-135deg); animation: rotate $duration ease-out infinite;
} @keyframes rotate { 20%, 80% { transform: rotate(0deg); animation-timing-function: ease-in; } 100% { transform: rotate(135deg); }
}

We only have one element with a scaling transform that distorts our white square:

/* original */
.squeeze { transform-origin: 50% 100%; animation: squeeze $duration $easing infinite both;
} @keyframes squeeze { 0%, 4% { transform: scale(1); } 45% { transform: scale(1.8, 0.4); } 100% { transform: scale(1); }
}

There’s not really much we can do here in terms of compacting the code, save for removing the animation-fill-mode and grouping the 100% keyframe with the 0% and 4% ones:

/* new */
.squeeze { transform-origin: 50% 100%; animation: squeeze $duration $easing infinite;
} @keyframes squeeze { 0%, 4%, 100% { transform: scale(1); } 45% { transform: scale(1.8, .4); }
}

The innermost element (.square) is only used to display the white box and has no transform set on it.

 /* original */
.square { width: 100px; height: 100px; background: #fff;
}

This means we can get rid of it if we move its styles to its parent element.

/* new */
$d: 6.25em; .rotate { width: $d; height: $d; transform: rotate(-135deg); background: #fff; animation: rotate $duration ease-out infinite;
}

We got rid of three elements so far and our structure has become:

.frame .center .jump .squeeze .rotate .shadow

The outermost element (.frame) serves as a scene or container. This is the big blue square.

/* original */
.frame { position: absolute; top: 50%; left: 50%; width: 400px; height: 400px; margin-top: -200px; margin-left: -200px; border-radius: 2px; box-shadow: 1px 2px 10px 0px rgba(0,0,0,0.2); overflow: hidden; background: #3498db; color: #fff; font-family: 'Open Sans', Helvetica, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
}

There’s no text in this demo, so we can get rid of the text-related properties. We can also get rid of the color property since, not only do we not have text anywhere in this demo, but we’re also not using this for any borders, shadows, backgrounds (via currentColor) and so on.

We can also avoid taking this containing element out of the document flow by using a flexbox layout on the body. This also eliminates the offsets and the margin properties.

/* new */
$s: 4*$d; body { display: flex; align-items: center; justify-content: center; height: 100vh;
} .frame { overflow: hidden; position: relative; width: $s; height: $s; border-radius: 2px; box-shadow: 1px 2px 10px rgba(#000, .2); background: #3498db;
}

We’ve also tied the dimensions of this element to those of the hopping square.

The .center element is only used for positioning its direct children (.jump and .shadow), so we can take it out altogether and use the offsets on it directly on these children.

We use absolute positioning on all .frame descendants. This makes the .jump and .squeeze elements 0x0 boxes, so we tweak the transform-origin for the squeezing transform (100% of 0 is always 0, but the value we want is half the square edge length .5*$d). We also set a margin of minus half the square edge length (-.5*$d) on the .rotate element (to compensate for the translate(-50%, -50%) we had on the removed .center element).

/* new */
.frame * { position: absolute, } .jump { top: $top; left: $left; /* same as before */
} .squeeze { transform-origin: 50% .5*$d; /* same as before */
} .rotate { margin: -.5*$d; /* same as before */
}

Finally, let’s take a look at the .shadow element.

/* original */
.shadow { position: absolute; z-index: -1; bottom: -2px; left: -4px; right: -4px; height: 2px; border-radius: 50%; background: rgba(0,0,0,0.2); box-shadow: 0 0 0px 8px rgba(0,0,0,0.2); animation: shadow $duration ease-in-out infinite both;
} @keyframes shadow { 0%, 100% { transform: scaleX(.5); } 45%, 50% { transform: scaleX(1.8); }
}

We’re of course removing the position since we’ve already set that for all descendants of the .frame. We can also get rid of the z-index if we move the .shadow before the .jump element in the DOM.

Next, we have the offsets. The midpoint of the shadow is offset by $left (just like the .jump element) horizontally and by $top plus half a square edge length (.5*$d) vertically.

We see a height that’s set to 2px. Along the other axis, the width computes to the square’s edge length ($d) plus 4px from the left and 4px from the right. That’s plus 8px in total. But one thing we notice is that the box-shadow with an 8px spread and no blur is just an extension of the background. So we can just increase the dimensions of the our element by twice the spread along both axes and get rid of the box-shadow altogether.

Just like in the case of the other elements, we also get rid of the animation-fill-mode from the animation shorthand:

/* new */
.shadow { margin: .5*($d - $sh-h) (-.5*$sh-w); width: $sh-w; height: $sh-h; border-radius: 50%; transform: scaleX(.5); background: rgba(#000, .2); animation: shadow $duration ease-in-out infinite;
} @keyframes shadow { 45%, 50% { transform: scaleX(1.8); }
}

We’ve now reduced the code in the original demo by about 40% while still getting the same result.

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

Our next step is to merge the .jump, .squeeze and rotate components into one, so that we go from three elements to a single one. Just as a reminder, the relevant styles we have at this point are:

.jump { transform: translateY(-100px); animation: jump $duration ease-in infinite;
} .squeeze { transform-origin: 50% .5*$d; animation: squeeze $duration $easing infinite;
} .rotate { transform: rotate(-135deg); animation: rotate $duration ease-out infinite;
} @keyframes jump { 20%, 75% { transform: translateY(0); animation-timing-function: ease-in-out; }
} @keyframes squeeze { 0%, 4%, 100% { transform: scale(1); } 45% { transform: scale(1.8, .4); }
} @keyframes rotate { 20%, 80% { transform: rotate(0deg); animation-timing-function: ease-in; } 100% { transform: rotate(135deg); }
}

The only problem here is that the scaling transform has a transform-origin that’s different from the default 50% 50%. Fortunately, we can go around that.

Any transform with a transform-origin different from the default is equivalent to a transform chain with default transform-origin that first translates the element such that its default transform-origin point (the 50% 50% point in the case of HTML elements and the 0 0 point of the viewBox in the case of SVG elements) goes to the desired transform-origin, applies the actual transformation we want (scaling, rotation, shearing, a combination of these… doesn’t matter) and then applies the reverse translation (the values for each of the axes of coordinates are multiplied by -1).

Any transform with a transform with a transform-origin different from the default is equivalent to a chain that translates the point of the default transform-origin to that of the custom one, performs the desired transform and then reverses the initial translation (live demo).

Putting this into code means that if we have any transform with transform-origin: $x1 $y1, the following two are equivalent:

/* transform on HTML element with transform-origin != default */ transform-origin: $x1 $y1;
transform: var(--transform); /* can be rotation, scaling, shearing */ /* equivalent transform chain on HTML element with default transform-origin */
transform: translate(calc(#{$x1} - 50%), calc(#{$y1} - 50%)) var(--transform) translate(calc(50% - #{$x1}), calc(50% - $y1);

In our particular case, we have the default transform-origin value along the x axis, so we only need to perform a translation along the y axis. By also replacing the hardcoded values with variables, we get the following transform chain:

transform: translateY(var(--y)) translateY(.5*$d) scale(var(--fx), var(--fy)) translateY(-.5*$d) rotate(var(--az));

We can compact this a bit by joining the first two translations:

transform: translateY(calc(var(--y) + #{.5*$d})) scale(var(--fx), var(--fy)) translateY(-.5*$d) rotate(var(--az));

We also put the three animations on the three elements into just one:

animation: jump $duration ease-in infinite, squeeze $duration $easing infinite, rotate $duration ease-out infinite;

And we modify the @keyframes so that we now animate the newly-introduced custom properties --y, --fx, --fy and --az:

@keyframes jump { 20%, 75% { --y: 0; animation-timing-function: ease-in-out; }
} @keyframes squeeze { 0%, 4%, 100% { --fx: 1; --fy: 1 } 45% { --fx: 1.8; --fy: .4 }
} @keyframes rotate { 20%, 80% { --az: 0deg; animation-timing-function: ease-in; } 100% { --az: 135deg }
}

However, this won’t work unless we register these CSS variables we have introduced and want to animate:

CSS.registerProperty({ 'name': '--y', 'syntax': '<length>', 'initialValue': '-100px'
}); CSS.registerProperty({ 'name': '--fx', 'syntax': '<number>', 'initialValue': 1
}); /* exactly the same for --fy */ CSS.registerProperty({ 'name': '--az', 'syntax': '<angle>', 'initialValue': '-135deg'
});

We now have a working demo of the method animating CSS variables. But given that our structure is now one wrapper with two children, we can reduce it further to one element and two pseudo-elements, thus getting the final version which can be seen below. It’s worth noting that this only works in Blink browsers with the Experimental Web Platform features flag enabled.

Animated gif. The square rotates in the air, falls down and gets squished against the ground, then bounces back up and the cycle repeats.
The final result (live, needs Houdini support)

The post What Houdini Means for Animating Transforms appeared first on CSS-Tricks.

Everything you need to know about CSS Variables

This is by far the biggest deep dive I’ve seen on CSS Variables posted to the web and it’s merely Chapter One of complete e-book on the topic.

Truth is, I’m still on the thick of reading through this myself, but had to stop somewhere in the middle to write this up and share it because it’s just that gosh-darned useful. For example, the post goes into great detail on three specific use cases for CSS Variables and breaks the code down to give a better understanding of what it does, in true tutorial fashion.

Scoping, inheritance, resolving multiple declarations, little gotchas—there’s plenty in here for beginners and advanced developers alike.

OK, back to reading. 🤓

Direct Link to Article — Permalink


Everything you need to know about CSS Variables is a post from CSS-Tricks