Easily manage projects with monday.com

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

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:

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—so let's see how I did it!

### 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 halves, 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`

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

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.

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.

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`

.

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`

).

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

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.

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:

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.

Adding `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:

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:

A quick fix here would be to add a `radial-gradient()`

cover on each of the two halves. This cover is transparent white (`rgba(#fff, 0)`

) for most of the unmasked part of the two halves and goes to solid white (`rgba(#fff, 1)`

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

#### 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. While `conic-gradient()`

is supported natively without the flag starting with Chrome 69+, Houdini isn't, so no real gain so far in this particular case.

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,
inherits: true
})
```

Note that `inherits`

is now required, even though it was optional in earlier versions of the spec.

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!

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

I don’t know why but the 3D trick isn’t working for in chrome android (afaik it’s the latest stable version)

Mozilla/5.0 (Linux; Android 7.0; E5823 Build/32.3.A.2.33) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.158 Mobile Safari/537.36

Very nice! This could be made into a nice loading animation component.

this is awesome great job