Settings

Settings other than “Default” will be saved in local storage to persist across pages, reloads, and sessions.

Purple Cosmos

It’s time for more CSS-art! 😊

The following figure contains only a single div, nothing else! Everything visual is done purely with CSS, no Javascript is involved at all!

Purple Cosmos, by Fiona Johanna Weber, CSS on HTML.

(In case you are using a Chromium based browser and experience bad performance, my recommendation is to switch to Firefox or something based on it, because Chromium tends to perform pretty badly with anything involving animations. Also because the dominance of webkit as a rendering engine is really bad for the open web and because Google, Microsoft, Apple, Brave… are far less trustworthy companies than even Mozilla already isn’t! Also: Don’t use reader-mode on this page, since it strips out all the CSS, which will basically mean that none of the graphics on this page will work at all!)

So, how does it work?

The core idea here is to work with radial gradients and change their configuration over time. The latter is a bit more straightforward, so let’s start with that: We create a custom property of a numeric type as well as an animation that changes its value from -1 to 1:

css Skip Download
@property --purple-cosmos-step {
	syntax: '<number>';
	initial-value: 0;
	inherits: true;
}
@keyframes purple-cosmos-animation-progress {
	0%{--purple-cosmos-step:-1.0;}
	100%{--purple-cosmos-step:1.0;}
}

After that we can set up the element that will actually hold the visuals. Our starting point looks like this:

css Skip Download
.purple-cosmos {
	width: 100%;
	height: unset;
	aspect-ratio: 1;
	animation: purple-cosmos-animation-progress 5000s linear infinite;
	background: repeat
		/* TBD */
		linear-gradient(black, color-mix(in oklab, black 30%, purple 70%));
}

This does the following:

The “/* TBD */” is where all the magick happens: It will be a long list of radial-gradients whose parameters depend on the value of --purple_cosmos-step.

The benefit of doing it like that is that this makes it a pure CSS thing that can be used for arbitrary background without requiring any changes to the HTML-structure of a document. This is such a central requirement, that it is in fact why I started this project, I was (and am!) considering to use this to create a more interessting background for this website!

Gradients

CSS has a function radial-gradient that allows us to create an image of a gradient that changes color as distance from a center-point increases; this distance can either be purely linear, in which case we get a circular appearance, or dependent on direction to create an ellipse, though I’m only using the simple, former variant here. There is also a lot that can be done with regards to using multiple colors and different color-spots, but I will also use only the simlest variant of that by starting with one color at the center and moving to full transparency. The only thing beyond that is that I will set the coordinate of the center explicitly:

css Skip Download
radial-gradient(
	circle at /* circle or ellipse */
	30% /* x-coordinate */
	60% /* y-coordinate */,
	green /* color at center */
	0 /* start the transition at the center */,
	transparent /* transition to transparency */
	5em /* distance from center where full transparency is reached */
)

Placed on a black background, the result looks as follows:

A single, simple radial gradient.

I will use a lot of these gradients, but the only places in which they differ from this will be the x- and y-coordinates, the color, and the outer distance from center. Everything else will essentially be just a copy-pasted version of the above.

Sines, lot’s of Sines…

Leaving color out for the moment (I’ll get back to it), the coordinates and the outer radius are all numeric values. If I just wanted a single image, I could just assign randomly generated values to them at be done. And in a way it is in fact the first thing I do. But after that I want a smooth animation that behaves somewhat randomly appearing and returns to it’s starting position at the end of its run. Put differently, I need an interesting (non constant), continous, and idealy differentiable function that has the same value when evaluated at −1 and at 1 (as those are the values between which --purple_cosmos-step is moving.

One function that meets those requirements is the sine function if we scale it down a bit on the x-axis (divide by τ). Not only that, it has additional further benefits:

So in order to animate the x-coordinate, we can replace 30% in the upper example with calc(30% + 15% * sin(var(--step) * 1turn)) to get some movement in the x-direction if we set the custom property step to repeatedly animate from −1 to 1:

Simple movement.

Naturally the same works with the y-axis by replacing 60% with calc(60% + 15% * sin(var(--step) * 1turn)):

Diagonal movement.

At this point we have effectively rotated the movement, but it is still linear, which is kinda boring. This is where the periodicity comes in: If we shift where we evaluate sine, we can get circular or elliptic movements instead. So let’s replace the y-coordinate again, this time with calc(60% + 25% * sin(var(--step) * 1turn + 0.3turn)):

Circular Movement.

Another thing we can then do is to scale the evaluations differently. Here we have to use integer-factors to ensure that the animation ends where it starts, but other than that the main-thing to keep in mind is that the bigger the factor, the longer the time-period should be. (The next example uses 20 seconds instead of 5s!)

Movement with different period lengths on different axes.

This looks already more eratic, but all we have effectively done at this point is disentangled the movement on the x-axis from the movement on the y-axis; the movement on each axis by itself is still fully described by a very simple sine. To change this we will now add further sines to the mix, that will all differ in phase, magnitued and “speed”. Effectively this means that we will stack more of these movements on top of each other. To see have effective this is, consider the follow graphic, though note that it only uses ellipses with equal x- and y-velocity1:

Four points rotationg at constant angular velocity on circles, where each (except for the last one colored in cyan) is the center point for the next circle.
Even just four ellipses rotating at different speeds with different phases and different radii result in eratic movement of the cyan point, especially when projected onto a linear axis.

Once we combine all of these techniques, we can get CSS that will look roughly like this:

css Skip Download
@property --step {
	syntax: '<number>';
	initial-value: 0;
	inherits: true;
}
@keyframes --frames{
	0%{--step:-1.0;}
	100%{--step:1.0;}
}
#expl6[role="img"] {
	animation: --frames 20s linear infinite;
	background:
		radial-gradient(circle at
			calc(
				30%
				+ 15% * sin(3 * var(--step) * 1turn)
				+ 25% * sin(2 * var(--step) * 1turn + 0.25turn)
				+  3% * sin(1 * var(--step) * 1turn + 0.66turn)
			)
			calc(
				60%
				+ 25% * sin(7 * var(--step) * 1turn + 0.3turn)
				+ 15% * sin(5 * var(--step) * 1turn + 0.4turn)
				+ 10% * sin(3 * var(--step) * 1turn + 0.7turn)
			),
			green 0, transparent
			calc(
				5em
				+ 3em * sin(6 * var(--step) * 1turn + 0.1turn)
				+ 2em * sin(4 * var(--step) * 1turn + 0.2turn)
				+ 1em  * sin(10 * var(--step) * 1turn + 0.3turn)
			)),
		linear-gradient(black);
	
	width: 100%;
	aspect-ratio: 1;
	height: unset;
}

Which will produce the following result:

Erratic movement: Done!

A Note on Sizes and radial-gradient

So far I have used “em” as the unit for most descriptions; in practice I am using percent instead, which have the benefit to be independent of the size of the background. The reason to stick with “em” so far is however how relative size work with radial-gradient: Radii given in percent are not relative to the width or height of the background (this ambiguity already hints at part of a possible reason), but relative to the distance to, by default, the furthest corner. The problem with this approach was (until now) that this means that changing the position of the center of the gradient would change the radii and thus the apparent size. This was undesirable behavior when demonstrating movement of the gradient, but doesn’t hurt if we are changing the size of the gradient anyways.

An alternative approach to using percent that doesn’t depend on the font-size would be to use container-relative sizes; the downside of that approach is that it would require to place the element that has the background in another HTML-element that has the style property container-type: size; set; after that it would be possible to use container size units.

Since my goal here was not to impose any restrictions on the HTML, I decided to go with relative sizes in percent, instead of container queries; especially since it does not create a meaningful optical difference.

Colors

At this point we get back to the one thing we still need to replace, the color of the gradient. The idea here is the same though: Instead of hard-coding a keyword-color, we will compute it based on values derived from --step with a lot of sines.

Thankfully modern CSS has useful color spaces and the one we will want to use here is known as “oklch”, based on the Oklab color space. It does much better what HSV only pretends to do in separating hue, lightness and chroma (think saturation) into three (as close to as reasonably possible) independent dimensions where an equal difference in value should ideally correspond to an equal perceived difference in that dimension.

(“lch”, based on the older CIELAB color space is older and tries the same thing, but has some rather suboptimal behavior, especially with regards to deep blue; oklch fixes that part and is otherwise very similar.)

Lastly, there is a fourth dimension: The transparency at the center: While we always want to have the gradient end at full transparency, we don’t need it to start at full opacity. As such we add that as a parameter too. Combined with what we learned before, we can now write a function that computes a mildly, but erratically shifting color in oklch:

css Skip Download
#expl8[role="img"] {
	animation: --frames 20s linear infinite;
	background:
		oklch(
			calc(0.5657 /* lightness */ 
				+ 0.0828 * sin(181turn * var(--step) + 0.7463turn)
				+ 0.0404 * sin(4turn * var(--step) + 0.4524turn)
				+ 0.0210 * sin(489turn * var(--step) + 0.9993turn)
				+ 0.0131 * sin(108turn * var(--step) + 0.2732turn)
			)
			calc(0.2146 /* chroma */
				+ 0.0072 * sin(440turn * var(--step) + 0.3932turn)
				+ 0.0430 * sin(318turn * var(--step) + 0.6632turn)
				+ 0.0140 * sin(371turn * var(--step) + 0.7723turn)
				+ 0.0135 * sin(459turn * var(--step) + 0.6165turn)
			)
			calc(323.29 /* hue */
				+ 17.7444 * sin(76turn * var(--step) + 0.3170turn)
				+ 5.1156 * sin(386turn * var(--step) + 0.9415turn)
				+ 5.1822 * sin(81turn * var(--step) + 0.1472turn)
				+ 0.7097 * sin(152turn * var(--step) + 0.9751turn)
			)
			/
			calc(0.4361 /* transparency */
				+ 0.0359 * sin(240turn * var(--step) + 0.1182turn)
				+ 0.1328 * sin(331turn * var(--step) + 0.1266turn)
				+ 0.0928 * sin(275turn * var(--step) + 0.0878turn)
				+ 0.0601 * sin(337turn * var(--step) + 0.7590turn)
			)
		);
}

Which renders as follows.

Slow, but continous color-shift.

Now obviously most of the numbers above are chosen randomly, but the central color “oklch(0.5657 0.2146 323.29)” is in fact a deliberate pick: Not only do I like purple (as a look at the site-wide css should already reveal), I will go a step further and claim that it is in a surprisingly objective way a great color, especially when dealing with the sRGB color space: If we check the aforementioned color in an Oklch color picker, we can see that it is in all directions reasonably far within gamut, meaning that we can add some variation to all components in all directions without immediately creating colors that cannot be represented in sRGB. The extend to which this holds is relatively unique to purple: Blue can’t really produce bright shades at high chroma, green struggles with dark shades and while there is a little bit of play with red, it still has less range available than purple. Now, to be fair, a device that can display more than just sRGB, notably one that supports the full P3 colorspace, would suffer less from this limitation and could create richer presentations of other color, because the limitation is not that the human eye wouldn’t be able to perceive them! The benefit of this is even that if we produce a value that is slightly out of gamut in sRGB, users with P3 displays can still benefit from them, whereas everyone else has to deal with barely (if at all) perceivable clipping.

Now if we combine all of this, we get the code for our first sphere:

css Skip Download
#expl9[role="img"] {
	animation: --frames 10000s linear infinite;
	background:
		background: repeat
			radial-gradient(circle at calc(42.75103326345193%
					+ 6.333548068030857% * sin(674turn * var(--step) + 0.5630448790170792turn)
					+ 3.960065286385456% * sin(222turn * var(--step) + 0.47866417748107615turn)
					+ 2.0922877115817484% * sin(498turn * var(--step) + 0.6571123754380062turn)
					+ 1.7384324058699923% * sin(984turn * var(--step) + 0.14542563462662705turn)
					+ 1.019097925315298% * sin(549turn * var(--step) + 0.2542504422150368turn)
					+ 0.7209053957275368% * sin(624turn * var(--step) + 0.5898098131530617turn)
					+ 0.7734945004464631% * sin(97turn * var(--step) + 0.4455330011253156turn)
					+ 0.27922837208596035% * sin(876turn * var(--step) + 0.5694131899481079turn)
					+ 0.32903341767828903% * sin(760turn * var(--step) + 0.3346798010764367turn)
					+ 0.428681034191624% * sin(520turn * var(--step) + 0.9101202826065431turn)
				)
				calc(32.157853730836734%
					+ 3.310468076523022% * sin(403turn * var(--step) + 0.02315577433395999turn)
					+ 2.7585626351264976% * sin(960turn * var(--step) + 0.5769415685975573turn)
					+ 1.884243456010488% * sin(312turn * var(--step) + 0.5679470983547803turn)
					+ 0.8907105483708453% * sin(6turn * var(--step) + 0.6069441024560049turn)
					+ 0.7952422391477516% * sin(250turn * var(--step) + 0.012594707523515658turn)
					+ 0.4075733170367528% * sin(18turn * var(--step) + 0.2038346355536671turn)
					+ 0.6958983606626911% * sin(550turn * var(--step) + 0.7002451576081569turn)
					+ 0.5110493692166269% * sin(721turn * var(--step) + 0.6843961628983911turn)
					+ 0.1980997294846363% * sin(479turn * var(--step) + 0.41827237463884903turn)
					+ 0.5598230903676014% * sin(742turn * var(--step) + 0.6183785145101294turn)
				),
				oklch(calc(0.5657
					+ 0.0389313799716634 * sin(598turn * var(--step) + 0.20381351930532576turn)
					+ 0.03460688431358107 * sin(63turn * var(--step) + 0.9822407543851197turn)
					+ 0.019933295088939623 * sin(352turn * var(--step) + 0.7891545325678033turn)
					+ 0.005215180949081588 * sin(839turn * var(--step) + 0.027660494666813973turn)
				)
					calc(0.2146
					+ 0.06510968576658022 * sin(567turn * var(--step) + 0.00302190467239849turn)
					+ 0.02052180355656937 * sin(766turn * var(--step) + 0.647934277327469turn)
					+ 0.02319284306224317 * sin(743turn * var(--step) + 0.5755984953259234turn)
					+ 0.02419714855518543 * sin(230turn * var(--step) + 0.9640649856262274turn)
				)
					calc(323.29
					+ 14.32668277558097 * sin(768turn * var(--step) + 0.33365352411667903turn)
					+ 0.932983634943062 * sin(147turn * var(--step) + 0.4109163964785737turn)
					+ 1.7543110586023818 * sin(982turn * var(--step) + 0.45926963423778855turn)
					+ 2.559476921966262 * sin(760turn * var(--step) + 0.7050787538982074turn)
				)
					/ calc(0.328865390907233
					+ 0.2244241264293079 * sin(935turn * var(--step) + 0.3842721599418537turn)
					+ 0.14014883214744003 * sin(796turn * var(--step) + 0.7612435180995604turn)
					+ 0.09001255588879759 * sin(843turn * var(--step) + 0.9523694276558075turn)
					+ 0.053825624032027335 * sin(348turn * var(--step) + 0.452193633178387turn)
				))
				0, transparent calc(11.795627877147785%
					+ 5.841000023711475% * sin(826turn * var(--step) + 0.671934255528448turn)
					+ 2.856462605732548% * sin(254turn * var(--step) + 0.9336268847396764turn)
					+ 1.6131085987264502% * sin(11turn * var(--step) + 0.8152344247262183turn)
					+ 1.0640937123447114% * sin(598turn * var(--step) + 0.34518374496910365turn)
				)),
			linear-gradient(black, color-mix(in oklab, black 30%, purple 70%));
}
Finally there; now we just need to add more of these gradients.

The nice thing here is that we can repeat this with a different numbers and eventually get the visuals from above.

Scripting it all

Of course sampling all those numbers manually is very much not supper practical. This is where some python comes in. For the most part all that code does is sampling random values and place them as seen above.

Firstly we set up a bunch of functions to create appropriate random values:

🐍 Skip Download
def sample_pos(unit: str, low: float = -10, high: float = 110) -> str:    pos = random.uniform(low, high)    return f"{pos}{unit}"def sample_size(α: float = 9.0, β: float = 0.5) -> float:    return random.gammavariate(α, β)def sample_speed(max: int = 1000) -> int:    return random.randint(1, max)def sample_angle() -> str:    return f"{random.uniform(0, 1)}turn"

I used uniform distributions for the positions, the speeds, and the phases (angles) because I want the spheres to be clustered all over the space, and it seemed reasonable for the other values as well. For the sizes I instead opted for a gamma distribution since I wanted size of a wide range of magnitudes but still have most of them be somewhat comparable.

The next step was sampling animations:

🐍 Skip Download
def sample_animation(    pos: str | float,    sample_Δ: Callable[[], float],    unit: str = "",    speed: int = 1000,    degree: int = 4,) -> str:    ret = f"calc({pos}"    for i in range(degree):        ret += f" + {sample_Δ() / (i+1)}{unit} * sin({sample_speed(speed)}turn * var(--purple-cosmos-step) + {sample_angle()})\n"    return ret + ")"

All this does is sampling a formula that computes an animated value using the previous functions; In order to be able to use this function the random values are not passed directly, but rather by passing a function that samples them; finally the degree parameter allows specifying how many independent values we want to add.

Once we have that, we can now easily define the function that defines one gradient:

🐍 Skip Download
def make_circle() -> str:    x = sample_pos("%")    Δx = lambda: sample_size()    y = sample_pos("%")    Δy = lambda: sample_size()    size = f"{2.5*sample_size()}%"    Δsize = lambda: 1 * sample_size()    Δhue = lambda: random.uniform(0, 20)    Δchroma = lambda: random.uniform(0, 0.1)    Δlightness = lambda: random.uniform(0, 0.1)    transparency = random.uniform(0, 1)    Δtransparency = lambda: random.uniform(0, 0.3)    return (        f"radial-gradient(circle at {sample_animation(x, Δx, "%", degree=10)}"        + f"{sample_animation(y, Δy, "%", degree=10)},"        + f"\toklch({sample_animation(0.5657, Δlightness)}"        + f"{sample_animation(0.2146, Δchroma)}"        + f"{sample_animation(323.29, Δhue)}"        + f"/ {sample_animation(transparency, Δtransparency)})"        + f"0, transparent {sample_animation(size, Δsize, "%")}),"    )

Since movement on the plane is the most obvious characteristic, I chose the degree for them pretty higher, at ten sines added on top of each other; most other values are a little bit less sensitive end I opted for merely four components.

The only thing that remained after that was essentially writing down some boilerplate code, and accessibility options2 resulting in this script:

🐍 purple_cosmos.py Skip Download
#! /usr/bin/pythonfrom typing import Iterable, Callableimport randomimport textwrapdef sample_pos(unit: str, low: float = -10, high: float = 110) -> str:    pos = random.uniform(low, high)    return f"{pos}{unit}"def sample_size(α: float = 9.0, β: float = 0.5) -> float:    return random.gammavariate(α, β)def sample_speed(max: int = 1000) -> int:    return random.randint(1, max)def sample_angle() -> str:    return f"{random.uniform(0, 1)}turn"def sample_animation(    pos: str | float,    sample_Δ: Callable[[], float],    unit: str = "",    speed: int = 1000,    degree: int = 4,) -> str:    ret = f"calc({pos}"    for i in range(degree):        ret += f" + {sample_Δ() / (i+1)}{unit} * sin({sample_speed(speed)}turn * var(--purple-cosmos-step) + {sample_angle()})\n"    return ret + ")"def make_circle() -> str:    x = sample_pos("%")    Δx = lambda: sample_size()    y = sample_pos("%")    Δy = lambda: sample_size()    size = f"{2.5*sample_size()}%"    Δsize = lambda: 1 * sample_size()    Δhue = lambda: random.uniform(0, 20)    Δchroma = lambda: random.uniform(0, 0.1)    Δlightness = lambda: random.uniform(0, 0.1)    transparency = random.uniform(0, 1)    Δtransparency = lambda: random.uniform(0, 0.3)    return (        f"radial-gradient(circle at {sample_animation(x, Δx, "%", degree=10)}"        + f"{sample_animation(y, Δy, "%", degree=10)},"        + f"\toklch({sample_animation(0.5657, Δlightness)}"        + f"{sample_animation(0.2146, Δchroma)}"        + f"{sample_animation(323.29, Δhue)}"        + f"/ {sample_animation(transparency, Δtransparency)})"        + f"0, transparent {sample_animation(size, Δsize, "%")}),"    )def backdrop() -> str:    return "linear-gradient(black, color-mix(in oklab, black 30%, purple 70%));"def make_css(circles: Iterable[str]) -> str:    return (        """\        @property --purple-cosmos-step {            syntax: '<number>';            initial-value: 0;            inherits: true;        }        @keyframes purple-cosmos-animation-progress {            0%{--purple-cosmos-step:-1.0;}            100%{--purple-cosmos-step:1.0;}        }        .purple-cosmos {            width: 100%;            height: unset;            aspect-ratio: 1;            animation-name: purple-cosmos-animation-progress;            animation-duration: calc(10000s);            animation-iteration-count: infinite;            animation-direction: repeat;            animation-timing-function: linear;            background: repeat                """        + "\n".join(circles)        + "\n "        + backdrop()        + """        }"""    )def make_body(circles: Iterable[str]) -> str:    return (        "<!DOCTYPE html>\n<html><head><title>Purple Cosmos</title><style>:root, body {padding: 0;margin: 0;overflow: hidden;} .purple-cosmos-container{margin: 0;width: 100%;height: 100vh;}\n"        + make_css(circles)        + '</style></head><body><div class="purple-cosmos" role="img" aria-label="a large number of purple spheres moving around in a dark field, chaning their size and colors"></div></body></html>'    )def main() -> None:    import argparse    parser = argparse.ArgumentParser(prog="gen circles")    parser.add_argument("-c", "--count", type=int, default=200)    parser.add_argument("-s", "--standalone", action="store_true")    parser.add_argument("file", type=argparse.FileType("w"))    parsed = parser.parse_args()    circles = (make_circle() for _ in range(parsed.count))    parsed.file.write(make_body(circles) if parsed.standalone else make_css(circles))if __name__ == "__main__":    main()

  1. Using different speeds would create more complex shapes and angles, for which it would be much more complicated to describe them in the SVG and harder to follow; the only practical difference is in any case that the movemnts on the x- and y-axis would be fully independent of each others, which they are not here.↩︎

  2. Specifically the div, since it is effectively used as an image, requires the attribute role="img" and an alt text via an aria-label attribute.↩︎