Tech News

Better CSS Shapes Using shape() — Part 4: Close and Move

Css Tricks - Mon, 07/07/2025 - 2:48am

This is the fourth post in a series about the new CSS shape() function. So far, we’ve covered the most common commands you will use to draw various shapes, including lines, arcs, and curves. This time, I want to introduce you to two more commands: close and move. They’re fairly simple in practice, and I think you will rarely use them, but they are incredibly useful when you need them.

Better CSS Shapes Using shape()
  1. Lines and Arcs
  2. More on Arcs
  3. Curves
  4. Close and Move (you are here!)
The close command

In the first part, we said that shape() always starts with a from command to define the first starting point but what about the end? It should end with a close command.

But you never used any close command in the previous articles!?

That’s true. I never did because I either “close” the shape myself or rely on the browser to “close” it for me. Said like that, it’s a bit confusing, but let’s take a simple example to better understand:

clip-path: shape(from 0 0, line to 100% 0, line to 100% 100%)

If you try this code, you will get a triangle shape, but if you look closely, you will notice that we have only two line commands whereas, to draw a triangle, we need a total of three lines. The last line between 100% 100% and 0 0 is implicit, and that’s the part where the browser is closing the shape for me without having to explicitly use a close command.

I could have written the following:

clip-path: shape(from 0 0, line to 100% 0, line to 100% 100%, close)

Or instead, define the last line by myself:

clip-path: shape(from 0 0, line to 100% 0, line to 100% 100%, line to 0 0)

But since the browser is able to close the shape alone, there is no need to add that last line command nor do we need to explicitly add the close command.

This might lead you to think that the close command is useless, right? It’s true in most cases (after all, I have written three articles about shape() without using it), but it’s important to know about it and what it does. In some particular cases, it can be useful, especially if used in the middle of a shape.

CodePen Embed Fallback

In this example, my starting point is the center and the logic of the shape is to draw four triangles. In the process, I need to get back to the center each time. So, instead of writing line to center, I simply write close and the browser will automatically get back to the initial point!

Intuitively, we should write the following:

clip-path: shape( from center, line to 20% 0, hline by 60%, line to center, /* triangle 1 */ line to 100% 20%, vline by 60%, line to center, /* triangle 2 */ line to 20% 100%, hline by 60%, line to center, /* triangle 3 */ line to 0 20%, vline by 60% /* triangle 4 */ )

But we can optimize it a little and simply do this instead:

clip-path: shape( from center, line to 20% 0, hline by 60%, close, line to 100% 20%, vline by 60%, close, line to 20% 100%, hline by 60%, close, line to 0 20%, vline by 60% )

We write less code, sure, but another important thing is that if I update the center value with another position, the close command will follow that position.

CodePen Embed Fallback

Don’t forget about this trick. It can help you optimize a lot of shapes by writing less code.

The move command

Let’s turn our attention to another shape() command you may rarely use, but can be incredibly useful in certain situations: the move command.

Most times when we need to draw a shape, it’s actually one continuous shape. But it may happen that our shape is composed of different parts not linked together. In these situations, the move command is what you will need.

Let’s take an example, similar to the previous one, but this time the triangles don’t touch each other:

CodePen Embed Fallback

Intuitively, we may think we need four separate elements, with its own shape() definition. But the that example is a single shape!

The trick is to draw the first triangle, then “move” somewhere else to draw the next one, and so on. The move command is similar to the from command but we use it in the middle of shape().

clip-path: shape( from 50% 40%, line to 20% 0, hline by 60%, close, /* triangle 1 */ move to 60% 50%, line to 100% 20%, vline by 60%, close, /* triangle 2 */ move to 50% 60%, line to 20% 100%, hline by 60%, close, /* triangle 3 */ move to 40% 50%, line to 0 20%, vline by 60% /* triangle 4 */ )

After drawing the first triangle, we “close” it and “move” to a new point to draw the next triangle. We can have multiple shapes using a single shape() definition. A more generic code will look like the below:

clip-path: shape( from X1 Y1, ..., close, /* shape 1 */ move to X2 Y2, ..., close, /* shape 2 */ ... move to Xn Yn, ... /* shape N */ )

The close commands before the move commands aren’t mandatory, so the code can be simplified to this:

clip-path: shape( from X1 Y1, ..., /* shape 1 */ move to X2 Y2, ..., /* shape 2 */ ... move to Xn Yn, ... /* shape N */ ) CodePen Embed Fallback

Let’s look at a few interesting use cases where this technique can be helpful.

Cut-out shapes

Previously, I shared a trick on how to create cut-out shapes using clip-path: polygon(). Starting from any kind of polygon, we can easily invert it to get its cut-out version:

CodePen Embed Fallback

We can do the same using shape(). The idea is to have an intersection between the main shape and the rectangle shape that fits the element boundaries. We need two shapes, hence the need for the move command.

The code is as follows:

.shape { clip-path: shape(from ...., move to 0 0, hline to 100%, vline to 100%, hline to 0); }

You start by creating your main shape and then you “move” to 0 0 and you create the rectangle shape (Remember, It’s the first shape we create in the first part of this series). We can even go further and introduce a CSS variable to easily switch between the normal shape and the inverted one.

.shape { clip-path: shape(from .... var(--i,)); } .invert { --i:,move to 0 0, hline to 100%, vline to 100%, hline to 0; }

By default, --i is not defined so var(--i,)will be empty and we get the main shape. If we define the variable with the rectangle shape, we get the inverted version.

Here is an example using a rounded hexagon shape:

CodePen Embed Fallback

In reality, the code should be as follows:

.shape { clip-path: shape(evenodd from .... var(--i,)); } .invert { --i:,move to 0 0, hline to 100%, vline to 100%, hline to 0; }

Notice the evenodd I am adding at the beginning of shape(). I won’t bother you with a detailed explanation on what it does but in some cases, the inverted shape is not visible and the fix is to add evenodd at the beginning. You can check the MDN page for more details.

Another improvement we can do is to add a variable to control the space around the shape. Let’s suppose you want to make the hexagon shape of the previous example smaller. It‘s tedious to update the code of the hexagon but it’s easier to update the code of the rectangle shape.

.shape { clip-path: shape(evenodd from ... var(--i,)) content-box; } .invert { --d: 20px; padding: var(--d); --i: ,move to calc(-1*var(--d)) calc(-1*var(--d)), hline to calc(100% + var(--d)), vline to calc(100% + var(--d)), hline to calc(-1*var(--d)); }

We first update the reference box of the shape to be content-box. Then we add some padding which will logically reduce the area of the shape since it will no longer include the padding (nor the border). The padding is excluded (invisible) by default and here comes the trick where we update the rectangle shape to re-include the padding.

That is why the --i variable is so verbose. It uses the value of the padding to extend the rectangle area and cover the whole element as if we didn’t have content-box.

CodePen Embed Fallback

Not only you can easily invert any kind of shape, but you can also control the space around it! Here is another demo using the CSS-Tricks logo to illustrate how easy the method is:

CodePen Embed Fallback

This exact same example is available in my SVG-to-CSS converter, providing you with the shape() code without having to do all of the math.

Repetitive shapes

Another interesting use case of the move command is when we need to repeat the same shape multiple times. Do you remember the difference between the by and the to directives? The by directive allows us to define relative coordinates considering the previous point. So, if we create our shape using only by, we can easily reuse the same code as many times as we want.

Let’s start with a simple example of a circle shape:

clip-path: shape(from X Y, arc by 0 -50px of 1%, arc by 0 50px of 1%)

Starting from X Y, I draw a first arc moving upward by 50px, then I get back to X Y with another arc using the same offset, but downward. If you are a bit lost with the syntax, try reviewing Part 1 to refresh your memory about the arc command.

How I drew the shape is not important. What is important is that whatever the value of X Y is, I will always get the same circle but in a different position. Do you see where I am going with this idea? If I want to add another circle, I simply repeat the same code with a different X Y.

clip-path: shape( from X1 Y1, arc by 0 -50px of 1%, arc by 0 50px of 1%, move to X2 Y2, arc by 0 -50px of 1%, arc by 0 50px of 1% )

And since the code is the same, I can store the circle shape into a CSS variable and draw as many circles as I want:

.shape { --sh:, arc by 0 -50px of 1%, arc by 0 50px of 1%; clip-path: shape( from X1 Y1 var(--sh), move to X2 Y2 var(--sh), ... move to Xn Yn var(--sh) ) }

You don’t want a circle? Easy, you can update the --sh variable with any shape you want. Here is an example with three different shapes:

CodePen Embed Fallback

And guess what? You can invert the whole thing using the cut-out technique by adding the rectangle shape at the end:

CodePen Embed Fallback

This code is a perfect example of the shape() function’s power. We don’t have any code duplication and we can simply adjust the shape with CSS variables. This is something we are unable to achieve with the path() function because it doesn’t support variables.

Conclusion

That’s all for this fourth installment of our series on the CSS shape() function! We didn’t make any super complex shapes, but we learned how two simple commands can open a lot of possibilities of what can be done using shape().

Just for fun, here is one more demo recreating a classic three-dot loader using the last technique we covered. Notice how much further we could go, adding things like animation to the mix:

CodePen Embed Fallback Better CSS Shapes Using shape()
  1. Lines and Arcs
  2. More on Arcs
  3. Curves
  4. Close and Move (you are here!)

Better CSS Shapes Using shape() — Part 4: Close and Move originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

The Gap Strikes Back: Now Stylable

Css Tricks - Tue, 07/01/2025 - 2:42am

Four years ago, I wrote an article titled Minding the “gap”, where I talked about the CSS gap property, where it applied, and how it worked with various CSS layouts.

At the time, I described how easy it was to evenly space items out in a flex, grid, or multi-column layout, by using the gap property. But, I also said that styling the gap areas was much harder, and I shared a workaround.

However, workarounds like using extra HTML elements, pseudo-elements, or borders to draw separator lines tend to come with drawbacks, especially those that impact your layout size, interfere with assistive technologies, or pollute your markup with style-only elements.

Today, I’m writing again about layout gaps, but this time, to tell you all about a new and exciting CSS feature that’s going to change it all. What you previously had to use workarounds for, you’ll soon be able to do with just a few simple CSS properties that make it easy, yet also flexible, to display styled separators between your layout items.

There’s already a specification draft for the feature you can peruse. At the time I’m writing this, it is available in Chrome and Edge 139 behind a flag. But I believe it won’t be long before we turn that flag on. I believe other browsers are also very receptive and engaged.

Displaying decorative lines between items of a layout can make a big difference. When used well, these lines can bring more structure to your layout, and give your users more of a sense of how the different regions of a page are organized.

Introducing CSS gap decorations

If you’ve ever used a multi-column layout, such as by using the column-width property, then you might already be familiar with gap decorations. You can draw vertical lines between the columns of a multi-column layout by using the column-rule property:

article { column-width: 20rem; column-rule: 1px solid black; }

The CSS gap decorations feature builds on this to provide a more comprehensive system that makes it easy for you to draw separator lines in other layout types.

For example, the draft specification says that the column-rule property also works in flexbox and grid layouts:

.my-grid-container { display: grid; gap: 2px; column-rule: 2px solid pink; }

No need for extra elements or borders! The key benefit here is that the decoration happens in CSS only, where it belongs, with no impacts to your semantic markup.

The CSS gap decorations feature also introduces a new row-rule property for drawing lines between rows:

.my-flex-container { display: flex; gap: 10px; row-rule: 10px dotted limegreen; column-rule: 5px dashed coral; }

But that’s not all, because the above syntax also allows you to define multiple, comma-separated, line style values, and use the same repeat() function that CSS grid already uses for row and column templates. This makes it possible to define different styles of line decorations in a single layout, and adapt to an unknown number of gaps:

.my-container { display: grid; gap: 2px; row-rule: repeat(2, 1px dashed red), 2px solid black, repeat(auto, 1px dotted green); }

Finally, the CSS gap decorations feature comes with additional CSS properties such as row-rule-break, column-rule-break, row-rule-outset, column-rule-outset, and gap-rule-paint-order, which make it possible to precisely customize the way the separators are drawn, whether they overlap, or where they start and end.

And of course, all of this works across grid, flexbox, multi-column, and soon, masonry!

Browser support

Currently, the CSS gap decorations feature is only available in Chromium-based browsers.

The feature is still early in the making, and there’s time for you all to try it and to provide feedback that could help make the feature better and more adapted to your needs.

If you want to try the feature today, make sure to use Edge or Chrome, starting with version 139 (or another Chromium-based browser that matches those versions), and enable the flag by following these steps:

  1. In Chrome or Edge, go to about://flags.
  2. In the search field, search for Enable Experimental Web Platform Features.
  3. Enable the flag.
  4. Restart the browser.

To put this all into practice, let’s walk through an example together that uses the new CSS gap decorations feature. I also have a final example you can demo.

Using CSS gap decorations

Let’s build a simple web page to learn how to use the feature. Here is what we’ll be building:

The above layout contains a header section with a title, a navigation menu with a few links, a main section with a series of short paragraphs of text and photos, and a footer.

We’ll use the following markup:

<body> <header> <h1>My personal site</h1> </header> <nav> <ul> <li><a href="#">Home</a></li> <li><a href="#">Blog</a></li> <li><a href="#">About</a></li> <li><a href="#">Links</a></li> </ul> </nav> <main> <article> <p>...</p> </article> <article> <img src="cat.jpg" alt="A sleeping cat."> </article> <article> <p>...</p> </article> <article> <img src="tree.jpg" alt="An old olive tree trunk."> </article> <article> <p>...</p> </article> <article> <p>...</p> </article> <article> <p>...</p> </article> <article> <img src="strings.jpg" alt="Snow flakes falling in a motion blur effect."> </article> </main> <footer> <p>© 2025 Patrick Brosset</p> </footer> </body>

We’ll start by making the <body> element be a grid container. This way, we can space out the <header>, <nav>, <main>, and <footer> elements apart in one go by using the gap property:

body { display: grid; gap: 4rem; margin: 2rem; }

Let’s now use the CSS gap decorations feature to display horizontal separator lines within the gaps we just defined:

body { display: grid; gap: 4rem; margin: 2rem; row-rule: 1rem solid #efefef; }

This gives us the following result:

We can do a bit better by making the first horizontal line look different than the other two lines, and simplify the row-rule value by using the repeat() syntax:

body { display: grid; gap: 4rem; margin: 2rem; row-rule: 1rem solid #efefef, repeat(2, 2px solid #efefef); }

With this new row-rule property value, we’re telling the browser to draw the first horizontal separator as a 1rem thick line, and the next two separators as 2px thick lines, which gives the following result:

Now, let’s turn our attention to the navigation element and its list of links. We’ll use flexbox to display the links in a single row, where each link is separated from the other links by a gap and a vertical line:

nav ul { display: flex; flex-wrap: wrap; gap: 2rem; column-rule: 2px dashed #666; }

Very similarly to how we used the row-rule property before, we’re now using the column-rule property to display a dashed 2px thick separator between the links.

Our example web page now looks like this:

The last thing we need to change is the <main> element and its paragraphs and pictures. We’ll use flexbox again and display the various children in a wrapping row of varying width items:

main { display: flex; flex-wrap: wrap; gap: 4rem; } main > * { flex: 1 1 200px; } main article:has(p) { flex-basis: 400px; }

In the above code snippet, we’re setting the <main> element to be a wrapping flex container with a 4rem gap between items and flex lines. We’re also making the items have a flex basis size of 200px for pictures and 400px for text, and allowing them to grow and shrink as needed. This gives us the following result:

Let’s use CSS gap decorations to bring a little more structure to our layout by drawing 2px thick separator lines between the rows and columns of the layout:

main { display: flex; flex-wrap: wrap; gap: 4rem; row-rule: 2px solid #999; column-rule: 2px solid #999; }

This gives us the following result, which is very close to our expected design:

The last detail we want to change is related to the vertical lines. We don’t want them to span across the entire height of the flex lines but instead start and stop where the content starts and stops.

With CSS gap decorations, we can easily achieve this by using the column-rule-outset property to fine-tune exactly where the decorations start and end, relative to the gap area:

main { display: flex; flex-wrap: wrap; gap: 4rem; row-rule: 2px solid #999; column-rule: 2px solid #999; column-rule-outset: 0; }

The column-rule-outset property above makes the vertical column separators span the height of each row, excluding the gap area, which is what we want:

And with that, we’re done with our example. Check out the live example, and source code.

Learn more

There’s more to the feature and I mentioned a couple more CSS properties earlier

  • gap-rule-paint-order, which lets you control which of the decorations, rows or columns, appear above the other ones.
  • row-rule-break / column-rule-break, which sets the behavior of the decoration lines at intersections. In particular, whether they are made of multiple segments, which start and end at intersections, or single, continuous lines.

Because the feature is new, there isn’t MDN documentation about it yet. So to learn more, check out:

The Edge team has also created an interactive playground where you can use visual controls to configure gap decorations.

And, of course, the reason this is all implemented behind a flag is to elicit feedback from developers like you! If you have any feedback, questions, or bugs about this feature, I definitely encourage you to open a new ticket on the Chromium issue tracker.

The Gap Strikes Back: Now Stylable originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Using CSS Cascade Layers With Tailwind Utilities

Css Tricks - Mon, 06/30/2025 - 3:16am

Adam Wathan has (very cleverly) built Tailwind with CSS Cascade Layers, making it extremely powerful for organizing styles by priority.

@layer theme, base, components, utilities; @import 'tailwindcss/theme.css' layer(theme); @import 'tailwindcss/utilities.css' layer(utilities);

The core of Tailwind are its utilities. This means you have two choices:

  1. The default choice
  2. 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 */ } } <!-- Override with Tailwind utilities --> <div class="component p-4"> ... </div>

That’s a decent way of doing things.

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.

Of these two, I prefer the unnamed layer option:

/* Unnamed layer option */ @layer theme, base, components, utilities; /* Write your CSS normally here */ .component { /* ... */ } /* 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:

  1. 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.
  2. 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.
  3. 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.

<!-- !important the padding utility --> <div class="component !p-4"> ... </div>

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.

You can find out about these thoughts inside Unorthodox Tailwind.

Thanks for reading and I hope you’re enjoying a new way of looking at (or using) Tailwind!

Using CSS Cascade Layers With Tailwind Utilities originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

What Do You Want To AI?

LukeW - Sun, 06/29/2025 - 2:00pm

Alongside an increasing sameness of features and user interfaces, AI applications have also converged on their approach to primary calls to action: "What Do You Want To ___?" But is there a better way... especially for more domain specific applications?

Looking across AI products today, most feature an open-ended text field with an equally open-ended call to action:

  • What do you want to know?
  • What can I help with?
  • What do you want to create?
  • What do you want to build?
  • What will you imagine?
  • Ask anything...
  • Ask a question...
  • Ask [AI tool]...

So many questions. I've even turned them into a running joke. When a financial company integrates their AI: "What do you want to bank?" or "What do you want to accountant?" Silly I know, but it illustrates the issue. People often don't know what AI products can do nor how to best instruct/prompt them. Questions just exacerbate the issue.

It may be a small detail but instead of asking, how about instructing? Reve's image creation call to action says: "Describe an image or drop one here...". Bench's AI-powered workspace starts with: "Describe the task you want Bench to do...". Both calls to action are still open ended enough that so they can capture the kind of broad intent AI models can handle. But perhaps there's something to having a bit more guidance beyond "What Do You Want To AI?"

CSS Blob Recipes

Css Tricks - Fri, 06/27/2025 - 3:48am

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:

  1. They can be with just a single element (and preferably without pseudos).
  2. They can be easily designed (ideally through an online tool).
  3. 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.

<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"> <path fill="#FA4D56" d="M65.4,-37.9C79.2,-13.9,81,17,68.1,38C55.2,59.1,27.6,70.5,1.5,69.6C-24.6,68.8,-49.3,55.7,-56,38.2C-62.6,20.7,-51.3,-1.2,-39,-24.4C-26.7,-47.6,-13.3,-72,6.2,-75.6C25.8,-79.2,51.6,-62,65.4,-37.9Z" transform="translate(100 100)" /> </svg> Using border-radius

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%; } CodePen Embed Fallback

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%; } CodePen Embed Fallback

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%; } CodePen Embed Fallback

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

.blob { border-radius: /* horizontal */ 100% 30% 60% 70% / /* vertical */ 50% 40% 70% 70%; } CodePen Embed Fallback

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:

CodePen Embed Fallback

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:

  1. 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.
  2. We don’t have a tool to prototype our blobby amalgamations, so making one is a process of trial and error.
  3. 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:

CodePen Embed Fallback

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

CodePen Embed Fallback

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:

<div class="blob"> <div class="subblob"></div> <div class="subblob"></div> <div class="subblob"></div> </div>

…we would need to apply filters and background colors as such:

.blob { filter: contrast(50); background: white; /* Solid colors are necessary */ } .subblob { filter: blur(15px); background: black; /* Solid colors are necessary */ }

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.

CodePen Embed Fallback

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:

<svg xmlns="http://www.w3.org/2000/svg" version="1.1" style="position: absolute;"> <defs> <filter id="blob"> <feGaussianBlur in="SourceGraphic" stdDeviation="12" result="blur" /> <feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -6" result="goo" /> <feBlend in="SourceGraphic" in2="blob" /> </filter> </defs> </svg>

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!

CodePen Embed Fallback

That being said, this approach comes with two small, but important, changes to common CSS filters:

  1. The filter is applied to the parent element, not the individual shapes.
  2. 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:

CodePen Embed Fallback 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-imagebackground-position and background-size.

First, we will use background-image along with radial-gradient() to create a circle inside the element:

body { background: radial-gradient(farthest-side, var(--blob-color) 100%, #0000); background-repeat: no-repeat; /* Important! */ } CodePen Embed Fallback

Here is what each parameter does:

  • 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 (/).

body { background: radial-gradient(...) 20% 30% / 30% 40%; background-repeat: no-repeat; /* Important! */ } CodePen Embed Fallback

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:

.blob { background: radial-gradient(farthest-side, var(--blob-color) 100%, #0000) 20% 30% / 30% 40%, radial-gradient(farthest-side, var(--blob-color) 100%, #0000) 80% 50% / 40% 60%, radial-gradient(farthest-side, var(--blob-color) 100%, #0000) 50% 70% / 50% 50%; background-repeat: no-repeat; }

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!

CodePen Embed Fallback

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!

I’ll explain shape()‘s syntax briefly, but for an in-depth explanation, you’ll want to check out both this explainer from the CSS-Tricks Almanac as well as Temani Afif‘s three-part series on the shape() function, as well as his recent article about blobs.

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:

CodePen Embed Fallback

Let’s check if it passes our requirements:

  1. Yes, they can be made of a single element.
  2. Yes, they can also be created in a generator and then translated into CSS.
  3. 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.

CSS Blob Recipes originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

KelpUI

Css Tricks - Thu, 06/26/2025 - 6:42am

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.

And that’s what I’ve seen so far. The Cascade is openly embraced and logically structured with Cascade Layers. Plenty of utility classes are included, with extra care put into how they are named. Selectors are kept simple and specificity is nice and low, where needed. Layouts are flexible with good constraints. Color palettes are accessible and sport semantic naming.

Chris has even put a ton of thought into how KelpUI is licensed.

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.

KelpUI originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Lightly Poking at the CSS if() Function in Chrome 137

Css Tricks - Tue, 06/24/2025 - 5:17am

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 poke a little harder at it in another 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.

.element { color: if(style(--theme: dark): oklch(52% 0.18 140); else: oklch(65% 0.05 220)); } CodePen Embed Fallback How does if() work?

Well before Chrome implemented the feature, back in 2021 when it was first proposed, the early syntax was like this:

<if()> = if( <container-query>, [<declaration-value>]{1, 2} )

Now we’re looking at this instead:

<if()> = if( [<if-statement>: <result>]*; <if-statement>: <result> ;? )

Where…

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

<if()> = if( [ <if-branch> ; ]* <if-branch> ;? ) <if-branch> = <if-condition> : <declaration-value>? <if-condition> = <boolean-expr[ <if-test> ]> | else <if-test> = supports( [ <ident> : <declaration-value> ] | <supports-condition> ) media( <media-feature> | <media-condition> ) | style( <style-query> )

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:

.element { padding: if(style(--theme: dark): 2rem; else: 3rem); }

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:

:root { --height: 12.5rem; --width: 4rem; --weight: 2rem; } .element { height: if( style(--height: 3rem): 14.5rem; style(--width: 7rem): 10rem; style(--weight: 100rem): 2rem; else: var(--height) ); }

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:

CodePen Embed Fallback 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:

/* formal syntax for @supports */ @supports <supports-condition> { <rule-list> } /* formal syntax for supports() */ supports( [ <ident> : <declaration-value> ] | <supports-condition> )

The only difference here is that supports() returns a value instead of matching a block of code. But, how does this work in real code?

The <ident>: <declaration-value> you see here is, in this case, the property name: property value e.g. display: flex.

Let’s say you want to check for support for the backdrop-filter property, particularly the blur() function. Typically, you can do this with @supports:

/* Fallback in case the browser doesn't support backdrop-filter */ .card { backdrop-filter: unset; background-color: oklch(20% 50% 40% / 0.8); } @supports (backdrop-filter: blur(10px)) { .card { backdrop-filter: blur(10px); background-color: oklch(20% 50% 40% / 0.8); } }

But, with CSS if(), we can also do this:

.card { backdrop-filter: if( supports(backdrop-filter: blur(10px)): blur(10px); else: unset ); }

Note: Think of unset here as a possible fallback for graceful degradation.

That looks awesome, right? Multiple conditions can be checked as well for supports() and any of the supported functions. For example:

.card { backdrop-filter: if( supports(backdrop-filter: blur(10px)): blur(10px); supports(backdrop-filter: invert(50%)): invert(50%); supports(backdrop-filter: hue-rotate(230deg)): hue-rotate(230deg);; else: unset ); }

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:

h1 { font-size: 2rem; } @media (min-width: 768px) { h1 { font-size: 2.5rem; } } @media (min-width: 1200px) { h1 { font-size: 3rem; } }

The media() wrapper works almost the same way as its at-rule counterpart. Note its syntax from the spec:

/* formal syntax for @media */ @media <media-query-list> { <rule-list> } /* formal syntax for media() */ media( <media-feature> | <media-condition> )

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

h1 { font-size: if( media(width >= 1200px): 3rem; media(width >= 768px): 2.5rem; else: 2rem ); } Again, these are early days

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.

Lightly Poking at the CSS if() Function in Chrome 137 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

A Better API for the Intersection and Mutation Observers

Css Tricks - Mon, 06/23/2025 - 3:41am

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.

export function mutationObserver(node, options = {}) { // ... return { disconnect() { observer.disconnect() } } }

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.

const node = document.querySelector('.some-element') const obs = mutationObserver(/* ... */) obs.disconnect() MutationObserver’s observe options

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:

  1. Create a new observer: with the new keyword. This observer takes in an observer function to execute.
  2. Do something with the observed changes: This is done via the observer function that is passed into the observer.
  3. Observe a specific element: By using the observe method.
  4. (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.

const node = document.querySelector('.some-element') const obs = intersectionObserver(node, /*...*/) // Disconnect the observer obs.disconnect() IntersectionObserver options

In case you were wondering, IntersectionObserver takes in three options:

  • root: The element used to check if observed elements are visible
  • rootMargin: Lets you specify an offset amount from the edges of the root
  • threshold: Determines when to log an observer entry

Here’s an article to help you understand IntersectionObserver options.

Using this in practice via Splendid Labz

Splendid Labz has a utils library that contains resizeObserver, mutationObserver and IntersectionObserver.

You can use them if you don’t want to copy-paste the above snippets into every project.

import { resizeObserver, intersectionObserver, mutationObserver } from 'splendidlabz/utils/dom' const mode = document.querySelector(‘some-element’) const resizeObs = resizeObserver(node, /* ... */) const intersectObs = intersectionObserver(node, /* ... */) const mutateObs = mutationObserver(node, /* ... */)

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. &#x1f609;

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.

That’s it! Hope you had fun reading this piece!

A Better API for the Intersection and Mutation Observers originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

More on Generative Publishing

LukeW - Sat, 06/21/2025 - 2:00pm

One of the most common questions people ask my personal AI, Ask LukeW, is "how did you build this?" While I've written a lot about the high level architecture and product design details of the service, I never published a more technical overview. Doing so highlighted enough interesting generative publishing ideas that I decided to share a bit about the process.

First of all, Ask LukeW makes use of the thousands of articles I've written over the years to answer people's questions about digital product design. Yes, that's a lot of writing but it's not enough to capture all the things I've learned over the past 30 years. Which means sometimes people Ask LukeW questions that I can answer but haven't written about.

In the admin system I built for Ask LukeW, I can not only see the questions that don't get answered well but I can also add content to answer them better in the future. Over the last two years, I've added about 500 answers and thereby expanded the corpus Ask LukeW can respond from by a lot. So the next time similar questions get asked, people aren't left without answers.

That process is an interesting part of generative publishing that I've written about before but it's also how I know that people regularly ask how I built Ask LukeW. they want technical details: what frameworks, what models, what services. I never wrote this up because I'm not that technical and several great engineers helped me build Ask LukeW. As a result, I didn't think I'd do a great job detailing the technical aspect of things.

But one day it occurred to me I could use our AI for code company, Augment Code, which has a deep contextual understanding of codebases to help me write up how Ask LukeW works. I opened the codebase in VS Code and asked Augment the questions people asked me: "how does the feature work?" "what is the codebase?" "what is the tech stack?" and got great detailed responses.

Augment, however, doesn't answer questions the way I do. So I took Augment's detailed technical replies and dropped them into another one of our companies, Bench. A while back I had Bench read a lot of my blog posts and create a prompt that writes articles the way I would. I've saved this prompt in Bench's agent library and can apply it anytime I want it to write like I would.

Once I had Augment's technical details of how Ask LukeW worked written the way I'd explain them by Bench, I took the results and added them as saved answers to the Ask LukeW corpus. Now anytime someone asks these kinds of questions, they get much more detailed technical answers. In fact, this worked so well that I also asked Augment to write up the overall tech stack for my Website and went through the same process.

I for one, found this a really enlightening look at where generative publishing is now. I can see what kinds of information I should be publishing by looking at the questions people ask my personal AI but don't get good answers for. I can use an AI for coding tool to turn code into prose. I can use an agentic workspace to rewrite that prose the way I would because I taught it to write like me. And finally I can feed that content back into my overall corpus so it's available for any similar questions people ask in the future.

That doesn't look like the publishing of old to me. Of course, it's split between multiple tools, requires me know what each one can do, and a host of other issues. We're still early but it's exciting.

Color Everything in CSS

Css Tricks - Fri, 06/20/2025 - 4:04am

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!

Credit: Elle Stone

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:

CodePen Embed Fallback

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.

Image Credits to Björn Ottosson

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
  1. 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.
  2. 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.
  3. 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.
  4. In CSS, we can access all these color spaces through color functions, of which there are many.
  5. The CIEXYZ color space is extremely useful to define other color spaces, describe their gamuts, and convert between them.
References Footnotes

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. ⤴️

Color Everything in CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Common AI Product Issues

LukeW - Thu, 06/19/2025 - 2:00pm

At this point, almost every software domain has launched or explored AI features. Despite the wide range of use cases, most of these implementations have been the same ("let's add a chat panel to our app"). So the problems are the same as well.

Capability Awareness

Open-ended interfaces to AI models have the same problem as every "invisible" interface that came before them. Without a clear set of affordances, people don't know what they can do. The vision of these invisible UIs was always something like "Voice interfaces will work when you can ask them anything". Today it's "AI chat interfaces will work because you can tell them to do anything". Sounds great but...

In reality, even extremely capable systems (like extremely capable people) have limitations. They do some things well, some things ok, and other things poorly. How you ask them to do things also matters as different phrasings yield different results. But without affordances, these guideposts are as invisible as the UI.

I'm pretty certain this is the biggest problem in AI product interfaces today: because large-scale AI models can do so many things (but not all things or all things equally well), most people don't know what they can do nor how to best instruct/prompt them.

Context Awareness

If capability awareness is knowing what an AI product can do, context awareness is knowing how it did it. The fundamental question here is "what information did an AI product use to provide an answer?" But there's lots of potential answers especially as agents can make use of an increasing number and variety of tools. Some examples of what could be in context (considered in an AI model's response):

  • It's own training data? If so, when was the cut off?
  • The history of your session with the model? If so, going how far back?
  • The history of all your sessions or a user profile? If so, which parts?
  • Specific tools like search or browse? If so, which of their results?
  • Specific connections to other services or accounts? If so...

You get the idea. There's a lot of stuff that could be in context at any given point, but not everything will be in context all the time because models have context limits. So when getting replies people aren't sure if or how much they should trust them. Was the right information used or not (hallucinations)?

Walls of Text

While writing has done an enormous amount to enable communication, it's not the only medium for conveying information and, often, it may not be the best. Despite this, most AI products render the streams of text emitting from AI models as their primary output and they render them in a linear "chat-like" interface. Unsurprisingly, people have a hard time extracting and recalling information by scrolling through long blocks of text.

As the novelty of AI models being able to write text wears off, people increasingly ask for visuals, tables, and other formats like slides, spreadsheets as output instead of just walls of text.

And More..

Yes, there's other issues with AI products. I'm not suggesting this is a complete list but it is reflective of what I'm currently seeing over and over in user testing and across multiple domains. But it's still early for AI products so... more solutions and issues to come.

Steven Heller’s Font of the Month: Kefir

Typography - Thu, 06/19/2025 - 1:01pm

Read the book, Typographic Firsts

This month, Steven Heller takes a closer look at Roch Modrzejewski’s Kefir font family.

The post Steven Heller’s Font of the Month: Kefir appeared first on I Love Typography.

CSS Color Functions

Css Tricks - Thu, 06/19/2025 - 5:01am

If you asked me a few months ago, “What does it take for a website to stand out?” I may have said fancy animations, creative layouts, cool interactions, and maybe just the general aesthetics, without pointing out something in particular. If you ask me now, after working on color for the better part of the year, I can confidently say it’s all color. Among all the aspects that make a design, a good color system will make it as beautiful as possible.

However, color in CSS can be a bit hard to fully understand since there are many ways to set the same color, and sometimes they even look the same, but underneath are completely different technologies. That’s why, in this guide, we will walk through all the ways you can set up colors in CSS and all the color-related properties out there!

Colors are in everything

They are in your phone, in what your eye sees, and on any screen you look at; they essentially capture everything. Design-wise, I see the amazing use of colors on sites listed over at awwwards.com, and I’m always in awe.

Not all color is the same. In fact, similar colors can live in different worlds, known as color spaces. Take for example, sRGB, the color space used on the web for the better part of its existence and hence the most known. While it’s the most used, there are many colors that are simply missing in sRGB that new color spaces like CIELAB and Oklab bring, and they cover a wider range of colors sRGB could only dream of, but don’t let me get ahead of myself.

What’s a color space?

A color space is the way we arrange and represent colors that exist within a device, like printers and monitors. We have different types of color spaces that exist in media (Rec2020, Adobe RGB, etc), but not all of them are covered in CSS. Luckily, the ones we have are sufficient to produce all the awesome and beautiful colors we need. In this guide, we will be diving into the three main color spaces available in CSS: sRGB, CIELAB, and OkLab.

The sRGB Color Space

The sRGB is one of the first color spaces we learn. Inside, there are three color functions, which are essentially notations to define a color: rgb(), hsl(), and hwb().

sRGB has been a standard color space for the web since 1996. However, it’s closer to how old computers represented color, rather than how humans understand it, so it had some problems like not being able to capture the full gamut of modern screens. Still, many modern applications and websites use sRGB, so even though it is the “old way” of doing things, it is still widely accepted and used today.

The rgb() function

rgb() uses three values, r, g, and b which specifies the redness, greenness, and blueness of the color you want.

All three values are non-negative, and they go from 0 to 255.

.element { color: rgb(245 123 151); }

It also has an optional value (the alpha value) preceded by a forward slash. It determines the level of opacity for the color, which goes from 0 (or 0%) for a completely transparent color, to 1 (or 100%) for a fully opaque one.

.element { color: rgb(245 123 151 / 20%); }

There are two ways you can write inside rgb(). Either using the legacy syntax that separates the three values with commas or the modern syntax that separates each with spaces.

You want to combine the two syntax formats, yes? That’s a no-no. It won’t even work.

/* This would not work */ .element { color: rgb(225, 245, 200 / 0.5); } /* Neither will this */ .element { color: rgb(225 245 200, 0.5); } /* Or this */ .element { color: rgb(225, 245 200 / 0.5); }

But, following one consistent format will do the trick, so do that instead. Either you’re so used to the old syntax and it’s hard for you to move on, continue to use the legacy syntax, or you’re one who’s willing to try and stick to something new, use the modern syntax.

/* Valid (Modern syntax) */ .element { color: rgb(245 245 255 / 0.5); } /* Valid (Legacy syntax) */ .element { color: rgb(245, 245, 255, 0.5); } CodePen Embed Fallback The rgba() function

rgba() is essentially the same as rgb() with an extra alpha value used for transparency.

In terms of syntax, the rgba() function can be written in two ways:

  • Comma-separated and without percentages
  • Space-separated, with the alpha value written after a forward slash (/)
.element { color: rgba(100, 50, 0, 0.5); } .element { color: rgba(100 50 0 / 0.5); }

So, what’s the difference between rgba() and rgb()?

Breaking news! There is no difference. Initially, only rgba() could set the alpha value for opacity, but in recent years, rgb() now supports transparency using the forward slash (/) before the alpha value.

rgb() also supports legacy syntax (commas) and modern syntax (spaces), so there’s practically no reason to use rgba() anymore; it’s even noted as a CSS mistake by folks at W3C.

In a nutshell, rgb() and rgba() are the same, so just use rgb().

/* This works */ .element-1 { color: rgba(250 30 45 / 0.8); } /* And this works too, so why not just use this? */ .element-2 { color: rgb(250 30 45 / 0.8); } The hexadecimal notation

The hexadecimal CSS color code is a 3, 4, 6, or 8 (being the maximum) digit code for colors in sRGB. It’s basically a shorter way of writing rgb(). The hexadecimal color (or hex color) begins with a hash token (#) and then a hexadecimal number, which means it goes from 0 to 9 and then skips to letters a to f (a being 10, b being 11, and so on, up to f for 15).

In the hexadecimal color system, the 6-digit style is done in pairs. Each pair represents red (RR), blue (BB), and green (GG).

Each value in the pair can go from 00 to FF, which it’s equivalent to 255 in rgb().

Notice how I used caps for the letters (F) and not lowercase letters like I did previously? Well, that’s because hexadecimals are not case-sensitive in CSS, so you don’t have to worry about uppercase or lowercase letters when dealing with hexadecimal colors.

  • 3-digit hexadecimal. The 3-digit hexadecimal system is a shorter way of writing the 6-digit hexadecimal system, where each value represents the color’s redness, greenness, and blueness, respectively
.element { color: #abc; }

In reality, each value in the 3-digit system is duplicated and then translated to a visible color

.element { color: #abc; /* Equals #AABBCC */ }

BUT, this severely limits the colors you can set. What if I want to target the color 213 in the red space, or how would I get a blue of value 103? It’s impossible. That’s why you can only get a total number of 4,096 colors here as opposed to the 17 million in the 6-digit notation. Still, if you want a fast way of getting a certain color in hexadecimal without having to worry about the millions of other colors, use the 3-digit notation.

  • 4-digit hexadecimal. This is similar to the 3-digit hexadecimal notation except it includes the optional alpha value for opacity. It’s a shorter way of writing the 8-digit hexadecimal which also means that all values here are repeated once during color translation.
.element { color: #abcd; /* Same as #AABBCCDD */ }

For the alpha value, 0 represents 00 (a fully transparent color) and F represents FF (a fully opaque color).

  • 6-digit hexadecimal. The 6-digit hexadecimal system just specifies a hexadecimal color’s redness, blueness, and greenness without its alpha value for color opacity.
.element { color: #abcdef; }
  • 8-digit hexadecimal. This 8-digit hexadecimal system specifies hexadecimal color’s redness, blueness, greenness, and its alpha value for color opacity. Basically, it is complete for color control in sRGB.
.element { color: #faded101; } The hsl() function

Both hsl() and rgb() live in the sRGB space, but they access colors differently. And while the consensus is that hsl() is far more intuitive than rgb(), it all boils down to your preference.

hsl() takes three values: h, s, and l, which set its hue, saturation, and lightness, respectively.

  • The hue sets the base color and represents a direction in the color wheel, so it’s written in angles from 0deg to 360deg.
  • The saturation sets how much of the base color is present and goes from 0 (or 0%) to 100 (or 100%).
  • The lightness represents how close to white or black the color gets.

One cool thing: the hue angle goes from (0deg–360deg), but we might as well use negative angles or angles above 360deg, and they will circle back to the right hue. Especially useful for infinite color animation. Pretty neat, right?

Plus, you can easily get a complementary color from the opposite angle (i.e., adding 180deg to the current hue) on the color wheel.

/* Current color */ .element { color: hsl(120deg 40 60 / 0.8); } /* Complementary color */ .element { color: hsl(300deg 40 60 / 0.8); }

You want to combine the two syntax formats like in rgb(), yes? That’s also a no-no. It won’t work.

/* This would not work */ .element { color: hsl(130deg, 50, 20 / 0.5); } /* Neither will this */ .element { color: hsl(130deg 50 20, 0.5); } /* Or this */ .element { color: hsl(130deg 50, 20 / 0.5); }

Instead, stick to one of the syntaxes, like in rgb():

/* Valid (Modern syntax) */ .element { color: hsl(130deg 50 20 / 0.5); } /* Valid (Modern syntax) */ .element { color: hsl(130deg, 50, 20, 0.5); } CodePen Embed Fallback The hsla() function

hsla() is essentially the same with hsl(). It uses three values to represent its color’s hue (h), saturation (s), and lightness (l), and yes (again), an alpha value for transparency (a). We can write hsla() in two different ways:

  • Comma separated
  • Space separated, with the alpha value written after a forward slash (/)
.element { color: hsla(120deg, 100%, 50%, 0.5); } .element { color: hsla(120deg 100% 50% / 0.5); }

So, what’s the difference between hsla() and hsl()?

Breaking news (again)! They’re the same. hsl() and hsla() both:

  • Support legacy and modern syntax
  • Have the power to increase or reduce color opacity

So, why does hsla() still exist? Well, apart from being one of the mistakes of CSS, many applications on the web still use hsla() since there wasn’t a way to set opacity with hsl() when it was first conceived.

My advice: just use hsl(). It’s the same as hsla() but less to write.

/* This works */ .element-1 { color: hsla(120deg 80 90 / 0.8); } /* And this works too, so why not just use this? */ .element-2 { color: hsl(120deg 80 90 / 0.8); } The hwb() function

hwb() also uses hue for its first value, but instead takes two values for whiteness and blackness to determine how your colors will come out (and yes, it also does have an optional transparency value, a, just like rgb() and hsl()).

.element { color: hwb(80deg 20 50 / 0.5); }
  • The first value h is the same as the hue angle in hsl(), which represents the color position in the color wheel from 0 (or 0deg) to 360 (or 360deg).
  • The second value, w, represents the whiteness in the color. It ranges from 0/0% (no white) to 100/100% (full white if b is 0).
  • The third value, b, represents the blackness in the color. It ranges from 0/0% (no black) to 100/100% (fully black if w is 0).
  • The final (optional) value is the alpha value, a, for the color’s opacity, preceded by a forward slash The value’s range is from 0.0 (or 0%) to 1.0 (or 100%).

Although this color function is barely used, it’s completely valid to use, so it’s up to personal preference.

CodePen Embed Fallback Named colors

CSS named colors are hardcoded keywords representing predefined colors in sRGB. You are probably used to the basic: white, blue, black, red, but there are a lot more, totaling 147 in all, that are defined in the Color Modules Level 4 specification.

Named colors are often discouraged because their names do not always match what color you would expect.

The CIELAB Color Space

The CIELAB color space is a relatively new color space on the web that represents a wider color gamut, closer to what the human eye can see, so it holds a lot more color than the sRGB space.

The lab() function

For this color function, we have three axes in a space-separated list to determine how the color is set.

.element { color: lab(50 20 20 / 0.9); }
  • The first value l represents the degree of whiteness to blackness of the color. Its range being 0/(or 0%) (black) to 100 (or 100%) (white).
  • The second value a represents the degree of greenness to redness of the color. Its range being from -125/0% (green) to125 (or 100%) (red).
  • The third value b represents the degree of blueness to yellowness of the color. Its range is also from -125 (or 0%) (blue) to 125 (or 100%) (red).
  • The fourth and final value is its alpha value for color’s opacity. The value’s range is from 0.0 (or 0%) to 1.0 (or 100%).

This is useful when you’re trying to obtain new colors and provide support for screens that do support them. Actually, most screens and all major browsers now support lab(), so you should be good.

The CSS lab() color function’s a and b values are actually unbounded. Meaning they don’t technically have an upper or lower limit. But, at practice, those are their limits according to the spec.

CodePen Embed Fallback The lch() function

The CSS lch() color function is said to be better and more intuitive than lab().

.element { color: lch(10 30 300deg); }

They both use the same color space, but instead of having l, a, and b, lch uses lightness, chroma, and hue.

  • The first value l represents the degree of whiteness to blackness of the color. Its range being 0 (or 0%) (black) to 100 (or 100%) (white).
  • The second value c represents the color’s chroma (which is like saturation). Its range being from 0 (or 100%) to 150 or (or 100%).
  • The third value h represents the color hue. The value’s range is also from 0 (or 0deg) to 360 (or 360deg).
  • The fourth and final value is its alpha value for color’s opacity. The value’s range is from 0.0 (or 0%) to 1.0 (or 100%).

The CSS lch() color function’s chroma (c) value is actually unbounded. Meaning it doesn’t technically have an upper or lower limit. But, in practice, the chroma values above are the limits according to the spec.

CodePen Embed Fallback The OkLab Color Space

Björn Ottosson created this color space as an “OK” and even better version of the lab color space. It was created to solve the limitations of CIELAB and CIELAB color space like image processing in lab(), such as making an image grayscale, and perceptual uniformity. The two color functions in CSS that correspond to this color space are oklab() and oklch().

Perceptual uniformity occurs when there’s a smooth change in the direction of a gradient color from one point to another. If you notice stark contrasts like the example below for rgb() when transitioning from one hue to another, that is referred to as a non-uniform perceptual colormap.

CodePen Embed Fallback

Notice how the change from one color to another is the same in oklab() without any stark contrasts as opposed to rgb()? Yeah, OKLab color space solves the stark contrasts present and gives you access to many more colors not present in sRGB.

OKlab actually provides a better saturation of colors while still maintaining the hue and lightness present in colors in CIELAB (and even a smoother transition between colors!).

The oklab() function

The oklab() color function, just like lab(), generates colors according to their lightness, red/green axis, blue/yellow axis, and an alpha value for color opacity. Also, the values for oklab() are different from that of lab() so please watch out for that.

.element { color: oklab(30% 20% 10% / 0.9); }
  • The first value l represents the degree of whiteness to blackness of the color. Its range being 0 (or 0%) (black) to 0.1 (or 100%) (white).
  • The second value a represents the degree of greenness to redness of the color. Its range being from -0.4 (or -100%) (green) to 0.4 (or 100%) (red).
  • The third value b represents the degree of blueness to yellowness of the color. The value’s range is also from -0.4 (or 0%) (blue) to 0.4 (or -100%) (red).
  • The fourth and final value is its alpha value for color’s opacity. The value’s range is from 0.0 (or 0%) to 1.0 (or 100%).

Again, this solves one of the issues in lab which is perceptual uniformity so if you’re looking to use a better alternative to lab, use oklab().

The CSS oklab() color function’s a and b values are actually unbounded. Meaning they don’t technically have an upper or lower limit. But, theoretically, those are the limits for the values according to the spec.

CodePen Embed Fallback The oklch() function

The oklch() color function, just like lch(), generates colors according to their lightness, chroma, hue, and an alpha value for color opacity. The main difference here is that it solves the issues present in lab() and lch().

.element { color: oklch(40% 20% 100deg / 0.7); }
  • The first value l represents the degree of whiteness to blackness of the color. Its range being 0.0 (or 0%) (black) to 1.0 (or 100%) (white).
  • The second value c represents the color’s chroma. Its range being from 0 (or 0%) to 0.4 (or 100%) (it theoretically doesn’t exceed 0.5).
  • The third value h represents the color hue. The value’s range is also from 0 (or 0deg) to 360 (or 360deg).
  • The fourth and final value is its alpha value for color’s opacity. The value’s range is from 0.0 (or 0%) to 1.0 (or 100%).

The CSS oklch() color function’s chroma (c) value is actually unbounded. Meaning it doesn’t technically have an upper or lower limit. But, theoretically, the chroma values above are the limits according to the spec.

CodePen Embed Fallback The color() function

The color() function allows access to colors in nine different color spaces, as opposed to the previous color functions mentioned, which only allow access to one.

To use this function, you must simply be aware of these 6 parameters:

  • The first value specifies the color space you want to access colors from. They can either be srgb, srgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020, xyz, xyz-d50, or xyz-d65
  • The next three values (c1, c2, and c3) specifies the coordinates in the color space for the color ranging from 0.0 – 1.0.
  • The sixth and final value is its alpha value for color’s opacity. The value’s range is from 0.0 (or 0%) to 1.0 (or 100%).
CodePen Embed Fallback The color-mix() function

The color-mix() function mixes two colors of any type in a given color space. Basically, you can create an endless number of colors with this method and explore more options than you normally would with any other color function. A pretty powerful CSS function, I would say.

.element { color-mix(in oklab, hsl(40 20 60) 80%, red 20%); }

You’re basically mixing two colors of any type in a color space. Do take note, the accepted color spaces here are different from the color spaces accepted in the color() function.

To use this function, you must be aware of these three values:

  • The first value in colorspace specifies the interpolation method used to mix the colors, and these can be any of these 15 color spaces: srgb, srgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020, lab, oklab, xyz, xyz-d50, xyz-d65, hsl, hwb, lch, and oklch.
  • The second and third values specifies an accepted color value and a percentage from 0% to 100%.
CodePen Embed Fallback The Relative Color Syntax

Here’s how it works. We have:

.element{ color-function(from origin-color c1 c2 c3 / alpha) }
  • The first value from is a mandatory keyword you must set to extract the color values from origin-color.
  • The second value, origin-color, represents a color function or value or even another relative color that you want to get color from.
  • The next three values, c1, c2, and c3 represent the current color function’s color channels and they correspond with the color function’s valid color values.
  • The sixth and final value is its alpha value for color’s opacity. The value’s range is from 0.0 (or 0%) to 1.0 (or 100%) which either set from the origin-color or set manually,

Let’s take an example, say, converting a color from rgb() to lab():

.element { color: lab(from rgb(255 210 01 / 0.5) l a b / a); }

All the values above will be translated to the corresponding colors in rgb(). Now, let’s take a look at another example where we convert a color from rgb() to oklch():

.element { color: oklch(from rgb(255 210 01 / 0.5) 50% 20% h / a); }

Although, the l and c values were changed, the h and a would be taken from the original color, which in this case is a light yellowish color in rgb().

You can even be wacky and use math functions:

All CSS color functions support the relative color syntax. The relative color syntax, simply put, is a way to access other colors in another color function or value, then translating it to the values of the current color function. It goes “from <color>” to another.

.element { color: oklch(from rgb(255 210 01 / 0.5) calc(50% + var(--a)) calc(20% + var(--b)) h / a); }

The relative color syntax is, however, different than the color() function in that you have to include the color space name and then fully write out the channels, like this:

.element { color: color(from origin-color colorspace c1 c2 c3 / alpha); }

Remember, the color-mix() function is not a part of this. You can have relative color functions inside the color functions you want to mix, yes, but the relative color syntax is not available in color-mix() directly.

Color gradients

CSS is totally capable of transitioning from one color to another. See the “CSS Gradients Guide” for a full run-down, including of the different types of gradients with examples.

Visit the Guide Properties that support color values

There are a lot of properties that support the use of color. Just so you know, this list does not contain deprecated properties.

accent-color

This CSS property sets the accent color for UI controls like checkboxes and radio buttons, and any other form element

progress { accent-color: lightgreen; }

Accent colors are a way to style unique elements in respect to the chosen color scheme.

background-color

Applies solid colors as background on an element.

.element { background-color: #ff7a18; } border-color

Shorthand for setting the color of all four borders.

/* Sets all border colors */ .element { border-color: lch(50 50 20); } /* Sets top, right, bottom, left border colors */ .element { border-color: black green red blue; } box-shadow

Adds shadows to element for creating the illusion of depth. The property accepts a number of arguments, one of which sets the shadow color.

.element { box-shadow: 0 3px 10px rgb(0 0 0 / 0.2); } caret-color

Specifies the color of the text input cursor (caret).

.element { caret-color: lch(30 40 40); } color

Sets the foreground color of text and text decorations.

.element { color: lch(80 10 20); } column-rule-color

Sets the color of a line between columns in a multi-column layout. This property can’t act alone, so you need to set the columns and column-rule-style property first before using this.

.element { column: 3; column-rule-style: solid; column-rule-color: lch(20 40 40); /* highlight */ } fill

Sets the color of the SVG shape

.element { fill: lch(40 20 10); } flood-color

Specifies the flood color to use for <feFlood> and <feDropShadow> elements inside the <filter> element for <svg>. This should not be confused with the flood-color CSS attribute, as this is a CSS property and that’s an HTML attribute (even though they basically do the same thing). If this property is specified, it overrides the CSS flood-color attribute

.element { flood-color: lch(20 40 40); } lighting-color

Specifies the color of the lighting source to use for <feDiffuseLighting> and <feSpecularLighting> elements inside the <filter> element for <svg>.

.element { lighting-color: lch(40 10 20); } outline-color

Sets the color of an element’s outline.

.element { outline-color: lch(20 40 40); } stop-color

Specifies the color of gradient stops for the <stop> tags for <svg>.

.element { stop-color: lch(20 40 40); } stroke

Defines the color of the outline of <svg>.

.element { stroke: lch(20 40 40); } text-decoration-color

Sets the color of text decoration lines like underlines.

.element { text-decoration-color: lch(20 40 40); } text-emphasis-color

Specifies the color of emphasis marks on text.

.element { text-emphasis-color: lch(70 20 40); } text-shadow

Applies shadow effects to text, including color.

.element { text-shadow: 1px 1px 1px lch(50 10 30); } Almanac references Color functions Almanac on Feb 22, 2025 rgb() .element { color: rgb(0 0 0 / 0.5); } Sunkanmi Fafowora Almanac on Feb 22, 2025 hsl() .element { color: hsl(90deg, 50%, 50%); } Sunkanmi Fafowora Almanac on Jun 12, 2025 hwb() .element { color: hwb(136 40% 15%); } Gabriel Shoyombo Almanac on Mar 4, 2025 lab() .element { color: lab(50% 50% 50% / 0.5); } Sunkanmi Fafowora Almanac on Mar 12, 2025 lch() .element { color: lch(10% 0.215 15deg); } Sunkanmi Fafowora Almanac on Apr 29, 2025 oklab() .element { color: oklab(25.77% 25.77% 54.88%; } Sunkanmi Fafowora Almanac on May 10, 2025 oklch() .element { color: oklch(70% 0.15 240); } Gabriel Shoyombo Almanac on May 2, 2025 color() .element { color: color(rec2020 0.5 0.15 0.115 / 0.5); } Sunkanmi Fafowora Color properties Almanac on Apr 19, 2025 accent-color .element { accent-color: #f8a100; } Geoff Graham Almanac on Jan 13, 2025 background-color .element { background-color: #ff7a18; } Chris Coyier Almanac on Jan 27, 2021 caret-color .element { caret-color: red; } Chris Coyier Almanac on Jul 11, 2022 color .element { color: #f8a100; } Sara Cope Almanac on Jul 11, 2022 column-rule-color .element { column-rule-color: #f8a100; } Geoff Graham Almanac on Jan 27, 2025 fill .element { fill: red; } Geoff Graham Almanac on Jul 11, 2022 outline-color .element { outline-color: #f8a100; } Mojtaba Seyedi Almanac on Dec 15, 2024 stroke .module { stroke: black; } Geoff Graham Almanac on Aug 2, 2021 text-decoration-color .element { text-decoration-color: orange; } Marie Mosley Almanac on Jan 27, 2023 text-emphasis .element { text-emphasis: circle red; } Joel Olawanle Almanac on Jan 27, 2023 text-shadow p { text-shadow: 1px 1px 1px #000; } Sara Cope Related articles & tutorials Article on Aug 12, 2024 Working With Colors Guide Sarah Drasner Article on Aug 23, 2022 The Expanding Gamut of Color on the Web Ollie Williams Article on Oct 13, 2015 The Tragicomic History of CSS Color Names Geoff Graham Article on Feb 11, 2022 A Whistle-Stop Tour of 4 New CSS Color Features Chris Coyier Article on Feb 7, 2022 Using Different Color Spaces for Non-Boring Gradients Chris Coyier Article on Oct 29, 2024 Come to the light-dark() Side Sara Joy Article on Sep 24, 2024 Color Mixing With Animation Composition Geoff Graham Article on Sep 13, 2016 8-Digit Hex Codes? Chris Coyier Article on Feb 24, 2021 A DRY Approach to Color Themes in CSS Christopher Kirk-Nielsen Article on Apr 6, 2017 Accessibility Basics: Testing Your Page For Color Blindness Chris Coyier Article on Mar 9, 2020 Adventures in CSS Semi-Transparency Land Ana Tudor Article on Mar 4, 2017 Change Color of All Four Borders Even With `border-collapse: collapse;` Daniel Jauch Article on Jan 2, 2020 Color contrast accessibility tools Robin Rendle Article on Aug 14, 2019 Contextual Utility Classes for Color with Custom Properties Christopher Kirk-Nielsen Article on Jun 26, 2021 Creating Color Themes With Custom Properties, HSL, and a Little calc() Dieter Raber Article on May 4, 2021 Creating Colorful, Smart Shadows Chris Coyier Article on Feb 21, 2018 CSS Basics: Using Fallback Colors Chris Coyier Article on Oct 21, 2019 Designing accessible color systems Robin Rendle Article on Jun 22, 2021 Mixing Colors in Pure CSS Carter Li Article on Jul 26, 2016 Overriding The Default Text Selection Color With CSS Chris Coyier Article on Oct 21, 2015 Reverse Text Color Based on Background Color Automatically in CSS Robin Rendle Article on Dec 27, 2019 So Many Color Links Chris Coyier Article on Aug 18, 2018 Switch font color for different backgrounds with CSS Facundo Corradini Article on Jan 20, 2020 The Best Color Functions in CSS? Chris Coyier Article on Dec 3, 2021 What do you name color variables? Chris Coyier Article on May 8, 2025 Why is Nobody Using the hwb() Color Function? Sunkanmi Fafowora Table of contents
  1. Colors are in everything
  2. What’s a color space?
  3. The sRGB Color Space
  4. The CIELAB Color Space
  5. The OkLab Color Space
  6. The color() function
  7. The color-mix() function
  8. The Relative Color Syntax
  9. Color gradients
  10. Properties that support color values
  11. Almanac references
  12. Related articles and tutorials

CSS Color Functions originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

How to Keep Up With New CSS Features

Css Tricks - Tue, 06/17/2025 - 3:13am

How do you keep up with new CSS features?

Let’s say for example that, hypothetically speaking, you run a popular web development survey focused on CSS, and need to figure out what to include in this year’s edition. (In a total coincidence the aforementioned State of CSS survey for this year is actually open right now — go take it to see what’s new in CSS!)

You might think you can just type “new CSS features 2025” in Google and be done with it. But while this does give us a few promising leads, it also unearths a lot of cookie-cutter content that proclaims CSS Grid as the “next big thing”, despite the fact it’s been well-supported for over eight years now. 

We need a better approach. 

I’ll focus on CSS in this article, but all the resources linked here cover all web platform features, including JavaScript and HTML.

Browser blogs

The browsers themselves are often a good source of what’s new and, thankfully, the big ones maintain blogs where they even cover specific CSS news.

A good general starting point is Google’s web.dev blog, and more specifically Rachel Andrew‘s monthly web platform recaps. Here’s a small sample of those:

The WebKit blog is, of course, another great place. Jen Simmons is very active on the blog (as well as Bluesky) and there’s an entire category dedicated solely to CSS. The blog doesn’t publish content as regularly as web.dev, but the breadth of content is incredibly deep and thorough, as you can see in Jen’s write-up on the release of text-wrap: pretty.

And, to round things out, you’ll want to keep an eye on Firefox Nightly News for what’s shipping in Firefox, and the official blog for Microsoft Edge.

CSS-Tricks (and others)

I’d be remiss to not mention that CSS-Tricks is also a great source for up-to-date CSS knowledge, including an ever-growing almanac of CSS features. But you probably already know that since you’re reading this.

And let’s not discount other fine publications that cover CSS. Here are just a few:

Following individual sources can get a little overwhelming, particularly when CSS is moving as fast as it is. That’s where Frontend Dogma comes in with an ever-growing and updated list of curated links from these (and many other sources) in a one-stop shop.

Web Platform Features Explorer

If you need something a bit more structured to help you figure out what’s new, Web Platform Features Explorer is great way to look up features based on their Baseline status.

Web Platform Status

A similar tool is the Web Platform Status dashboard. This one features more fine-grained filtering tools, letting you narrow down features by Baseline year or even show features mentioned as Top CSS Interop in the latest State of CSS survey!

Another very cool feature is the ability to view a feature’s adoption rate, as measured in terms of percentage of Chrome page views where that feature was used, such as here for the popover HTML attribute:

An important caveat: since sites like Facebook and Google account for a very large percentage of all measured page views, this metric can become skewed once one of these platforms adopts a new feature.

The Web Platform Status’s stats section also features the “chart of shame” (according to Lea Verou), which highlights how certain browsers might be slightly lagging behind their peers in terms of new feature adoption.

Chrome Platform Status

That same adoption data can also be found on the Chrome Platform Status dashboard, which gives you even more details, such as usage among top sites, as well as sample URLs of sites that are using a feature. 

Polypane Experimental Chromium Features Dashboard

Polypane is a great developer-focused browser that provides a ton of useful tools like contrast checkers, multi-viewport views, and more. 

They also provide an experimental Chromium features explorer that breaks new features down by Chromium version, for those of you who want to be at the absolute top of the cutting edge. 

Kevin Powell’s YouTube Channel

As YouTube’s de facto CSS expert, Kevin Powell often puts up great video recaps of new features. You should definitely be following him, but statistically speaking you probably already are! It’s also worth mentioning that Kevin runs a site that publishes weekly HTML and CSS tips.

CSS Working Group

Of course, you can always also go straight to the source and look at what the CSS Working Group itself has been working on! They have a mailing list you can subscribe to keep tabs on things straight from your inbox, as well as an RSS feed.

Browser release notes

Most browsers publish a set of release notes any time a new version ships. For the most part, you can get a good pulse on when new CSS features are released by following the three big names in browsers:

ChatGPT

Another way to catch up with CSS is to just ask ChatGPT! This sample prompt worked well enough for me:

What are the latest CSS features that have either become supported by browsers in the past year, or will soon become supported? 

Other resources

If you really want to get in the weeds, Igalia’s BCD Watch displays changes to MDN’s browser-compat-data repo, which itself tracks which features are supported in which browsers. 

Also, the latest editions of the HTTP Archive Web Almanac do not seem to include a CSS section specifically, but past editions did feature one, which was a great way to catch up with CSS once a year. 

There’s also caniuse has a news section which does not seem to be frequently updated at the moment, but could potentially become a great resource for up-to-date new feature info in the future.

The IntentToShip bot (available on Bluesky, Mastodon, Twitter) posts whenever a browser vendor ships or changes a feature. You can’t get more cutting-edge than that!

And lastly, there’s a ton of folks on social media who are frequently discussing new CSS features and sharing their own thoughts and experiments with them. If you’re on Bluesky, there’s a starter pack of CSS-Tricks authors that’s a good spot to find a deep community of people.

Wrapping up

Of course, another great way to make sure no new features are slipping through the cracks is to take the State of CSS survey once a year. I use all the resources mentioned above to try and make sure each survey includes every new important feature. What’s more, you can bookmark features by adding them to your “reading list” as you take the survey to get a nice recap at the end.

So go take this year’s State of CSS survey and then let me know on Bluesky how many new features you learned about!

How to Keep Up With New CSS Features originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

A Better API for the Resize Observer

Css Tricks - Mon, 06/16/2025 - 2:47am

Resize Observer, Mutation Observer, and Intersection Observers are all good APIs that are more performant than their older counterparts:

The API for these three observers are quite similar (but they have their differences which we will go into later). To use an observer, you have to follow the steps below:

  1. Create a new observer with the new keyword: This observer takes in an observer function to execute.
  2. Do something with the observed changes: This is done via the observer function that is passed into the observer.
  3. Observe a specific element: By using the observe method.
  4. (Optionally) unobserve the element: By using the unobserve or disconnect method. (depending on which observer you’re using).

In practice, the above steps looks like this with the ResizeObserver.

// Step 1: Create a new observer const observer = new ResizeObserver(observerFn) // Step 2: Do something with the observed changes function observerFn (entries) { for (let entry of entries) { // Do something with entry } } // Step 3: Observe an element const element = document.querySelector('#some-element') observer.observe(element); // Step 4 (optional): Disconnect the observer observer.disconnect(element)

This looks clear (and understandable) after the steps have been made clear. But it can look like a mess without the comments:

const observer = new ResizeObserver(observerFn) function observerFn (entries) { for (let entry of entries) { // Do something with entry } } const element = document.querySelector('#some-element') observer.observe(element);

The good news is: I think we can improve the observer APIs and make them easier to use.

The Resize Observer

Let’s start with the ResizeObserver since it’s the simplest of them all. We’ll begin by writing a function that encapsulates the resizeObserver that we create.

function resizeObserver () { // ... Do something }

The easiest way to begin refactoring the ResizeObserver code is to put everything we’ve created into our resizeObserver first.

function resizeObserver () { const observer = new ResizeObserver(observerFn) function observerFn (entries) { for (let entry of entries) { // Do something with entry } } const node = document.querySelector('#some-element') observer.observe(node); }

Next, we can pass the element into the function to make it simpler. When we do this, we can eliminate the document.querySelector line.

function resizeObserver (element) { const observer = new ResizeObserver(observerFn) function observerFn (entries) { for (let entry of entries) { // Do something with entry } } observer.observe(node); }

This makes the function more versatile since we can now pass any element into it.

// Usage of the resizeObserver function const node = document.querySelector('#some-element') const obs = resizeObserver(node)

This is already much easier than writing all of the ResizeObserver code from scratch whenever you wish to use it.

Next, it’s quite obvious that we have to pass in an observer function to the callback. So, we can potentially do this:

// Not great function resizeObserver (node, observerFn) { const observer = new ResizeObserver(observerFn) observer.observe(node); }

Since observerFn is always the same — it loops through the entries and acts on every entry — we could keep the observerFn and pass in a callback to perform tasks when the element is resized.

// Better function resizeObserver (node, callback) { const observer = new ResizeObserver(observerFn) function observerFn (entries) { for (let entry of entries) { callback(entry) } } observer.observe(node); }

To use this, we can pass callback into the resizeObserver — this makes resizeObserver operate somewhat like an event listener which we are already familiar with.

// Usage of the resizeObserver function const node = document.querySelector('#some-element') const obs = resizeObserver(node, entry => { // Do something with each entry })

We can make the callback slightly better by providing both entry and entries. There’s no performance hit for passing an additional variable so there’s no harm providing more flexibility here.

function resizeObserver (element, callback) { const observer = new ResizeObserver(observerFn) function observerFn (entries) { for (let entry of entries) { callback({ entry, entries }) } } observer.observe(element); }

Then we can grab entries in the callback if we need to.

// Usage of the resizeObserver function // ... const obs = resizeObserver(node, ({ entry, entries }) => { // ... })

Next, it makes sense to pass the callback as an option parameter instead of a variable. This will make resizeObserver more consistent with the mutationObserver and intersectionObserver functions that we will create in the next article.

function resizeObserver (element, options = {}) { const { callback } = options const observer = new ResizeObserver(observerFn) function observerFn (entries) { for (let entry of entries) { callback({ entry, entries }) } } observer.observe(element); }

Then we can use resizeObserver like this.

const obs = resizeObserver(node, { callback ({ entry, entries }) { // Do something ... } }) The observer can take in an option too

ResizeObserver‘s observe method can take in an options object that contains one property, box. This determines whether the observer will observe changes to content-box, border-box or device-pixel-content-box.

So, we need to extract these options from the options object and pass them to observe.

function resizeObserver (element, options = {}) { const { callback, ...opts } = options // ... observer.observe(element, opts); } Optional: Event listener pattern

I prefer using callback because it’s quite straightforward. But if you want to use a standard event listener pattern, we can do that, too. The trick here is to emit an event. We’ll call it resize-obs since resize is already taken.

function resizeObserver (element, options = {}) { // ... function observerFn (entries) { for (let entry of entries) { if (callback) callback({ entry, entries }) else { node.dispatchEvent( new CustomEvent('resize-obs', { detail: { entry, entries }, }), ) } } } // ... }

Then we can listen to the resize-obs event, like this:

const obs = resizeObserver(node) node.addEventListener('resize-obs', event => { const { entry, entries } = event.detail })

Again, this is optional.

Unobserving the element

One final step is to allow the user to stop observing the element(s) when observation is no longer required. To do this, we can return two of the observer methods:

  • unobserve: Stops observing one Element
  • disconnect: Stops observing all Elements
function resizeObserver (node, options = {}) { // ... return { unobserve(node) { observer.unobserve(node) }, disconnect() { observer.disconnet() } } }

Both methods do the same thing for what we have built so far since we only allowed resizeObserver to observe one element. So, pick whatever method you prefer to stop observing the element.

const obs = resizeObserver(node, { callback ({ entry, entries }) { // Do something ... } }) // Stops observing all elements obs.disconect()

With this, we’ve completed the creation of a better API for the ResizeObserver — the resizeObserver function.

Code snippet

Here’s the code we’ve wrote for resizeObserver

export function resizeObserver(node, options = {}) { const observer = new ResizeObserver(observerFn) const { callback, ...opts } = options function observerFn(entries) { for (const entry of entries) { // Callback pattern if (callback) callback({ entry, entries, observer }) // Event listener pattern else { node.dispatchEvent( new CustomEvent('resize-obs', { detail: { entry, entries, observer }, }) ) } } } observer.observe(node) return { unobserve(node) { observer.unobserve(node) }, disconnect() { observer.disconnect() } } } Using this in practice via Splendid Labz

Splendid Labz has a utils library that contains an enhanced version of the resizeObserver we made above. You can use it if you wanna use a enhanced observer, or if you don’t want to copy-paste the observer code into your projects.

import { resizeObserver } from '@splendidlabz/utils/dom' const node = document.querySelector('.some-element') const obs = resizeObserver(node, { callback ({ entry, entries }) { /* Do what you want here */ } })

Bonus: The Splendid Labz resizeObserver is capable of observing multiple elements at once. It can also unobserve multiple elements at once.

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) Found this refactoring helpful?

Refactoring is ultra useful (and important) because its a process that lets us create code that’s easy to use or maintain.

If you found this refactoring exercise useful, you might just love how I teach JavaScript to budding developers in my Learn JavaScript course.

In this course, you’ll learn to build 20 real-world components. For each component, we start off simple. Then we add features and you’ll learn to refactor along the way.

That’s it!

Hope you enjoyed this piece and see you in the next one.

A Better API for the Resize Observer originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Breaking Boundaries: Building a Tangram Puzzle With (S)CSS

Css Tricks - Thu, 06/12/2025 - 3:58am

For years, I believed that drag-and-drop games — especially those involving rotation, spatial logic, and puzzle solving — were the exclusive domain of JavaScript. Until one day, I asked AI:

“Is it possible to build a fully interactive Tangram puzzle game using only CSS?”

The answer: “No — not really. You’ll need JavaScript.” That was all the motivation I needed to prove otherwise.

CodePen Embed Fallback

But first, let’s ask the obvious question: Why would anyone do this?

Well…

  • To know how far CSS can be pushed in creating interactive UIs.
  • To get better at my CSS skills.
  • And it’s fun!

Fair enough?

Now, here’s the unsurprising truth: CSS isn’t exactly made for this. It’s not a logic language, and let’s be honest, it’s not particularly dynamic either. (Sure, we have CSS variables and some handy built-in functions now, hooray!)

In JavaScript, we naturally think in terms of functions, loops, conditions, objects, comparisons. We write logic, abstract things into methods, and eventually ship a bundle that the browser understands. And once it’s shipped? We rarely look at that final JavaScript bundle — we just focus on keeping it lean.

Now ask yourself: isn’t that exactly what Sass does for CSS?

Why should we hand-write endless lines of repetitive CSS when we can use mixins and functions to generate it — cleanly, efficiently, and without caring how many lines it takes, as long as the output is optimized?

So, we put it to the test and it turns out Sass can replace JavaScript, at least when it comes to low-level logic and puzzle behavior. With nothing but maps, mixins, functions, and a whole lot of math, we managed to bring our Tangram puzzle to life, no JavaScript required.

Let the (CSS-only) games begin! &#x1f389;

The game

The game consists of seven pieces: the classic Tangram set. Naturally, these pieces can be arranged into a perfect square (and many other shapes, too). But we need a bit more than just static pieces.

So here’s what I am building:

  • A puzzle goal, which is the target shape the player has to recreate.
  • A start button that shuffles all the pieces into a staging area.
  • Each piece is clickable and interactive.
  • The puzzle should let the user know when they get a piece wrong and also celebrate when they finish the puzzle.
The HTML structure

I started by setting up the HTML structure, which is no small task, considering the number of elements involved.

  • Each shape was given seven radio buttons. I chose radios over checkboxes to take advantage of their built-in exclusivity. Only one can be selected within the same group. This made it much easier to track which shape and state were currently active.
  • The start button? Also a radio input. A checkbox could’ve worked too, but for the sake of consistency, I stuck with radios across the board.
  • The puzzle map itself is just a plain old <div>, simple and effective.
  • For rotation, we added eight radio buttons, each representing a 45-degree increment: 45°, 90°, 135°, all the way to 360°. These simulate rotation controls entirely in CSS.
  • Every potential shadow position got its own radio button too. (Yes, it’s a lot, I know.)
  • And to wrap it all up, I included a classic reset button inside a <form> using <button type="reset">, so players can easily start over at any point.

Given the sheer number of elements required, I used Pug to generate the HTML more efficiently. It was purely a convenience choice. It doesn’t affect the logic or behavior of the puzzle in any way.

Below is a sample of the compiled HTML. It might look overwhelming at first glance (and this is just a portion of it!), but it illustrates the structural complexity involved. This section is collapsed to not nuke your screen, but it can be expanded if you’d like to explore it.

Open HTML Code <div class="wrapper"> <div class="tanagram-box"></div> <div class="tanagram-box"></div> <form class="container"> <input class="hide_input start" type="checkbox" id="start" autofocus /> <button class="start-button" type="reset" id="restart">Restart</button> <label class="start-button" for="start">Start </label> <div class="shadow"> <input class="hide_input" type="radio" id="blueTriangle-tan" name="tan-active" /> <input class="hide_input" type="radio" id="yellowTriangle-tan" name="tan-active" /> <!-- Inputs for others tans --> <input class="hide_input" type="radio" id="rotation-reset" name="tan-active" /> <input class="hide_input" type="radio" id="rotation-45" name="tan-rotation" /> <input class="hide_input" type="radio" id="rotation-90" name="tan-rotation" /> <!--radios for 90, 225, 315, 360 --> <input class="hide_input" type="checkbox" id="yellowTriangle-tan-1-135" name="tan-rotation" /> <input class="hide_input" type="checkbox" id="yellowTriangle-tan-1-225" name="tan-rotation" /> <!-- radio for every possible shape shadows--> <label class="rotation rot" for="rotation-45" id="rot45">⟲</label> <label class="rotation rot" for="rotation-90" id="rot90">⟲</label> <!--radios for 90, 225, 315, 360 --> <label class="rotation" for="rotation-reset" id="rotReset">✘</label> <label class="blueTriangle tans" for="blueTriangle-tan" id="tanblueTrianglelab"></label> <div class="tans tan_blocked" id="tanblueTrianglelabRes"></div> <!-- labels for every tan and disabled div --> <label class="blueTriangle tans" for="blueTriangle-tan-1-90" id="tanblueTrianglelab-1-90"></label> <label class="blueTriangle tans" for="blueTriangle-tan-1-225" id="tanblueTrianglelab-1-225"></label> <!-- labels radio for every possible shape shadows--> <div class="shape"></div> </div> </form> <div class="tanagram-box"></div> <div class="tanagram-box"></div> <div class="tanagram-box"></div> <div class="tanagram-box"></div> <div class="tanagram-box"></div> </div> Creating maps for shape data

Now that HTML skeleton is ready, it’s time to inject it with some real power. That’s where our Sass maps come in, and here’s where the puzzle logic starts to shine.

Note: Maps in Sass hold pairs of keys and values, and make it easy to look up a value by its corresponding key. Like objects in JavaScript, dictionaries in Python and, well, maps in C++.

I’m mapping out all the core data needed to control each tangram piece (tan): its color, shape, position, and even interaction logic. These maps contain:

  • the background-color for each tan,
  • the clip-path coordinates that define their shapes,
  • the initial position for each tan,
  • the position of the blocking div (which disables interaction when a tan is selected),
  • the shadow positions (coordinates for the tan’s silhouette displayed on the task board),
  • the grid information, and
  • the winning combinations — the exact target coordinates for each tan, marking the correct solution.
$colors: ( blue-color: #53a0e0, yellow-color: #f7db4f, /* Colors for each tan */ ); $nth-child-grid: ( 1: (2, 3, 1, 2, ), 2: ( 3, 4, 1, 2, ), 4: ( 1, 2, 2, 3, ), /* More entries to be added */); $bluePosiblePositions: ( 45: none, 90: ( (6.7, 11.2), ), 135: none, 180: none, /* Positions defined up to 360 degrees */); /* Other tans */ /* Data defined for each tan */ $tansShapes: ( blueTriangle: ( color: map.get($colors, blue-color), clip-path: ( 0 0, 50 50, 0 100, ), rot-btn-position: ( -20, -25, ), exit-mode-btn-position: ( -20, -33, ), tan-position: ( -6, -37, ), diable-lab-position: ( -12, -38, ), poss-positions: $bluePosiblePositions, correct-position: ((4.7, 13.5), (18.8, 13.3), ), transform-origin: ( 4.17, 12.5,), ), ); /* Remaining 7 combinations */ $winningCombinations: ( combo1: ( (blueTriangle, 1, 360), (yellowTriangle, 1, 225), (pinkTriangle, 1, 180), (redTriangle, 4, 360), (purpleTriangle, 2, 225), (square, 1, 90), (polygon, 4, 90), ), );

You can see this in action on CodePen, where these maps drive the actual look and behavior of each puzzle piece. At this point, there’s no visible change in the preview. We’ve simply prepared and stored the data for later use.

CodePen Embed Fallback Using mixins to read from maps

The main idea is to create reusable mixins that will read data from the maps and apply it to the corresponding CSS rules when needed.

But before that, we’ve elevated things to a higher level by making one key decision: We never hard-coded units directly inside the maps. Instead, we built a reusable utility function that dynamically adds the desired unit (e.g., vmin, px, etc.) to any numeric value when it’s being used. This way, when can use our maps however we please.

@function get-coordinates($data, $key, $separator, $unit) { $coordinates: null; // Check if the first argument is a map @if meta.type-of($data) == "map" { // If the map contains the specified key @if map.has-key($data, $key) { // Get the value associated with the key (expected to be a list of coordinates) $coordinates: map.get($data, $key); } // If the first argument is a list } @else if meta.type-of($data) == "list" { // Ensure the key is a valid index (1-based) within the list @if meta.type-of($key) == "number" and $key > 0 and $key <= list.length($data) { // Retrieve the item at the specified index $coordinates: list.nth($data, $key); } // If neither map nor list, throw an error } @else { @error "Invalid input: First argument must be a map or a list."; } // If no valid coordinates were found, return null @if $coordinates == null { @return null; } // Extract x and y values from the list $x: list.nth($coordinates, 1); $y: list.nth($coordinates, -1); // -1 gets the last item (y) // Return the combined x and y values with units and separator @return #{$x}#{$unit}#{$separator}#{$y}#{$unit}; }

Sure, nothing’s showing up in the preview yet, but the real magic starts now.

CodePen Embed Fallback

Now we move on to writing mixins. I’ll explain the approach in detail for the first mixin, and the rest will be described through comments.

The first mixin dynamically applies grid-column and grid-row placement rules to child elements based on values stored in a map. Each entry in the map corresponds to an element index (1 through 8) and contains a list of four values: [start-col, end-col, start-row, end-row].

@mixin tanagram-grid-positioning($nth-child-grid) { // Loop through numbers 1 to 8, corresponding to the tanam pieces @for $i from 1 through 8 { // Check if the map contains a key for the current piece (1-8) @if map.has-key($nth-child-grid, $i) { // Get the grid values for this piece: [start-column, end-column, start-row, end-row] $values: map.get($nth-child-grid, $i); // Target the nth child (piece) and set its grid positions &:nth-child(#{$i}) { // Set grid-column: start and end values based on the first two items in the list grid-column: #{list.nth($values, 1)} / #{list.nth($values, 2)}; // Set grid-row: start and end values based on the last two items in the list grid-row: #{list.nth($values, 3)} / #{list.nth($values, 4)}; } } } }

We can expect the following CSS to be generated:

.tanagram-box:nth-child(1) { grid-column: 2 / 3; grid-row: 1 / 2; } .tanagram-box:nth-child(2) { grid-column: 3 / 4; grid-row: 1 / 2; } CodePen Embed Fallback

In this mixin, my goal was actually to create all the shapes (tans). I am using clip-path. There were ideas to use fancy SVG images, but this test project is more about testing the logic rather than focusing on beautiful design. For this reason, the simplest solution was to cut the elements according to dimensions while they are still in the square (the initial position of all the tans).

So, in this case, through a static calculation, the $tansShapes map was updated with the clip-path property:

clip-path: (0 0, 50 50, 0 100);

This contains the clip points for all the tans. In essence, this mixin shapes and colors each tan accordingly.

@mixin set-tan-clip-path($tanName, $values) { // Initialize an empty list to hold the final clip-path points $clip-path-points: (); // Extract the 'clip-path' data from the map, which contains coordinate pairs $clip-path-key: map.get($values, clip-path); // Get the number of coordinate pairs to loop through $count: list.length($clip-path-key); // Loop through each coordinate point @for $i from 1 through $count { // Convert each pair of numbers into a formatted coordinate string with units $current-point: get-coordinates($clip-path-key, $i, " ", "%"); // Add the formatted coordinate to the list, separating each point with a comma $clip-path-points: list.append($clip-path-points, #{$current-point}, comma); } // Style for the preview element (lab version), using the configured background color #tan#{$tanName}lab { background: map.get($values, color); clip-path: polygon(#{$clip-path-points}); // Apply the full list of clip-path points } // Apply the same clip-path to the actual tan element .#{$tanName} { clip-path: polygon(#{$clip-path-points}); } }

and output in CSS should be:

.blueTriangle { clip-path: polygon(0% 0%, 50% 50%, 0% 100%); } /* other tans */ CodePen Embed Fallback Start logic

Alright, now I’d like to clarify what should happen first when the game loads.

First, with a click on the Start button, all the tans “go to their positions.” In reality, we assign them a transform: translate() with specific coordinates and a rotation.

.start:checked ~ .shadow #tanblueTrianglelab { transform-origin: 4.17vmin 12.5vmin; transform: translate(-6vmin,-37vmin) rotate(360deg); cursor: pointer; } CodePen Embed Fallback

So, we still maintain this pattern. We use transform and simply change the positions or angles (in the maps) of both the tans and their shadows on the task board.

When any tan is clicked, the rotation button appears. By clicking on it, the tan should rotate around its center, and this continues with each subsequent click. There are actually eight radio buttons, and with each click, one disappears and the next one appears. When we reach the last one, clicking it makes it disappear and the first one reappears. This way, we get the impression of clicking the same button (they are, of course, styled the same) and being able to click (rotate the tan) infinitely. This is exactly what the following mixin enables.

@mixin set-tan-rotation-states($tanName, $values, $angles, $color) { // This mixin dynamically applies rotation UI styles based on a tan's configuration. // It controls the positioning and appearance of rotation buttons and visual feedback when a rotation state is active. @each $angle in $angles{ & ~ #rot#{$angle}{ transform: translate(get-coordinates($values,rot-btn-position,',',vmin )); background: $color;} & ~ #rotation-#{$angle}:checked{ @each $key in map.keys($tansShapes){ & ~ #tan#{$key}labRes{ visibility: visible; background:rgba(0,0,0,0.4); } & ~ #tan#{$key}lab{ opacity:.3; } & ~ #rotReset{ visibility: visible; } } } } }

And the generated CSS should be:

#blueTriangle-tan:checked ~ #rotation-45:checked ~ #tanblueTrianglelab { transform: translate(-6vmin,-37vmin) rotate(45deg); } #blueTriangle-tan:checked ~ #rotation-45:checked ~ #tanblueTrianglelabRes { visibility: hidden; }

OK, the following mixins use the set-clip-path and set-rotation mixins. They contain all the information about the tans and their behavior in relation to which tan is clicked and which rotation is selected, as well as their positions (as defined in the second mixin).

@mixin generate-tan-shapes-and-interactions($tansShapes) { // Applies styling logic and UI interactions for each individual tan shape from the $tansShapes map. @each $tanName, $values in $tansShapes{ $color: color.scale(map.get($values, color), $lightness: 10%); $angles: (45, 90, 135, 180, 225, 270, 315, 360); @include set-tan-clip-path($tanName, $values); ##{$tanName}-tan:checked{ & ~ #tan#{$tanName}Res{ visibility:hidden; } & ~ #tan#{$tanName}lab{opacity: 1 !important;background: #{$color};cursor:auto;} @each $key in map.keys($tansShapes){ & ~ #tan#{$tanName}Res:checked ~ #tan#{$key}labRes{visibility: visible;} } & ~ #rot45{display: flex;visibility: visible;} & ~ #rotReset{ transform: translate(get-coordinates($values, exit-mode-btn-position,',', vmin)); } @include set-tan-rotation-states($tanName, $values, $angles, $color); } } } @mixin set-initial-tan-position($tansShapes) { // This mixin sets the initial position and transformation for both the interactive (`lab`) and shadow (`labRes`) versions // of each tan shape, based on coordinates provided in the $tansShapes map. @each $tanName, $values in $tansShapes{ & ~ .shadow #tan#{$tanName}lab{ transform-origin: get-coordinates($values, transform-origin,' ' ,vmin); transform: translate( get-coordinates($values,tan-position,',', vmin)) rotate(360deg) ; cursor: pointer; } & ~ .shadow #tan#{$tanName}labRes{ visibility:hidden; transform: translate(get-coordinates($values,diable-lab-position,',',vmin)); } } } CodePen Embed Fallback

As mentioned earlier, when a tan is clicked, one of the things that becomes visible is its shadow — a silhouette that appears on the task board.

These shadow positions (coordinates) are currently defined statically. Each shadow has a specific place on the map, and a mixin reads this data and applies it to the shadow using transform: translate().

When the clicked tan is rotated, the number of visible shadows on the task board can change, as well as their angles, which is expected.

Of course, special care was taken with naming conventions. Each shadow element gets a unique ID, made from the name (inherited from its parent tan) and a number that represents its sequence position for the given angle.

Pretty cool, right? That way, we avoid complicated naming patterns entirely!

@mixin render-possible-tan-positions( $name, $angle, $possiblePositions, $visibility, $color, $id, $transformOrigin ) { // This mixin generates styles for possible positions of a tan shape based on its name, rotation angle, and configuration map. // It handles both squares and polygons, normalizing their rotation angles accordingly and applying transform styles if positions exist.} @if $name == 'square' { $angle: normalize-angle($angle); // Normalizujemo ugao ako je u pitanju square } @else if $name == 'polygon'{ $angle: normalize-polygon-angle($angle); } @if map.has-key($possiblePositions, $angle) { $values: map.get($possiblePositions, $angle); @if $values != none { $count: list.length($values); @for $i from 1 through $count { $position: get-coordinates($values, $i, ',', vmin); & ~ #tan#{$name}lab-#{$i}-#{$angle} { @if $visibility == visible { visibility: visible; background-color: $color; opacity: .2; z-index: 2; transform-origin: #{$transformOrigin}; transform: translate(#{$position}) rotate(#{$angle}deg); } @else if $visibility == hidden { visibility: hidden; } &:hover{ opacity: 0.5; cursor: pointer; } } } } } }

The generated CSS:

#blueTriangle-tan:checked ~ #tanblueTrianglelab-1-360 { visibility: visible; background-color: #53a0e0; opacity: 0.2; z-index: 2; transform-origin: 4.17vmin 12.5vmin; transform: translate(4.7vmin,13.5vmin) rotate(360deg); }

This next mixin is tied to the previous one and manages when and how the tan shadows appear while their parent tan is being rotated using the button. It listens for the current rotation angle and checks whether there are any shadow positions defined for that specific angle. If there are, it displays them; if not — no shadows!

@mixin render-possible-positions-by-rotation { // This mixin applies rotation to each tan shape. It loops through each tan, calculates its possible positions for each angle, and handles visibility and transformation. // It ensures that rotation is applied correctly, including handling the transitions between various tan positions and visibility states. @each $tanName, $values in $tansShapes{ $possiblePositions: map.get($values, poss-positions); $possibleTansColor: map.get($values, color); $validPosition: get-coordinates($values, correct-position,',' ,vmin); $transformOrigin: get-coordinates($values,transform-origin,' ' ,vmin); $rotResPosition: get-coordinates($values,exit-mode-btn-position ,',' ,vmin ); $angle: 0; @for $i from 1 through 8{ $angle: $i * 45; $nextAngle: if($angle + 45 > 360, 45, $angle + 45); @include render-position-feedback-on-task($tanName,$angle, $possiblePositions,$possibleTansColor, #{$tanName}-tan, $validPosition,$transformOrigin, $rotResPosition); ##{$tanName}-tan{ @include render-possible-tan-positions($tanName,$angle, $possiblePositions,hidden, $possibleTansColor, #{$tanName}-tan,$transformOrigin) } ##{$tanName}-tan:checked{ @include render-possible-tan-positions($tanName,360, $possiblePositions,visible, $possibleTansColor, #{$tanName}-tan,$transformOrigin); & ~ #rotation-#{$angle}:checked { @include render-possible-tan-positions($tanName,360, $possiblePositions,hidden, $possibleTansColor, #{$tanName}-tan,$transformOrigin); & ~ #tan#{$tanName}lab{transform:translate( get-coordinates($values,tan-position,',', vmin)) rotate(#{$angle}deg) ;} & ~ #tan#{$tanName}labRes{ visibility: hidden; } & ~ #rot#{$angle}{ visibility: hidden; } & ~ #rot#{$nextAngle}{ visibility: visible } @include render-possible-tan-positions($tanName,$angle, $possiblePositions,visible, $possibleTansColor, #{$tanName}-tan,$transformOrigin); } } } } } CodePen Embed Fallback

When a tan’s shadow is clicked, the corresponding tan should move to that shadow’s position. The next mixin then checks whether this new position is the correct one for solving the puzzle. If it is correct, the tan gets a brief blinking effect and becomes unclickable, signaling it’s been placed correctly. If it’s not correct, the tan simply stays at the shadow’s location. There’s no effect and it remains draggable/clickable.

CodePen Embed Fallback

Of course, there’s a list of all the correct positions for each tan. Since some tans share the same size — and some can even combine to form larger, existing shapes — we have multiple valid combinations. For this Camel task, all of them were taken into account. A dedicated map with these combinations was created, along with a mixin that reads and applies them.

CodePen Embed Fallback

At the end of the game, when all tans are placed in their correct positions, we trigger a “merging” effect — and the silhouette of the camel turns yellow. At that point, the only remaining action is to click the Restart button.

Well, that was long, but that’s what you get when you pick the fun (albeit hard and lengthy) path. All as an ode to CSS-only magic!

Breaking Boundaries: Building a Tangram Puzzle With (S)CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Creating an Auto-Closing Notification With an HTML Popover

Css Tricks - Mon, 06/09/2025 - 2:58am

The HTML popover attribute transforms elements into top-layer elements that can be opened and closed with a button or JavaScript. Most popovers can be light-dismissed, closing when the user clicks or taps outside the popup. Currently, HTML popover lacks built-in auto-close functionality, but it’s easy to add. Auto closing popups are useful for user interfaces like banner notifications — the new-message alerts in phones, for instance.

A picture demo, is worth a thousand words, right? Click on the “Add to my bookmarks” button in the following example. It triggers a notification that dismisses itself after a set amount of time.

CodePen Embed Fallback Let’s start with the popover

The HTML popover attribute is remarkably trivial to use. Slap it on a div, specify the type of popover you need, and you’re done.

<div popover="manual" id="pop">Bookmarked!</div>

A manual popover simply means it cannot be light-dismissed by clicking outside the element. As a result, we have to hide, show, or toggle the popover’s visibility ourselves explicitly with either buttons or JavaScript. Let’s use a semantic HTML button.

<button popovertarget="pop" popovertargetaction="show"> Add to my bookmarks </button> <div popover="manual" id="pop">Bookmarked!</div>

The popovertarget and popovertargetaction attributes are the final two ingredients, where popovertarget links the button to the popover element and popovertargetaction ensures that the popover is show-n when the button is clicked.

Hiding the popover with a CSS transition

OK, so the challenge is that we have a popover that is shown when a certain button is clicked, but it cannot be dismissed. The button is only wired up to show the popover, but it does not hide or toggle the popover (since we are not explicitly declaring it). We want the popover to show when the button is clicked, then dismiss itself after a certain amount of time.

The HTML popover can’t be closed with CSS, but it can be hidden from the page. Adding animation to that creates a visual effect. In our example, we will hide the popover by eliminating its CSS height property. You’ll learn in a moment why we’re using height, and that there are other ways you can go about it.

We can indeed select the popover attribute using an attribute selector:

[popover] { height: 0; transition: height cubic-bezier(0.6, -0.28, 0.735, 0.045) .3s .6s; @starting-style { height: 1lh; } }

When the popover is triggered by the button, its height value is the one declared in the @starting-style ruleset (1lh). After the transition-delay (which is .6s in the example), the height goes from 1lh to 0 in .3s, effectively hiding the popover.

Once again, this is only hiding the popover, not closing it properly. That’s the next challenge and we’ll need JavaScript for that level of interaction.

Closing the popover with JavaScript

We can start by setting a variable that selects the popover:

const POPOVER = document.querySelector('[popover]');

Next, we can establish a ResizeObserver that monitors the popover’s size:

const POPOVER = document.querySelector('[popover]'); const OBSERVER = new ResizeObserver((entries) => { if(entries[0].contentBoxSize[0].blockSize == 0) OBSERVER.unobserve((POPOVER.hidePopover(), POPOVER)); });

And we can fire that off starting when the button to show the popover is clicked:

const POPOVER = document.querySelector('[popover]'); const OBSERVER = new ResizeObserver((entries) => { if(entries[0].contentBoxSize[0].blockSize == 0) OBSERVER.unobserve((POPOVER.hidePopover(), POPOVER)); }); document.querySelector('button').onclick = () => OBSERVER.observe(POPOVER);

The observer will know when the popover’s CSS height reaches zero at the end of the transition, and, at that point, the popover is closed with hidePopover(). From there, the observer is stopped with unobserve().

In our example, height and ResizeObserver are used to auto-close the notification. You can try any other CSS property and JavaScript observer combination that might work with your preference. Learning about ResizeObserver and MutationObserver can help you find some options.

Setting an HTML fallback

When JavaScript is disabled in the browser, if the popover type is set to any of the light-dismissible types, it acts as a fallback. Keep the popover visible by overriding the style rules that hide it. The user can dismiss it by clicking or tapping anywhere outside the element.

If the popover needs to be light-dismissible only when JavaScript is disabled, then include that popover inside a <noscript> element before the manual popover. It’s the same process as before, where you override CSS styles as needed.

<noscript> <div popover="auto" id="pop">Bookmarked!</div> </noscript> <div popover="manual" id="pop">Bookmarked!</div> <!-- goes where <head> element's descendants go --> <noscript> <style> [popover] { transition: none; height: 1lh; } </style> </noscript> When to use this method?

Another way to implement all of this would be to use setTimeout() to create a delay before closing the popover in JavaScript when the button is clicked, then adding a class to the popover element to trigger the transition effect. That way, no observer is needed.

With the method covered in this post, the delay can be set and triggered in CSS itself, thanks to @starting-style and transition-delay — no extra class required! If you prefer to implement the delay through CSS itself, then this method works best. The JavaScript will catch up to the change CSS makes at the time CSS defines, not the other way around.

Creating an Auto-Closing Notification With an HTML Popover originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Agent Management Interface Patterns

LukeW - Sun, 06/08/2025 - 2:00pm

As an increasing number of AI applications evolve to agents doing work for people, agent management becomes a critical part of these product's design. How can people start, steer, and stop multiple agents (and subagents) and stay on top of their results? Here's several approaches we've been building and testing.

Whenever a new technology emerges, user interfaces go through a balancing act between making the new technology approachable through common patterns and embodying what makes it unique. Make things too different and risk not having an onramp that brings people on board smoothly. Make things too familiar and risk limiting the potential of new capabilities within old models and interactions.

"Copy, extend, and finally, discovery of a new form. It takes a while to shed old paradigms." - Scott Jenson

As an example, Apple's VisionOS interface notably made use of many desktop and mobile interaction patterns to smooth the transition to spatial computing. But at the same time, they didn't take full advantage of spatial computing's opportunities by boxing limitless 3D interactions within the windows, icons, and menus, and pointers (WIMP) familiar to desktop interfaces.

Hence, the balancing act.

This context helps frame the way we've approached designing agent management interfaces. Are there high level user interface patterns that are both familiar enough for people to intuit how they work and flexible enough to enable effective AI agent management at a high level? In an agent-centric AI application like Augment Code for software development or Bench for office productivity, people need to be able to:

  • Start new agents through a combination of instructions and context (files, connections, etc.)
  • Schedule agents to run at certain times or under certain conditions.
  • Scrutinize the work of agents to asses whether or not they're making the right kind of progress.
  • Steer agents when they go off course, require clarification, or uncover something that suggests they should take a different path.
  • Stop agents when they've either done enough or are no longer being effective.
  • See, share, and save the results or processes of agents.

To help people adapt to agent management, we explored how interface patterns like kanban boards, dashboards, inboxes, tasks lists and calendars could fulfill many of these requirements by presenting the state of multiple agents and allowing people to access specific agents when they need to take further action.

Kanban Board

Kanban boards visualize work as cards moving through distinct stages, typically arranged in columns from left to right to represent progress through a workflow. They could be used to organize agents as they transition between scheduled, running, complete, and reviewed states. Or within workflows specific to domains like sales or engineering.

This pattern seems like a straightforward way to give people a sense of the state of multiple agents. But in kanban boards, people also expect to be able to move items between cards. How that would affect agents? Would they begin a new task defined by the card? Would that create a new agent or re-route an existing one?

Dashboard

Dashboards pull together multiple data sources into a unified monitoring interface through different visualizations like charts, graphs, and metrics. Unlike a kanban board, there's no workflow implied by the arrangement of the elements in a dashboard so you can pretty much represent agents anywhere and any way you like.

While that seems appealing, especially to those yearning for a "mission control" style interface to manage agents, it can quickly become problematic. When agents can be represented in different ways in different parts of a UI, it's hard to grasp both the big picture and details of what's happening.

Inbox

The inbox pattern organizes items in a chronological stream that requires user action to process. Items are listed from newest to oldest with visual cues like unread counts so people can quickly assess and act on items without losing context. Most of us do so every day in our messaging and email apps so applying the same model to agents seems natural.

But if you get too much email or too many texts, your inbox can get away from you. So it's not an ideal pattern for applications with a high volume of agents to manage nor for those that require coordination of multiple, potentially inter-dependent agents.

For what it's worth, this where we iterated to (for now) in Bench. So if you'd like to try this pattern out, fire off a few agents there.

Task List

Task lists present items as discrete, actionable units with clear completion states (usually a checkbox). Their vertical stack format lets people focus on specific tasks while still seeing the bigger picture. Task lists can be highly structured or pretty ad hoc lists of random to-dos.

Indented lists of subtasks can also display parallel agent processes and show the inter-dependencies of agents but perhaps at the expense of simplicity. In a single linear list, like an Inbox, its much easier to see what's happening than in a hierarchical task list where some subtasks may be collapsed but relevant.

Calendar

Calendar interfaces use a grid structure that maps to our understanding of time, with consistent rows and columns representing dates and times. This allows people to make use of both temporal memory and spatial memory to locate and contextualize items. Calendars also typically provide high level (month) and detailed (day) views of what's happening.

When it comes to scheduling agents, a calendar makes a lot of sense: just add it the same way you'd add a meeting. It's also helpful for contextually grouping the work of agents with actual meetings. "These tasks were all part of this project's brainstorm meeting." "I ran that task right after our one-on-one meeting." Representing the work of agents on a calendar can be tricky, though, as agents can run for minutes or many hours. And where should event-triggered agents should up on a calendar?

Coming back to Scott Jenson's quote at the start of this article, it takes a while to discover new paradigms and discover new forms. So it's quite likely as these interface patterns are adapted to agent management use cases, they'll evolve further and not end up looking much like their current selves. As David Hoang recently suggested, maybe agent management interfaces should learn from patterns found in Real-Time Strategy (RTS) games instead? Interesting...

Better CSS Shapes Using shape() — Part 3: Curves

Css Tricks - Fri, 06/06/2025 - 3:52am

If you’re following along, this is the third post in a series about the new CSS shape() function. We’ve learned how to draw lines and arcs and, in this third part, I will introduce the curve command — the missing command you need to know to have full control over the shape() function. In reality, there are more commands, but you will rarely need them and you can easily learn about them later by checking the documentation.

Better CSS Shapes Using shape()
  1. Lines and Arcs
  2. More on Arcs
  3. Curves (you are here!)
  4. Close and Move
The curve command

This command adds a Bézier curve between two points by specifying control points. We can either have one control point and create a Quadratic curve or two control points and create a Cubic curve.

Bézier, Quadratic, Cubic, control points? What?!

For many of you, that definition is simply unclear, or even useless! You can spend a few minutes reading about Bézier curves but is it really worth it? Probably not, unless your job is to create shapes all the day and you have a solid background in geometry.

We already have cubic-bezier() as an easing function for animations but, honestly, who really understands how it works? We either rely on a generator to get the code or we read a “boring” explanation that we forget in two minutes. (I have one right here by the way!)

Don’t worry, this article will not be boring as I will mostly focus on practical examples and more precisely the use case of rounding the corners of irregular shapes. Here is a figure to illustrate a few examples of Bézier curves.

The blue dots are the starting and ending points (let’s call them A and B) and the black dots are the control points. And notice how the curve is tangent to the dashed lines illustrated in red.

In this article, I will consider only one control point. The syntax will follow this pattern:

clip-path: shape( from Xa Ya, curve to Xb Yb with Xc Yc ); arc command vs. curve command

We already saw in Part 1 and Part 2 that the arc command is useful establishing rounded edges and corners, but it will not cover all the cases. That’s why you will need the curve command. The tricky part is to know when to use each one and the answer is “it depends.” There is no generic rule but my advice is to first see if it’s possible (and easy) using arc. If not, then you have to use curve.

For some shapes, we can have the same result using both commands and this is a good starting point for us to understand the curve command and compare it with arc.

Take the following example:

CodePen Embed Fallback

This is the code for the first shape:

.shape { clip-path: shape(from 0 0, arc to 100% 100% of 100% cw, line to 0 100%) }

And for the second one, we have this:

.shape { clip-path: shape(from 0 0, curve to 100% 100% with 100% 0, line to 0 100%) }

The arc command needs a radius (100% in this case), but the curve command needs a control point (which is 100% 0 in this example).

Now, if you look closely, you will notice that both results aren’t exactly the same. The first shape using the arc command is creating a quarter of a circle, whereas the shape using the curve command is slightly different. If you place both of them above each other, you can clearly see the difference.

CodePen Embed Fallback

This is interesting because it means we can round some corners using either an arc or a curve, but with slightly different results. Which one is better, you ask? I would say it depends on your visual preference and the shape you are creating.

In Part 1, we created rounded tabs using the arc command, but we can also create them with curve.

CodePen Embed Fallback

Can you spot the difference? It’s barely visible but it’s there.

Notice how I am using the by directive the same way I am doing with arc, but this time we have the control point, which is also relative. This part can be confusing, so pay close attention to this next bit.

Consider the following:

shape(from Xa Ya, curve by Xb Yb with Xc Yc)

It means that both (Xb,Yb) and (Xc,Yc) are relative coordinates calculated from the coordinate of the starting point. The equivalent of the above using a to directive is this:

shape(from Xa Ya, curve to (Xa + Xb) (Ya + Yb) with (Xa + Xc) (Yb + Yc))

We can change the reference of the control point by adding a from directive. We can either use start (the default value), end, or origin.

shape(from Xa Ya, curve by Xb Yb with Xc Yc from end)

The above means that the control point will now consider the ending point instead of the starting point. The result is similar to:

shape(from Xa Ya, curve to (Xa + Xb) (Ya + Yb) with (Xa + Xb + Xc) (Ya + Yb + Yc))

If you use origin, the reference will be the origin, hence the coordinate of the control point becomes absolute instead of relative.

The from directive may add some complexity to the code and the calculation, so don’t bother yourself with it. Simply know it exists in case you face it, but keep using the default value.

I think it’s time for your first homework! Similar to the rounded tab exercise, try to create the inverted radius shape we covered in the Part 1 using curve instead of arc. Here are both versions for you to reference, but try to do it without peeking first, if you can.

CodePen Embed Fallback Let’s draw more shapes!

Now that we have a good overview of the curve command, let’s consider more complex shapes where arc won’t help us round the corners and the only solution is to draw curves instead. Considering that each shape is unique, so I will focus on the technique rather than the code itself.

Slanted edge

Let’s start with a rectangular shape with a slanted edge.

Getting the shape on the left is quite simple, but the shape on the right is a bit tricky. We can round two corners with a simple border-radius, but for the slanted edge, we will use shape() and two curve commands.

The first step is to write the code of the shape without rounded corners (the left one) which is pretty straightforward since we’re only working with the line command:

.shape { --s: 90px; /* slant size */ clip-path: shape(from 0 0, line to calc(100% - var(--s)) 0, line to 100% 100%, line to 0 100% ); }

Then we take each corner and try to round it by modifying the code. Here is a figure to illustrate the technique I am going to use for each corner.

We define a distance, R, that controls the radius. From each side of the corner point, I move by that distance to create two new points, which are illustrated above in red. Then, I draw my curve using the new points as starting and ending points. The corner point will be the control point.

The code becomes:

.shape { --s: 90px; /* slant size */ clip-path: shape(from 0 0, Line to Xa Ya, curve to Xb Yb with calc(100% - var(--s)) 0, line to 100% 100%, line to 0 100% ); }

Notice how the curve is using the coordinates of the corner point in the with directive, and we have two new points, A and B.

Until now, the technique is not that complex. For each corner point, you replace the line command with line + curve commands where the curve command reuses the old point in its with directive.

If we apply the same logic to the other corner, we get the following:

.shape { --s: 90px; /* slant size */ clip-path: shape(from 0 0, line to Xa Ya, curve to Xb Yb with calc(100% - var(--s)) 0, line to Xc Yc, curve to Xd Yd with 100% 100%, line to 0 100% ); }

Now we need to calculate the coordinates of the new points. And here comes the tricky part because it’s not always simple and it may require some complex calculation. Even if I detail this case, the logic won’t be the same for the other shapes we’re making, so I will skip the math part and give you the final code:

.box { --h: 200px; /* element height */ --s: 90px; /* slant size */ --r: 20px; /* radius */ height: var(--h); border-radius: var(--r) 0 0 var(--r); --_a: atan2(var(--s), var(--h)); clip-path: shape(from 0 0, line to calc(100% - var(--s) - var(--r)) 0, curve by calc(var(--r) * (1 + sin(var(--_a)))) calc(var(--r) * cos(var(--_a))) with var(--r) 0, line to calc(100% - var(--r) * sin(var(--_a))) calc(100% - var(--r) * cos(var(--_a))), curve to calc(100% - var(--r)) 100% with 100% 100%, line to 0 100% ); }

I know the code looks a bit scary, but the good news is that the code is also really easy to control using CSS variables. So, even if the math is not easy to grasp, you don’t have to deal with it. It should be noted that I need to know the height to be able to calculate the coordinates which means the solution isn’t perfect because the height is a fixed value.

CodePen Embed Fallback Arrow-shaped box

Here’s a similar shape, but this time we have three corners to round using the curve command.

CodePen Embed Fallback

The final code is still complex but I followed the same steps. I started with this:

.shape { --s: 90px; clip-path: shape(from 0 0, /* corner #1 */ line to calc(100% - var(--s)) 0, /* corner #2 */ line to 100% 50%, /* corner #3 */ line to calc(100% - var(--s)) 100%, line to 0 100% ); }

Then, I modified it into this:

.shape { --s: 90px; clip-path: shape(from 0 0, /* corner #1 */ line to Xa Ya curve to Xb Yb with calc(100% - var(--s)) 0, /* corner #2 */ line to Xa Ya curve to Xb Yb with 100% 50%, /* corner #3 */ line to Xa Yb curve to Xb Yb with calc(100% - var(--s)) 100%, line to 0 100% ); }

Lastly, I use a pen and paper to do all the calculations.

You might think this technique is useless if you are not good with math and geometry, right? Not really, because you can still grab the code and use it easily since it’s optimized using CSS variables. Plus, you aren’t obligated to be super accurate and precise. You can rely on the above technique and use trial and error to approximate the coordinates. It will probably take you less time than doing all the math.

Rounded polygons

I know you are waiting for this, right? Thanks to the new shape() and the curve command, we can now have rounded polygon shapes!

Here is my implementation using Sass where you can control the radius, number of sides and the rotation of the shape:

CodePen Embed Fallback

If we omit the complex geometry part, the loop is quite simple as it relies on the same technique with a line + curve per corner.

$n: 9; /* number of sides*/ $r: .2; /* control the radius [0 1] */ $a: 15deg; /* control the rotation */ .poly { aspect-ratio: 1; $m: (); @for $i from 0 through ($n - 1) { $m: append($m, line to Xai Yai, comma); $m: append($m, curve to Xbi Ybi with Xci Yci, comma); } clip-path: shape(#{$m}); }

Here is another implementation where I define the variables in CSS instead of Sass:

CodePen Embed Fallback

Having the variables in CSS is pretty handy especially if you want to have some animations. Here is an example of a cool hover effect applied to hexagon shapes:

CodePen Embed Fallback

I have also updated my online generator to add the radius parameter. If you are not familiar with Sass, you can easily copy the CSS code from there. You will also find the border-only and cut-out versions!

Conclusion

Are we done with the curve command? Probably not, but we have a good overview of its potential and all the complex shapes we can build with it. As for the code, I know that we have reached a level that is not easy for everyone. I could have extended the explanation by explicitly breaking down the math, but then this article would be overly complex and make it seem like using shape() is harder than it is.

This said, most of the shapes I code are available within my online collection that I constantly update and optimize so you can easily grab the code of any shape!

If you want a good follow-up to this article, I wrote an article for Frontend Masters where you can create blob shapes using the curve command.

Better CSS Shapes Using shape()
  1. Lines and Arcs
  2. More on Arcs
  3. Curves (you are here!)
  4. Close and Move

Better CSS Shapes Using shape() — Part 3: Curves originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Exploring the CSS contrast-color() Function… a Second Time

Css Tricks - Thu, 06/05/2025 - 3:45am

In many countries, web accessibility is a human right and the law, and there can be heavy fines for non-compliance. Naturally, this means that text and icons and such must have optimal color contrast in accordance with the benchmarks set by the Web Content Accessibility Guidelines (WCAG). Now, there are quite a few color contrast checkers out there (Figma even has one built-in now), but the upcoming contrast-color() function doesn’t check color contrast, it outright resolves to either black or white (whichever one contrasts the most with your chosen color).

Right off the bat, you should know that we’ve sorta looked at this feature before. Back then, however, it was called color-contrast() instead of contrast-color() and had a much more convoluted way of going about things. It was only released in Safari Technology Preview 122 back in 2021, and that’s still the case at the time I’m writing this (now at version 220).

You’d use it like this:

button { --background-color: darkblue; background-color: var(--background-color); color: contrast-color(var(--background-color)); } CodePen Embed Fallback

Here, contrast-color() has determined that white contrasts with darkblue better than black does, which is why contrast-color() resolves to white. Pretty simple, really, but there are a few shortcomings, which includes a lack of browser support (again, it’s only in Safari Technology Preview at the moment).

We can use contrast-color() conditionally, though:

@supports (color: contrast-color(red)) { /* contrast-color() supported */ } @supports not (color: contrast-color(red)) { /* contrast-color() not supported */ } The shortcomings of contrast-color()

First, let me just say that improvements are already being considered, so here I’ll explain the shortcomings as well as any improvements that I’ve heard about.

Undoubtedly, the number one shortcoming is that contrast-color() only resolves to either black or white. If you don’t want black or white, well… that sucks. However, the draft spec itself alludes to more control over the resolved color in the future.

But there’s one other thing that’s surprisingly easy to overlook. What happens when neither black nor white is actually accessible against the chosen color? That’s right, it’s possible for contrast-color() to just… not provide a contrasting color. Ideally, I think we’d want contrast-color() to resolve to the closest accessible variant of a preferred color. Until then, contrast-color() isn’t really usable.

Another shortcoming of contrast-color() is that it only accepts arguments of the <color> data type, so it’s just not going to work with images or anything like that. I did, however, manage to make it “work” with a gradient (basically, two instances of contrast-color() for two color stops/one linear gradient):

CodePen Embed Fallback <button> <span>A button</span> </button> button { background: linear-gradient(to right, red, blue); span { background: linear-gradient(to right, contrast-color(red), contrast-color(blue)); color: transparent; background-clip: text; } }

The reason this looks so horrid is that, as mentioned before, contrast-color() only resolves to black or white, so in the middle of the gradient we essentially have 50% grey on purple. This problem would also get solved by contrast-color() resolving to a wider spectrum of colors.

But what about the font size? As you might know already, the criteria for color contrast depends on the font size, so how does that work? Well, at the moment it doesn’t, but I think it’s safe to assume that it’ll eventually take the font-size into account when determining the resolved color. Which brings us to APCA.

APCA (Accessible Perceptual Contrast Algorithm) is a new algorithm for measuring color contrast reliably. Andrew Somers, creator of APCA, conducted studies (alongside many other independent studies) and learned that 23% of WCAG 2 “Fails” are actually accessible. In addition, an insane 47% of “Passes” are inaccessible.

Not only should APCA do a better job, but the APCA Readability Criterion (ARC) is far more nuanced, taking into account a much wider spectrum of font sizes and weights (hooray for me, as I’m very partial to 600 as a standard font weight). While the criterion is expectedly complex and unnecessarily confusing, the APCA Contrast Calculator does a decent-enough job of explaining how it all works visually, for now.

contrast-color() doesn’t use APCA, but the draft spec does allude to offering more algorithms in the future. This wording is odd as it suggests that we’ll be able to choose between the APCA and WCAG algorithms. Then again, we have to remember that the laws of some countries will require WCAG 2 compliance while others require WCAG 3 compliance (when it becomes a standard).

That’s right, we’re a long way off of APCA becoming a part of WCAG 3, let alone contrast-color(). In fact, it might not even be a part of it initially (or at all), and there are many more hurdles after that, but hopefully this sheds some light on the whole thing. For now, contrast-color() is using WCAG 2 only.

Using contrast-color()

Here’s a simple example (the same one from earlier) of a darkblue-colored button with accessibly-colored text chosen by contrast-color(). I’ve put this darkblue color into a CSS variable so that we can define it once but reference it as many times as is necessary (which is just twice for now).

button { --background-color: darkblue; background-color: var(--background-color); /* Resolves to white */ color: contrast-color(var(--background-color)); }

And the same thing but with lightblue:

button { --background-color: lightblue; background-color: var(--background-color); /* Resolves to black */ color: contrast-color(var(--background-color)); }

First of all, we can absolutely switch this up and use contrast-color() on the background-color property instead (or in-place of any <color>, in fact, like on a border):

button { --color: darkblue; color: var(--color); /* Resolves to white */ background-color: contrast-color(var(--color)); }

Any valid <color> will work (named, HEX, RGB, HSL, HWB, etc.):

button { /* HSL this time */ --background-color: hsl(0 0% 0%); background-color: var(--background-color); /* Resolves to white */ color: contrast-color(var(--background-color)); }

Need to change the base color on the fly (e.g., on hover)? Easy:

button { --background-color: hsl(0 0% 0%); background-color: var(--background-color); /* Starts off white, becomes black on hover */ color: contrast-color(var(--background-color)); &:hover { /* 50% lighter */ --background-color: hsl(0 0% 50%); } } CodePen Embed Fallback

Similarly, we could use contrast-color() with the light-dark() function to ensure accessible color contrast across light and dark modes:

:root { /* Dark mode if checked */ &:has(input[type="checkbox"]:checked) { color-scheme: dark; } /* Light mode if not checked */ &:not(:has(input[type="checkbox"]:checked)) { color-scheme: light; } body { /* Different background for each mode */ background: light-dark(hsl(0 0% 50%), hsl(0 0% 0%)); /* Different contrasted color for each mode */ color: light-dark(contrast-color(hsl(0 0% 50%)), contrast-color(hsl(0 0% 0%)); } } CodePen Embed Fallback

The interesting thing about APCA is that it accounts for the discrepancies between light mode and dark mode contrast, whereas the current WCAG algorithm often evaluates dark mode contrast inaccurately. This one nuance of many is why we need not only a new color contrast algorithm but also the contrast-color() CSS function to handle all of these nuances (font size, font weight, etc.) for us.

This doesn’t mean that contrast-color() has to ensure accessibility at the expense of our “designed” colors, though. Instead, we can use contrast-color() within the prefers-contrast: more media query only:

button { --background-color: hsl(270 100% 50%); background-color: var(--background-color); /* Almost white (WCAG AA: Fail) */ color: hsl(270 100% 90%); @media (prefers-contrast: more) { /* Resolves to white (WCAG AA: Pass) */ color: contrast-color(var(--background-color)); } }

Personally, I’m not keen on prefers-contrast: more as a progressive enhancement. Great color contrast benefits everyone, and besides, we can’t be sure that those who need more contrast are actually set up for it. Perhaps they’re using a brand new computer, or they just don’t know how to customize accessibility settings.

Closing thoughts

So, contrast-color() obviously isn’t useful in its current form as it only resolves to black or white, which might not be accessible. However, if it were improved to resolve to a wider spectrum of colors, that’d be awesome. Even better, if it were to upgrade colors to a certain standard (e.g., WCAG AA) if they don’t already meet it, but let them be if they do. Sort of like a failsafe approach? This means that web browsers would have to take the font size, font weight, element, and so on into account.

To throw another option out there, there’s also the approach that Windows takes for its High Contrast Mode. This mode triggers web browsers to overwrite colors using the forced-colors: active media query, which we can also use to make further customizations. However, this effect is quite extreme (even though we can opt out of it using the forced-colors-adjust CSS property and use our own colors instead) and macOS’s version of the feature doesn’t extend to the web.

I think that forced colors is an incredible idea as long as users can set their contrast preferences when they set up their computer or browser (the browser would be more enforceable), and there are a wider range of contrast options. And then if you, as a designer or developer, don’t like the enforced colors, then you have the option to meet accessibility standards so that they don’t get enforced. In my opinion, this approach is the most user-friendly and the most developer-friendly (assuming that you care about accessibility). For complete flexibility, there could be a CSS property for opting out, or something. Just color contrast by default, but you can keep the colors you’ve chosen as long as they’re accessible.

What do you think? Is contrast-color() the right approach, or should the user agent bear some or all of the responsibility? Or perhaps you’re happy for color contrast to be considered manually?

Exploring the CSS contrast-color() Function… a Second Time originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Syndicate content
©2003 - Present Akamai Design & Development.