The core of Tailwind are its utilities. This means you have two choices:
The default choice
The unorthodox choice
The default choice
The default choice is to follow Tailwind’s recommended layer order: place components first, and Tailwind utilities last.
So, if you’re building components, you need to manually wrap your components with a @layer directive. Then, overwrite your component styles with Tailwind, putting Tailwind as the “most important layer”.
/* Write your components */
@layer components {
.component {
/* Your CSS here */
}
}
But, being the bad boy I am, I don’t take the default approach as the “best” one. Over a year of (major) experimentation with Tailwind and vanilla CSS, I’ve come across what I believe is a better solution.
The Unorthodox Choice
Before we go on, I have to tell you that I’m writing a course called Unorthodox Tailwind — this shows you everything I know about using Tailwind and CSS in synergistic ways, leveraging the strengths of each.
Shameless plug aside, let’s dive into the Unorthodox Choice now.
In this case, the Unorthodox Choice is to write your styles in an unnamed layer — or any layer after utilities, really — so that your CSS naturally overwrites Tailwind utilities.
/* Named layer option */
/* Use whatever layer name you come up with. I simply used css here because it made most sense for explaining things */
@layer theme, base, components, utilities, css;
@layer css {
.component { /* ... */ }
}
I have many reasons why I do this:
I don’t like to add unnecessary CSS layers because it makes code harder to write — more keystrokes, having to remember the specific layer I used it in, etc.
I’m pretty skilled with ITCSS, selector specificity, and all the good-old-stuff you’d expect from a seasoned front-end developer, so writing CSS in a single layer doesn’t scare me at all.
I can do complex stuff that are hard or impossible to do in Tailwind (like theming and animations) in CSS.
Your mileage may vary, of course.
Now, if you have followed my reasoning so far, you would have noticed that I use Tailwind very differently:
Tailwind utilities are not the “most important” layer.
My unnamed CSS layer is the most important one.
I do this so I can:
Build prototypes with Tailwind (quickly, easily, especially with the tools I’ve created).
Shift these properties to CSS when they get more complex — so I don’t have to read messy utility-littered HTML that makes my heart sink. Not because utility HTML is bad, but because it takes lots of brain processing power to figure out what’s happening.
Finally, here’s the nice thing about Tailwind being in a utility layer: I can always !important a utility to give it strength.
Whoa, hold on, wait a minute! Isn’t this wrong, you might ask?
Nope. The !important keyword has traditionally been used to override classes. In this case, we’re leveraging on the !important feature in CSS Layers to say the Tailwind utility is more important than any CSS in the unnamed layer.
This is perfectly valid and is a built-in feature for CSS Layers.
Besides, the !important is so explicit (and used so little) that it makes sense for one-off quick-and-dirty adjustments (without creating a brand new selector for it).
Tailwind utilities are more powerful than they seem
Tailwind utilities are not a 1:1 map between a class and a CSS property. Built-in Tailwind utilities mostly look like this so it can give people a wrong impression.
Tailwind utilities are more like convenient Sass mixins, which means we can build effective tools for layouts, theming, typography, and more, through them.
Blob, Blob, Blob. You hate them. You love them. Personally, as a design illiterate, I like to overuse them… a lot. And when you repeat the same process over and over again, it’s only a question of how much you can optimize it, or in this case, what’s the easiest way to create blobs in CSS? Turns out, as always, there are many approaches.
To know if our following blobs are worth using, we’ll need them to pass three tests:
They can be with just a single element (and preferably without pseudos).
They can be easily designed (ideally through an online tool).
We can use gradient backgrounds, borders, shadows, and other CSS effects on them.
Without further ado, let’s Blob, Blob, Blob right in.
Just generate them online
I know it’s disenchanting to click on an article about making blobs in CSS just for me to say you can generate them outside CSS. Still, it’s probably the most common way to create blobs on the web, so to be thorough, these are some online tools I’ve used before to create SVG blobs.
Haikei. Probably the one I have used the most since, besides blobs, it can also generate lots of SVG backgrounds.
Blobmaker. A dedicated tool for making blobs. It’s apparently part of Haikei now, so you can use both.
Lastly, almost all graphic programs let you hand-draw blobs and export them as SVGs.
For example, this is one I generated just now. Keep it around, as it will come in handy later.
While counterintuitive, we can use the border-radius property to create blobs. This technique isn’t new by any means; it was first described by Nils Binder in 2018, but it is still fairly unknown. Even for those who use it, the inner workings are not entirely clear.
To start, you may know the border-radius is a shorthand to each individual corner’s radius, going from the top left corner clockwise. For example, we can set each corner’s border-radius to get a bubbly square shape:
<div class="blob"></div>
.blob {
border-radius: 25% 50% 75% 100%;
}
However, what border-radius does — and also why it’s called “radius” — is to shape each corner following a circle of the given radius. For example, if we set the top left corner to 25%, it will follow a circle with a radius 25% the size of the shape.
.blob {
border-top-left-radius: 25%;
}
What’s less known is that each corner property is still a shortcut towards its horizontal and vertical radii. Normally, you set both radii to the same value, getting a circle, but you can set them individually to create an ellipse. For example, the following sets the horizontal radius to 25% of the element’s width and the vertical to 50% of its height:
.blob {
border-top-left-radius: 25% 50%;
}
We can now shape each corner like an ellipse, and it is the combination of all four ellipses that creates the illusion of a blob! Just take into consideration that to use the horizontal and vertical radii syntax through the border-radius property, we’ll need to separate the horizontal from the vertical radii using a forward slash (/).
The syntax isn’t too intuitive, so designing a blob from scratch will likely be a headache. Luckily, Nils Binder made a tool exactly for that!
Blobbing blobs together
This hack is awesome. We aren’t supposed to use border-radius like that, but we still do. Admittedly, we are limited to boring blobs. Due to the nature of border-radius, no matter how hard we try, we will only get convex shapes.
Just going off border-radius, we can try to minimize it a little by sticking more than one blob together:
However, I don’t want to spend too much time on this technique since it is too impractical to be worth it. To name a few drawbacks:
We are using more than one element or, at the very least, an extra pseudo-element. Ideally, we want to keep it to one element.
We don’t have a tool to prototype our blobby amalgamations, so making one is a process of trial and error.
We can’t use borders, gradients, or box shadows since they would reveal the element’s outlines.
Multiple backgrounds and SVG filters
This one is an improvement in the Gooey Effect, described here by Lucas Bebber, although I don’t know who first came up with it. In the original effect, several elements can be morphed together like drops of liquid sticking to and flowing out of each other:
It works by first blurring shapes nearby, creating some connected shadows. Then we crank up the contrast, forcing the blur out and smoothly connecting them in the process. Take, for example, this demo by Chris Coyer (It’s from 2014, so more than 10 years ago!):
If you look at the code, you’ll notice Chris uses the filter property along the blur() and contrast() functions, which I’ve also seen in other blob demos. To be specific, it applies blur() on each individual circle and then contrast() on the parent element. So, if we have the following HTML:
However, there is a good reason why those demos stick to white shapes and black backgrounds (or vice versa) since things get unpredictable once colors aren’t contrast-y enough. See it for yourself in the following demo by changing the color. Just be wary: shades get ugly.
To solve this, we will use an SVG filter instead. I don’t want to get too technical on SVG (if you want to, read Luca’s post!). In a nutshell, we can apply blurring and contrast filters using SVGs, but now, we can also pick which color channel we apply the contrast to, unlike normal contrast(), which modifies all colors.
Since we want to leave color channels (R, G and B) untouched, we will only crank the contrast up for the alpha channel. That translates to the next SVG filter, which can be embedded in the HTML:
To apply it, we will use again filter, but this time we’ll set it to url("#blob"), so that it pulls the SVG from the HTML.
.blob {
filter: url("#blob");
}
And now we can even use it with gradient backgrounds!
That being said, this approach comes with two small, but important, changes to common CSS filters:
The filter is applied to the parent element, not the individual shapes.
The parent element must be transparent (which is a huge advantage). To change the background color, we can instead change the body or other ancestors’ background, and it will work with no issues.
What’s left is to place the .subblob elements together such that they make a blobby enough shape, then apply the SVG filters to morph them:
Making it one element
This works well, but it has a similar issue to the blob we made by morphing several border-radius instances: too many elements for a simple blob. Luckily, we can take advantage of the background property to create multiple shapes and morph them together using SVG filters, all in a single element. Since we are keeping it to one element, we will go back to just one empty .blob div:
<div class="blob"></div>
To recap, the background shorthand can set all background properties and also set multiple backgrounds at once. Of all the properties, we only care about the background-image, background-position and background-size.
First, we will use background-image along with radial-gradient() to create a circle inside the element:
farthest-side: Confines the shape to the element’s box farthest from its center. This way, it is kept as a circle.
var(--blob-color) 100%: Fills the background shape from 0 to 100% with the same color, so it ends up as a solid color.
#0000: After the shape is done, it makes a full stop to transparency, so the color ends.
The next part is moving and resizing the circle using the background-position and background-size properties. Luckily, both can be set on background after the gradient, separated from each other by a forward slash (/).
The first pair of percentages sets the shape’s horizontal and vertical position (taking as a reference the top-left corner), while the second pair sets the shape’s width and height (taking as a reference the element’s size).
As I mentioned, we can stack up different backgrounds together, which means we can create as many circles/ellipses as we want! For example, we can create three ellipses on the same element:
What’s even better is that SVG filters don’t care whether shapes are made of elements or backgrounds, so we can also morph them together using the last url(#blob) filter!
While this method may be a little too much for blobs, it unlocks squishing, stretching, dividing, and merging blobs in seamless animations.
Again, all these tricks are awesome, but not enough for what we want! We accomplished reducing the blob to a single element, but we still can’t use gradients, borders, or shadows on them, and also, they are tedious to design and model. Then, that brings us to the ultimate blob approach…
Using the shape() function
Fortunately, there is a new way to make blobs that just dropped to CSS: the shape() function!
First off, the CSS shape() function is used alongside the clip-path property to cut elements into any shape we want. More specifically, it uses a verbal version of SVG’s path syntax. The syntax has lots of commands for lots of types of lines, but when blobbing with shape(), we’ll define curves using the curve command:
.blob {
clip-path: shape(
from X0 Y0,
curve to X1 Y1 with Xc1 Yc1,
curve to X2 Y2 with Xc21 Yc21 / Xc22 Yc22
/* ... */
);
}
Let’s break down each parameter:
X0 Y0 defines the starting point of the shape.
curve starts the curve where X1 Y1 is the next point of the shape, while Xc1 Yc1 defines a control point used in Bézier curves.
The next parameter is similar, but we used Xc21 Yc21 / Xc22 Yc22 instead to define two control points on the Bézier curve.
I honestly don’t understand Bézier curves and control points completely, but luckily, we don’t need them to use shape() and blobs! Again, shape() uses a verbal version of SVG’s path syntax, so it can draw any shape an SVG can, which means that we can translate the SVG blobs we generated earlier… and CSS-ify them. To do so, we’ll grab the d attribute (which defines the path) from our SVG and paste it into Temani’s SVG to shape() generator.
This is the exact code the tool generated for me:
.blob {
aspect-ratio: 0.925; /* Generated too! */
clip-path: shape(
from 91.52% 26.2%,
curve to 93.52% 78.28% with 101.76% 42.67%/103.09% 63.87%,
curve to 44.11% 99.97% with 83.95% 92.76%/63.47% 100.58%,
curve to 1.45% 78.42% with 24.74% 99.42%/6.42% 90.43%,
curve to 14.06% 35.46% with -3.45% 66.41%/4.93% 51.38%,
curve to 47.59% 0.33% with 23.18% 19.54%/33.13% 2.8%,
curve to 91.52% 26.2% with 62.14% -2.14%/81.28% 9.66%
);
}
As you might have guessed, it returns our beautiful blob:
Let’s check if it passes our requirements:
Yes, they can be made of a single element.
Yes, they can also be created in a generator and then translated into CSS.
Yes, we can use gradient backgrounds, but due to the nature of clip-path(), borders and shadows get cut out.
Two out of three? Maybe two and a half of three? That’s a big improvement over the other approaches, even if it’s not perfect.
Conclusion
So, alas, we failed to find what I believe is the perfect CSS approach to blobs. I am, however, amazed how something so trivial designing blobs can teach us about so many tricks and new CSS features, many of which I didn’t know myself.
KelpUI is new library that Chris Ferdinandi is developing, designed to leverage newer CSS features and Web Components. I’ve enjoyed following Chris as he’s published an ongoing series of articles detailing his thought process behind the library, getting deep into his approach. You really get a clear picture of his strategy and I love it.
He outlined his principles up front in a post back in April:
I’m imagining a system that includes…
Base styles for all of the common HTML elements.
Loads of utility classes for nudging and tweaking things.
Group classes for styling more complex UI elements without a million little classes.
Easy customization with CSS variables.
Web Components to progressively add interactivity to functional HTML.
All of the Web Component HTML lives in the light DOM, so its easy to style and reason about.
I’m imagining something that can be loaded directly from a CDN, downloaded locally, or imported if you want to roll your own build.
KelpUI is still evolving, and that’s part of the beauty of looking at it now and following Chris’s blog as he openly chronicles his approach. There’s always going to be some opinionated directions in a library like this, but I love that the guiding philosophy is so clear and is being used as a yardstick to drive decisions. As I write this, Chris is openly questioning the way he optimizes the library, demonstrating the tensions between things like performance and a good developer experience.
Looks like it’ll be a good system, but even more than that, it’s a wonderful learning journey that’s worth following.
Chrome 137 shipped the if() CSS function, so it’s totally possible we’ll see other browsers implement it, though it’s tough to know exactly when. Whatever the case, if() enables us to use values conditionally, which we can already do with queries and other functions (e.g., media queries and the light-dark() function), so I’m sure you’re wondering: What exactly does if() do?
To recap, if() conditionally assigns a value to a property based on the value of a CSS variable. For example, we could assign different values to the color and background properties based on the value of --theme:
--theme: "Shamrock"
color: hsl(146 50% 3%)
background: hsl(146 50% 40%)
--theme: Anything else
color: hsl(43 74% 3%)
background: hsl(43 74% 64%)
:root {
/* Change to fall back to the ‘else’ values */
--theme: "Shamrock";
body {
color: if(style(--theme: "Shamrock"): hsl(146 50% 3%); else: hsl(43 74% 3%));
background: if(style(--theme: "Shamrock"): hsl(146 50% 40%); else: hsl(43 74% 64%));
}
}
I don’t love the syntax (too many colons, brackets, and so on), but we can format it like this (which I think is a bit clearer):
We should be able to do a crazy number of things with if(), and I hope that becomes the case eventually, but I did some testing and learned that the syntax above is the only one that works. We can’t base the condition on the value of an ordinary CSS property (instead of a custom property), HTML attribute (using attr()), or any other value. For now, at least, the condition must be based on the value of a custom property (CSS variable).
Exploring what we can do with if()
Judging from that first example, it’s clear that we can use if() for theming (and design systems overall). While we could utilize the light-dark() function for this, what if the themes aren’t strictly light and dark, or what if we want to have more than two themes or light and dark modes for each theme? Well, that’s what if() can be used for.
Pretty simple really, but there are a few easy-to-miss things. Firstly, there’s no “else condition” this time, which means that if the theme isn’t Shamrock, Saffron, or Amethyst, the default browser styles are used. Otherwise, the if() function resolves to the value of the first true statement, which is the Saffron theme in this case. Secondly, transitions work right out of the box; in the demo below, I’ve added a user interface for toggling the --theme, and for the transition, literally just transition: 300ms alongside the if() functions:
Note: if theme-swapping is user-controlled, such as selecting an option, you don’t actually need if() at all. You can just use the logic that I’ve used at the beginning of the demo (:root:has(#shamrock:checked) { /* Styles */ }). Amit Sheen has an excellent demonstration over at Smashing Magazine.
To make the code more maintainable though, we can slide the colors into CSS variables as well, then use them in the if() functions, then slide the if() functions themselves into CSS variables:
/* Setup */
:root {
/* Shamrock | Saffron | Amethyst */
--theme: "Shamrock"; /* ...I choose you! */
/* Base colors */
--shamrock: hsl(146 50% 40%);
--saffron: hsl(43 74% 64%);
--amethyst: hsl(282 47% 56%);
/* Base colors, but at 3% lightness */
--shamrock-complementary: hsl(from var(--shamrock) h s 3%);
--saffron-complementary: hsl(from var(--saffron) h s 3%);
--amethyst-complementary: hsl(from var(--amethyst) h s 3%);
--background: if(
style(--theme: "Shamrock"): var(--shamrock);
style(--theme: "Saffron"): var(--saffron);
style(--theme: "Amethyst"): var(--amethyst)
);
--color: if(
style(--theme: "Shamrock"): var(--shamrock-complementary);
style(--theme: "Saffron"): var(--saffron-complementary);
style(--theme: "Amethyst"): var(--amethyst-complementary)
);
/* Usage */
body {
/* One variable, all ifs! */
background: var(--background);
color: var(--color);
accent-color: var(--color);
/* Can’t forget this! */
transition: 300ms;
}
}
As well as using CSS variables within the if() function, we can also nest other functions. In the example below, I’ve thrown light-dark() in there, which basically inverts the colors for dark mode:
If you haven’t used container style queries before, they basically check if a container has a certain CSS variable (much like the if() function). Here’s the exact same example/demo but with container style queries instead of the if() function:
As you can see, where if() facilitates conditional values, container style queries facilitate conditional properties and values. Other than that, it really is just a different syntax.
Additional things you can do with if() (but might not realize)
Check if a CSS variable exists:
/* Hide icons if variable isn’t set */
.icon {
display: if(
style(--icon-family): inline-block;
else: none
);
}
Although I’m not keen on the syntax and how unreadable it can sometimes look (especially if it’s formatted on one line), I’m mega excited to see how if() evolves. I’d love to be able to use it with ordinary properties (e.g., color: if(style(background: white): black; style(background: black): white);) to avoid having to set CSS variables where possible.
It’d also be awesome if calc() calculations could be calculated on the fly without having to register the variable.
That being said, I’m still super happy with what if() does currently, and can’t wait to build even simpler design systems.
We’ve known it for a few weeks now, but the CSS if() function officially shipped in Chrome 137 version. It’s really fast development for a feature that the CSSWG resolved to add less than a year ago. We can typically expect this sort of thing — especially one that is unlike anything we currently have in CSS — to develop over a number of years before we can get our dirty hands on it. But here we are!
I’m not here to debate whether if() in CSS should exist, nor do I want to answer whether CSS is a programming language; Chris already did that and definitely explained how exhausting that fun little argument can be.
What I am here to do is poke at if() in these early days of support and explore what we know about it today at a pretty high level to get a feel for its syntax. We’ll poke a little harder at it in another upcoming post where we’ll look at a more heady real-world example.
Yes, it’s already here!
Conditional statements exist everywhere in CSS. From at-rules to the parsing and matching of every statement to the DOM, CSS has always had conditionals. And, as Lea Verou put it, every selector is essentially a conditional! What we haven’t had, however, is a way to style an element against multiple conditions in one line, and then have it return a result conditionally.
The if() function is a more advanced level of conditionals, where you can manipulate and have all your conditional statements assigned to a single property.
The first <if-statement> represents conditions inside either style(), media(), or supports() wrapper functions. This allows us to write multiple if statements, as many as we may desire. Yes, you read that right. As many as we want!
The final <if-statement> condition (else) is the default value when all other if statements fail.
That’s the “easy” way to read the syntax. This is what’s in the spec:
A little wordy, right? So, let’s look at an example to wrap our heads around it. Say we want to change an element’s padding depending on a given active color scheme. We would set an if() statement with a style() function inside, and that would compare a given value with something like a custom variable to output a result. All this talk sounds so complicated, so let’s jump into code:
The example above sets the padding to 2rem… if the --theme variable is set to dark. If not, it defaults to 3rem. I know, not exactly the sort of thing you might actually use the function for, but it’s merely to illustrate the basic idea.
Make the syntax clean!
One thing I noticed, though, is that things can get convoluted very very fast. Imagine you have three if() statements like this:
We’re only working with three statements and, I’ll be honest, it makes my eyes hurt with complexity. So, I’m anticipating if() style patterns to be developed soon or prettier versions to adopt a formatting style for this.
For example, if I were to break things out to be more readable, I would likely do something like this:
:root {
--height: 12.5rem;
--width: 4rem;
--weight: 2rem;
}
/* This is much cleaner, don't you think? */
.element {
height: if(
style(--height: 3rem): 14.5rem;
style(--width: 7rem): 10rem;
style(--weight: 100rem): 2rem;
else: var(--height)
);
}
Much better, right? Now, you can definitely understand what is going on at a glance. That’s just me, though. Maybe you have different ideas… and if you do, I’d love to see them in the comments.
Here’s a quick demo showing multiple conditionals in CSS for this animated ball to work. The width of the ball changes based on some custom variable values set. Gentle reminder that this is only supported in Chrome 137+ at the time I’m writing this:
The supports() and media() statements
Think of supports() the same way you would use the @supports at-rule. In fact, they work about the same, at least conceptually:
Now, take a look at the @media at-rule. You can compare and check for a bunch of stuff, but I’d like to keep it simple and check for whether or not a screen size is a certain width and apply styles based on that:
Notice how at the end of the day, the formal syntax (<media-query>) is the same as the syntax for the media() function. And instead of returning a block of code in @media, you’d have something like this in the CSS inline if():
As of the time of this writing, only the latest update of Chrome supports if()). I’m guessing other browsers will follow suit once usage and interest come in. I have no idea when that will happen. Until then, I think it’s fun to experiment with this stuff, just as others have been doing:
Experimenting with early features is how we help CSS evolve. If you’re trying things out, consider adding your feedback to the CSSWG and Chromium. The more use cases, the better, and that will certain help make future implementations better as well.
Now that we have a high-level feel for the if()syntax, we’ll poke a little harder at the function in another article where we put it up against a real-world use case. We’ll link that up when it publishes tomorrow.
In a previous article, I showed you how to refactor the Resize Observer API into something way simpler to use:
// From this
const observer = new ResizeObserver(observerFn)
function observerFn (entries) {
for (let entry of entries) {
// Do something with each entry
}
}
const element = document.querySelector('#some-element')
observer.observe(element);
// To this
const node = document.querySelector('#some-element')
const obs = resizeObserver(node, {
callback({ entry }) {
// Do something with each entry
}
})
Today, we’re going to do the same for MutationObserver and IntersectionObserver.
Refactoring Mutation Observer
MutationObserver has almost the same API as that of ResizeObserver. So we can practically copy-paste the entire chunk of code we wrote for resizeObserver to mutationObserver.
export function mutationObserver(node, options = {}) {
const observer = new MutationObserver(observerFn)
const { callback, ...opts } = options
observer.observe(node, opts)
function observerFn(entries) {
for (const entry of entries) {
// Callback pattern
if (options.callback) options.callback({ entry, entries, observer })
// Event listener pattern
else {
node.dispatchEvent(
new CustomEvent('mutate', {
detail: { entry, entries, observer },
})
)
}
}
}
}
You can now use mutationObserver with the callback pattern or event listener pattern.
const node = document.querySelector('.some-element')
// Callback pattern
const obs = mutationObserver(node, {
callback ({ entry, entries }) {
// Do what you want with each entry
}
})
// Event listener pattern
node.addEventListener('mutate', event => {
const { entry } = event.detail
// Do what you want with each entry
})
Much easier!
Disconnecting the observer
Unlike ResizeObserver who has two methods to stop observing elements, MutationObserver only has one, the disconnect method.
But, MutationObserver has a takeRecords method that lets you get unprocessed records before you disconnect. Since we should takeRecords before we disconnect, let’s use it inside disconnect.
To create a complete API, we can return this method as well.
export function mutationObserver(node, options = {}) {
// ...
return {
// ...
disconnect() {
const records = observer.takeRecords()
observer.disconnect()
if (records.length > 0) observerFn(records)
}
}
}
Now we can disconnect our mutation observer easily with disconnect.
In case you were wondering, MutationObserver’s observe method can take in 7 options. Each one of them determines what to observe, and they all default to false.
subtree: Monitors the entire subtree of nodes
childList: Monitors for addition or removal children elements. If subtree is true, this monitors all descendant elements.
attributes: Monitors for a change of attributes
attributeFilter: Array of specific attributes to monitor
attributeOldValue: Whether to record the previous attribute value if it was changed
characterData: Monitors for change in character data
characterDataOldValue: Whether to record the previous character data value
Refactoring Intersection Observer
The API for IntersectionObserver is similar to other observers. Again, you have to:
Create a new observer: with the new keyword. This observer takes in an observer function to execute.
Do something with the observed changes: This is done via the observer function that is passed into the observer.
Observe a specific element: By using the observe method.
(Optionally) unobserve the element: By using the unobserve or disconnect method (depending on which Observer you’re using).
But IntersectionObserver requires you to pass the options in Step 1 (instead of Step 3). So here’s the code to use the IntersectionObserver API.
// Step 1: Create a new observer and pass in relevant options
const options = {/*...*/}
const observer = new IntersectionObserver(observerFn, options)
// Step 2: Do something with the observed changes
function observerFn (entries) {
for (const entry of entries) {
// Do something with entry
}
}
// Step 3: Observe the element
const element = document.querySelector('#some-element')
observer.observe(element)
// Step 4 (optional): Disconnect the observer when we're done using it
observer.disconnect(element)
Since the code is similar, we can also copy-paste the code we wrote for mutationObserver into intersectionObserver. When doing so, we have to remember to pass the options into IntersectionObserver and not the observe method.
export function mutationObserver(node, options = {}) {
const { callback, ...opts } = options
const observer = new MutationObserver(observerFn, opts)
observer.observe(node)
function observerFn(entries) {
for (const entry of entries) {
// Callback pattern
if (options.callback) options.callback({ entry, entries, observer })
// Event listener pattern
else {
node.dispatchEvent(
new CustomEvent('intersect', {
detail: { entry, entries, observer },
})
)
}
}
}
}
Now we can use intersectionObserver with the same easy-to-use API:
const node = document.querySelector('.some-element')
// Callback pattern
const obs = intersectionObserver(node, {
callback ({ entry, entries }) {
// Do what you want with each entry
}
})
// Event listener pattern
node.addEventListener('intersect', event => {
const { entry } = event.detail
// Do what you want with each entry
})
Disconnecting the Intersection Observer
IntersectionObserver‘s methods are a union of both resizeObserver and mutationObserver. It has four methods:
observe: observe an element
unobserve: stops observing one element
disconnect: stops observing all elements
takeRecords: gets unprocessed records
So, we can combine the methods we’ve written in resizeObserver and mutationObserver for this one:
export function intersectionObserver(node, options = {}) {
// ...
return {
unobserve(node) {
observer.unobserve(node)
},
disconnect() {
// Take records before disconnecting.
const records = observer.takeRecords()
observer.disconnect()
if (records.length > 0) observerFn(records)
},
takeRecords() {
return observer.takeRecords()
},
}
}
Now we can stop observing with the unobserve or disconnect method.
Aside from the code we’ve written together above (and in the previous article), each observer method in Splendid Labz is capable of letting you observe and stop observing multiple elements at once (except mutationObserver because it doesn’t have a unobserve method)
const items = document.querySelectorAll('.elements')
const obs = resizeObserver(items, {
callback ({ entry, entries }) {
/* Do what you want here */
}
})
// Unobserves two items at once
const subset = [items[0], items[1]]
obs.unobserve(subset)
So it might be just a tad easier to use the functions I’ve already created for you. 😉
Shameless Plug: Splendid Labz contains a ton of useful utilities — for CSS, JavaScript, Astro, and Svelte — that I have created over the last few years.
I’ve parked them all in into Splendid Labz, so I no longer need to scour the internet for useful functions for most of my web projects. If you take a look, you might just enjoy what I’ve complied!
(I’m still making the docs at the time of writing so it can seem relatively empty. Check back every now and then!)
Learning to refactor stuff
If you love the way I explained how to refactor the observer APIs, you may find how I teach JavaScript interesting.
In my JavaScript course, you’ll learn to build 20 real life components. We’ll start off simple, add features, and refactor along the way.
Refactoring is such an important skill to learn — and in here, I make sure you got cement it into your brain.
I have had the opportunity to edit over a lot of the new color entries coming to the CSS-Tricks Almanac. We’ve already published several with more on the way, including a complete guide on color functions:
And I must admit: I didn’t know a lot about color in CSS (I still used rgb(), which apparently isn’t what cool people do anymore), so it has been a fun learning experience. One of the things I noticed while trying to keep up with all this new information was how long the glossary of color goes, especially the “color” concepts. There are “color spaces,” “color models,” “color gamuts,” and basically a “color” something for everything.
They are all somewhat related, and it can get confusing as you dig into using color in CSS, especially the new color functions that have been shipped lately, like contrast-color() and color-mix(). Hence, I wanted to make the glossary I wish I had when I was hearing for the first time about each concept, and that anyone can check whenever they forget what a specific “color” thing is.
As a disclaimer, I am not trying to explain color, or specifically, color reproduction, in this post; that would probably be impossible for a mortal like me. Instead, I want to give you a big enough picture for some technicalities behind color in CSS, such that you feel confident using functions like lab() or oklch() while also understanding what makes them special.
What’s a color?
Let’s slow down first. In order to understand everything in color, we first need to understand the color in everything.
While it’s useful to think about an object being a certain color (watch out for the red car, or cut the white cable!), color isn’t a physical property of objects, or even a tangible thing. Yes, we can characterize light as the main cause of color1, but it isn’t until visible light enters our eyes and is interpreted by our brains that we perceive a color. As said by Elle Stone:
Light waves are out there in the world, but color happens in the interaction between light waves and the eye, brain, and mind.
Even if color isn’t a physical thing, we still want to replicate it as reliably as possible, especially in the digital era. If we take a photo of a beautiful bouquet of lilies (like the one on my desk) and then display it on a screen, we expect to see the same colors in both the image and reality. However, “reality” here is a misleading term since, once again, the reality of color depends on the viewer. To solve this, we need to understand how light wavelengths (something measurable and replicable) create different color responses in viewers (something not so measurable).
Luckily, this task was already carried out 95 years ago by the International Commission on Illumination (CIE, by its French name). I wish I could get into the details of the experiment, but we haven’t gotten into our first color thingie yet. What’s important is that from these measurements, the CIE was able to map all the colors visible to the average human (in the experiment) to light wavelengths and describe them with only three values.
Initially, those three primary values corresponded to the red, green, and blue wavelengths used in the experiment, and they made up the CIERGB Color Space, but researchers noticed that some colors required a negative wavelength2 to represent a visible color. To avoid that, a series of transformations were performed on the original CIERGB and the resulting color space was called CIEXYZ.
This new color space also has three values, X and Z represent the chromaticity of a color, while Y represents its luminance. Since it has three axes, it makes a 3D shape, but if we slice it such that its luminance is the same, we get all the visible colors for a given luminance in a figure you have probably seen before.
This is called the xy chromaticity diagram and holds all the colors visible by the average human eye (based on the average viewer in the CIE 1931 experiment). Colors inside the shape are considered real, while those outside are deemed imaginary.
Color Spaces
The purpose of the last explanation was to reach the CIEXYZ Color Space concept, but what exactly is a “color space”? And why is the CIEXYZ Color Space so important?
The CIEXYZ Color Space is a mapping from all the colors visible by the average human eye into a 3D coordinate system, so we only need three values to define a color. Then, a color space can be thought of as a general mapping of color, with no need to include every visible color, and it is usually defined through three values as well.
RGB Color Spaces
The most well-known color spaces are the RGB color spaces (note the plural). As you may guess from the name, here we only need the amount of red, green, and blue to describe a color. And to describe an RGB color space, we only need to define its “reddest”, “greenest”, and “bluest” values3. If we use coordinates going from 0 to 1 to define a color in the RGB color space, then:
(1, 0, 0) means the reddest color.
(0, 1, 0) means the greenest color.
(0, 0, 1) means the bluest color.
However, “reddest”, “bluest”, and “greenest” are only arbitrary descriptions of color. What makes a color the “bluest” is up to each person. For example, which of the following colors do you think is the bluest?
As you can guess, something like “bluest” is an appalling description. Luckily, we just have to look back at the CIEXYZ color space — it’s pretty useful! Here, we can define what we consider the reddest, greenest, and bluest colors just as coordinates inside the xy chromaticity diagram. That’s all it takes to create an RGB color space, and why there are so many!
In CSS, the most used color space is the standard RGB (sRGB) color space, which, as you can see in the last image, leaves a lot of colors out. However, in CSS, we can use modern RGB color spaces with a lot more colors through the color() function, such as display-p3, prophoto-rgb, and rec2020.
Credit: Chrome Developer Team
Notice how the ProPhoto RGB color space goes out of the visible color. This is okay. Colors outside are clamped; they aren’t new or invisible colors.
In CSS, besides sRGB, we have two more color spaces: the CIELAB color space and the Oklab color space. Luckily, once we understood what the CIEXYZ color space is, then these two should be simpler to understand. Let’s dig into that next.
CIELAB and Oklab Color Spaces
As we saw before, the sRGB color space lacks many of the colors visible by the average human eye. And as modern screens got better at displaying more colors, CSS needed to adopt newer color spaces to fully take advantage of those newer displays. That wasn’t the only problem with sRGB — it also lacks perceptual uniformity, meaning that changes in the color’s chromaticity also change its perceived lightness. Check, for example, this demo by Adam Argyle:
Created in 1976 by the CIE, CIELAB, derived from CIEXYZ, also encompasses all the colors visible by the human eye. It works with three coordinates: L for perceptual lightness, a for the amount of red-green, and b* for the amount of yellow-blue in the color.
Credit: Linshang Technology
It has a way better perceptual uniformity than sRGB, but it still isn’t completely uniform, especially in gradients involving blue. For example, in the following white-to-blue gradient, CIELAB shifts towards purple.
As a final improvement, Björn Ottosson came up with the Oklab color space, which also holds all colors visible by the human eye while keeping a better perceptual uniformity. Oklab also uses the three L*a*b* coordinates. Thanks to all these improvements, it is the color space I try to use the most lately.
Color Models
When I was learning about these concepts, my biggest challenge after understanding color spaces was not getting them confused with color models and color gamuts. These two concepts, while complementary and closely related to color spaces, aren’t the same, so they are a common pitfall when learning about color.
A color model refers to the mathematical description of color through tuples of numbers, usually involving three numbers, but these values don’t give us an exact color until we pair them with a color space. For example, you know that in the RGB color model, we define color through three values: red, green, and blue. However, it isn’t until we match it to an RGB color space (e.g., sRGB with display-p3) that we have a color. In this sense, a color space can have several color models, like sRGB, which uses RGB, HSL, and HWB. At the same time, a color model can be used in several color spaces.
I found plenty of articles and tutorials where “color spaces” and “color models” were used interchangeably. And some places were they had a different definition of color spaces and models than the one provided here. For example, Chrome’s High definition CSS color guide defines CSS’s RGB and HSL as different color spaces, while MDN’s Color Space entry does define RGB and HSL as part of the sRGB color space.
Personally, in CSS, I find it easier to understand the idea of RGB, HSL and HWB as different models to access the sRGB color space.
Color Gamuts
A color gamut is more straightforward to explain. You may have noticed how we have talked about a color space having more colors than another, but it would be more correct to say it has a “wider” gamut, since a color gamut is the range of colors available in a color space. However, a color gamut isn’t only restricted by color space boundaries, but also by physical limitations. For example, an older screen may decrease the color gamut since it isn’t able to display each color available in a given color space. In this case where a color can’t be represented (due to physical limitation or being outside the color space itself), it’s said to be “out of gamut”.
Color Functions
In CSS, the only color space available used to be sRGB. Nowadays, we can work with a lot of modern color spaces through their respective color functions. As a quick reference, each of the color spaces in CSS uses the following functions:
sRGB: We can work in sRGB using the ol’ hexadecimal notation, named colors, and the rgb(), rgba(), hsl(), hsla() and hwb() functions.
CIELAB: Here we have the lab() for Cartesian coordinates and lch() for polar coordinates.
Oklab: Similar to CIELAB, we have oklab() for Cartesian coordinates and oklch() for polar coordinates.
More through the color() and color-mix(). Outside these three color spaces, we can use many more using the color() and color-mix() functions. Specifically, we can use the RGB color spaces: rgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020 and the XYZ color space: xyz, xyz-d50, or xyz-d65.
TL;DR
Color spaces are a mapping between available colors and a coordinate system. In CSS, we have three main color spaces: sRGB, CIELAB, and Oklab, but many more are accessible through the color() function.
Color models define color with tuples of numbers, but they don’t give us information about the actual color until we pair them with a color space. For example, the RGB model doesn’t mean anything until we assign it an RGB color space.
Most of the time, we want to talk about how many colors a color space holds, so we use the term color gamut for the task. However, a color gamut is also tied to the physical limitations of a camera/display. A color may be out-of-gamut, meaning it can’t be represented in a given color space.
In CSS, we can access all these color spaces through color functions, of which there are many.
The CIEXYZ color space is extremely useful to define other color spaces, describe their gamuts, and convert between them.
1 Light is the main cause of color, but color can be created by things other than light. For example, rubbing your closed eyes mechanically stimulates your retina, creating color in what’s called phosphene. ⤴️
2 If negative light also makes you scratch your head, and for more info on how the CIEXYZ color space was created, I highly recommend Douglas A. Kerr The CIE XYZ and xyY Color Spaces paper. ⤴️
3 We also need to define the darkest dark color (“black”) and the lightest light color (“white”). However, for well-behaved color spaces, these two can be abstracted from the reddest, blues, and greenest colors. ⤴️