Super intereseting information! A footnote about it
In a previous article, I showed you how to refactor the Resize Observer API into something way simpler to use:
// From thisconst 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 ObserverMutationObserver 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 patternnode.addEventListener('mutate', event => { const { entry } = event.detail // Do what you want with each entry})
Much easier!
Disconnecting the observerUnlike 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 optionsIn 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 nodeschildList: Monitors for addition or removal children elements. If subtree is true, this monitors all descendant elements.attributes: Monitors for a change of attributesattributeFilter: Array of specific attributes to monitorattributeOldValue: Whether to record the previous attribute value if it was changedcharacterData: Monitors for change in character datacharacterDataOldValue: Whether to record the previous character data valueRefactoring Intersection ObserverThe API for IntersectionObserver is similar to other observers. Again, you have to:
new keyword. This observer takes in an observer function to execute.observe method.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 optionsconst options = {/*...*/}const observer = new IntersectionObserver(observerFn, options)// Step 2: Do something with the observed changesfunction observerFn (entries) { for (const entry of entries) { // Do something with entry }}// Step 3: Observe the elementconst element = document.querySelector('#some-element')observer.observe(element)// Step 4 (optional): Disconnect the observer when we're done using itobserver.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 patternnode.addEventListener('intersect', event => { const { entry } = event.detail // Do what you want with each entry})
Disconnecting the Intersection ObserverIntersectionObserver‘s methods are a union of both resizeObserver and mutationObserver. It has four methods:
observe: observe an elementunobserve: stops observing one elementdisconnect: stops observing all elementstakeRecords: gets unprocessed recordsSo, 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 observerobs.disconnect()
IntersectionObserver optionsIn case you were wondering, IntersectionObserver takes in three options:
root: The element used to check if observed elements are visiblerootMargin: Lets you specify an offset amount from the edges of the rootthreshold: Determines when to log an observer entryHere’s an article to help you understand IntersectionObserver options.
Using this in practice via Splendid LabzSplendid 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 onceconst subset = [items[0], items[1]]obs.unobserve(subset)
So it might be just a tad easier to use the functions I’ve already created for you. 😉
Shameless Plug: Splendid Labz contains a ton of useful utilities — for CSS, JavaScript, Astro, and Svelte — that I have created over the last few years.
I’ve parked them all in into Splendid Labz, so I no longer need to scour the internet for useful functions for most of my web projects. If you take a look, you might just enjoy what I’ve complied!
(I’m still making the docs at the time of writing so it can seem relatively empty. Check back every now and then!)
Learning to refactor stuffIf 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.
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:
color()hsl()lab()lch()oklab()oklch()rgb()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 SpacesThe 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 SpacesThe 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 StoneIn 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 TeamNotice 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 SpacesAs 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 FallbackCreated 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 TechnologyIt 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 OttossonAs 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 ModelsWhen 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 GamutsA 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 FunctionsIn 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:
rgb(), rgba(), hsl(), hsla() and hwb() functions.lab() for Cartesian coordinates and lch() for polar coordinates.oklab() for Cartesian coordinates and oklch() for polar coordinates.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;DR1. 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* Completely Painless Programmer’s Guide to XYZ, RGB, ICC, xyY, and TRCs (Elle Stone) * Color Spaces (Bartosz Ciechanowski) * The CIE XYZ and xyY Color Spaces(Douglas A. Kerr) * From personal project to industry standard (Björn Ottosson) * High definition CSS color guide (Adam Argyle) * Color Spaces: Explained from the Ground Up (Video Tech Explained) * Color Space (MDN) * What Makes a Color Space Well Behaved? (Elle Stone)
Footnotes1 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.
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 everythingThey 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 SpaceThe 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:
/).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.
.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.
.element { color: #ABCD2;}
For the alpha value, 0 represents 00 (a fully transparent color) and F represents FF (a fully opaque color).
.element { color: #abcd; /* Same as #AABBCCDD */}
* 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.
0deg to 360deg.0 (or 0%) to 100 (or 100%).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:
/).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:
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 FallbackNamed colorsCSS 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 SpaceThe 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’saandbvalues 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.
l represents the degree of whiteness to blackness of the color. Its range being 0 (or 0%) (black) to 100 (or 100%) (white).c represents the color’s chroma (which is like saturation). Its range being from 0 (or 100%) to 150 or (or 100%).h represents the color hue. The value’s range is also from 0 (or 0deg) to 360 (or 360deg).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 SpaceBjö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 FallbackNotice 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’saandbvalues 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() functionThe 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:
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-d65c1, c2, and c3) specifies the coordinates in the color space for the color ranging from 0.0 – 1.0.0.0 (or 0%) to 1.0 (or 100%).CodePen Embed Fallback
The color-mix() functionThe 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:
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.0% to 100%.CodePen Embed Fallback
The Relative Color SyntaxHere’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 gradientsCSS 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 valuesThere are a lot of properties that support the use of color. Just so you know, this list does not contain deprecated properties.
accent-colorThis 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-colorApplies solid colors as background on an element.
.element { background-color: #ff7a18;}
border-colorShorthand 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-shadowAdds 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-colorSpecifies the color of the text input cursor (caret).
.element { caret-color: lch(30 40 40);}
colorSets the foreground color of text and text decorations.
.element { color: lch(80 10 20);}
column-rule-colorSets 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 */}
fillSets the color of the SVG shape
.element { fill: lch(40 20 10);}
flood-colorSpecifies 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-colorSpecifies 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-colorSets the color of an element’s outline.
.element { outline-color: lch(20 40 40);}
stop-colorSpecifies the color of gradient stops for the <stop> tags for <svg>.
.element { stop-color: lch(20 40 40);}
strokeDefines the color of the outline of <svg>.
.element { stroke: lch(20 40 40);}
text-decoration-colorSets the color of text decoration lines like underlines.
.element { text-decoration-color: lch(20 40 40);}
text-emphasis-colorSpecifies the color of emphasis marks on text.
.element { text-emphasis-color: lch(70 20 40);}
text-shadowApplies shadow effects to text, including color.
.element { text-shadow: 1px 1px 1px lch(50 10 30);}
Almanac referencesColor functions Almanac on Feb 22, 2025 rgb() .element { color: rgb(0 0 0 / 0.5); } color Sunkanmi Fafowora Almanac on Feb 22, 2025 hsl() .element { color: hsl(90deg, 50%, 50%); } color Sunkanmi Fafowora Almanac on Jun 12, 2025 hwb() .element { color: hwb(136 40% 15%); } color Gabriel Shoyombo Almanac on Mar 4, 2025 lab() .element { color: lab(50% 50% 50% / 0.5); } color Sunkanmi Fafowora Almanac on Mar 12, 2025 lch() .element { color: lch(10% 0.215 15deg); } color Sunkanmi Fafowora Almanac on Apr 29, 2025 oklab() .element { color: oklab(25.77% 25.77% 54.88%; } color Sunkanmi Fafowora Almanac on May 10, 2025 oklch() .element { color: oklch(70% 0.15 240); } color Gabriel Shoyombo Almanac on May 2, 2025 color() .element { color: color(rec2020 0.5 0.15 0.115 / 0.5); } color Sunkanmi Fafowora Color properties Almanac on Apr 19, 2025 accent-color .element { accent-color: #f8a100; } color Geoff Graham Almanac on Jan 13, 2025 background-color .element { background-color: #ff7a18; } color Chris Coyier Almanac on Jan 27, 2021 caret-color .element { caret-color: red; } color Chris Coyier Almanac on Jul 11, 2022 color .element { color: #f8a100; } color Sara Cope Almanac on Jul 11, 2022 column-rule-color .element { column-rule-color: #f8a100; } color Geoff Graham Almanac on Jan 27, 2025 fill .element { fill: red; } color Geoff Graham Almanac on Jul 11, 2022 outline-color .element { outline-color: #f8a100; } color Mojtaba Seyedi Almanac on Dec 15, 2024 stroke .module { stroke: black;} color Geoff Graham Almanac on Aug 2, 2021 text-decoration-color .element { text-decoration-color: orange; } color Marie Mosley Almanac on Jan 27, 2023 text-emphasis .element { text-emphasis: circle red; } color Joel Olawanle Almanac on Jan 27, 2023 text-shadow p { text-shadow: 1px 1px 1px #000; } color Sara Cope
Related articles & tutorials Article on Aug 12, 2024 Working With Colors Guide color Sarah Drasner Article on Aug 23, 2022 The Expanding Gamut of Color on the Web color Ollie Williams Article on Oct 13, 2015 The Tragicomic History of CSS Color Names color Geoff Graham Article on Feb 11, 2022 A Whistle-Stop Tour of 4 New CSS Color Features color Chris Coyier Article on Feb 7, 2022 Using Different Color Spaces for Non-Boring Gradients color Chris Coyier Article on Oct 29, 2024 Come to the light-dark() Side color Sara Joy Article on Sep 24, 2024 Color Mixing With Animation Composition color Geoff Graham Article on Sep 13, 2016 8-Digit Hex Codes? color Chris Coyier Article on Feb 24, 2021 A DRY Approach to Color Themes in CSS color Christopher Kirk-Nielsen Article on Apr 6, 2017 Accessibility Basics: Testing Your Page For Color Blindness color Chris Coyier Article on Mar 9, 2020 Adventures in CSS Semi-Transparency Land color Ana Tudor Article on Mar 4, 2017 Change Color of All Four Borders Even With border-collapse: collapse; color Daniel Jauch Article on Jan 2, 2020 Color contrast accessibility tools color Robin Rendle Article on Aug 14, 2019 Contextual Utility Classes for Color with Custom Properties color Christopher Kirk-Nielsen Article on Jun 26, 2021 Creating Color Themes With Custom Properties, HSL, and a Little calc() color Dieter Raber Article on May 4, 2021 Creating Colorful, Smart Shadows color Chris Coyier Article on Feb 21, 2018 CSS Basics: Using Fallback Colors color Chris Coyier Article on Oct 21, 2019 Designing accessible color systems color Robin Rendle Article on Jun 22, 2021 Mixing Colors in Pure CSS color Carter Li Article on Jul 26, 2016 Overriding The Default Text Selection Color With CSS color Chris Coyier Article on Oct 21, 2015 Reverse Text Color Based on Background Color Automatically in CSS color Robin Rendle Article on Dec 27, 2019 So Many Color Links color Chris Coyier Article on Aug 18, 2018 Switch font color for different backgrounds with CSS color Facundo Corradini Article on Jan 20, 2020 The Best Color Functions in CSS? color Chris Coyier Article on Dec 3, 2021 What do you name color variables? color Chris Coyier Article on May 8, 2025 Why is Nobody Using the hwb() Color Function? color 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 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 blogsThe 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 ExplorerIf 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 StatusA 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 StatusThat 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 DashboardPolypane 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 ChannelAs 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 GroupOf 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 notesMost 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:
ChatGPTAnother 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 resourcesIf 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 upOf 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.
Resize Observer, Mutation Observer, and Intersection Observers are all good APIs that are more performant than their older counterparts:
ResizeObserver is better than the resize eventMutationObserver replaces the now deprecated Mutation EventsIntersectionObserver lets you do certain scroll interactions with less performance overhead.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:
new keyword: This observer takes in an observer function to execute.observe method.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 observerconst observer = new ResizeObserver(observerFn)// Step 2: Do something with the observed changesfunction observerFn (entries) { for (let entry of entries) { // Do something with entry }}// Step 3: Observe an elementconst element = document.querySelector('#some-element')observer.observe(element);// Step 4 (optional): Disconnect the observerobserver.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 ObserverLet’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 functionconst 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 greatfunction 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 functionconst 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 tooResizeObserver‘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 patternI 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 elementOne 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 Elementdisconnect: Stops observing all Elementsfunction 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 snippetHere’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 LabzSplendid 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 onceconst 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.
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 FallbackBut first, let’s ask the obvious question: Why would anyone do this?
Well…
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! 🎉
The gameThe 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:
The HTML structureI started by setting up the HTML structure, which is no small task, considering the number of elements involved.
<div>, simple and effective.<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
```
``` Creating maps for shape dataNow 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:
background-color for each tan,clip-path coordinates that define their shapes,div (which disables interaction when a tan is selected),$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 FallbackUsing mixins to read from mapsThe 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 FallbackNow 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 FallbackIn 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 FallbackStart logicAlright, 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 FallbackSo, 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:
```
``
OK, the following mixins use theset-clip-pathandset-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 FallbackAs 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:
```
``` 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 FallbackWhen 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 FallbackOf 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 FallbackAt 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.
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 FallbackLet’s start with the popoverThe HTML popover attribute is remarkably trivial to use. Slap it on a div, specify the type of popover you need, and you’re done.
```
``
Amanualpopover 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 HTMLbutton`.
<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 transitionOK, 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 JavaScriptWe 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 fallbackWhen 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.
```
``
When to use this method?Another way to implement all of this would be to usesetTimeout()to create a delay before closing the popover in JavaScript when the button is clicked, then adding a class to thepopover` 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.
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!)
The curve commandThis 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 commandWe 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 FallbackThis 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 FallbackThis 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 FallbackCan 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 FallbackLet’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 edgeLet’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 FallbackArrow-shaped boxHere’s a similar shape, but this time we have three corners to round using the curve command.
CodePen Embed FallbackThe 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 polygonsI 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 FallbackIf 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 FallbackHaving 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 FallbackI 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!
ConclusionAre 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!)
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.
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 FallbackHere, 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 FallbackSimilarly, 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 FallbackThe 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 thoughtsSo, 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.
The State of CSS 2025 Survey dropped a few days ago, and besides waiting for the results, it’s exciting to see a lot of the new things shipped to CSS over the past year reflected in the questions. To be specific, the next survey covers the following features:
calc-size()shape()text-box-edge and text-box-trimfield-sizing::target-text@functiondisplay: contentsattr()if()sibling-index() and sibling-count()Again, a lot!
However, I think the most important questions (regarding CSS) are asked at the end of each section. I am talking about the “What are your top CSS pain points related to ______?” questions. These sections are optional, but help user agents and the CSS Working Group know what they should focus on next.
By nature of comments, those respondents with strong opinions are most likely to fill them in, skewing data towards issues that maybe the majority doesn’t have. So, even if you don’t have a hard-set view on a CSS pain point, I encourage you to fill them — even with your mild annoyances.
The State of CSS 2025 Survey is out! originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Like ’em or loath ’em, whether you’re showing an alert, a message, or a newsletter signup, dialogue boxes draw attention to a particular piece of content without sending someone to a different page. In the past, dialogues relied on a mix of divisions, ARIA, and JavaScript. But the HTML dialog element has made them more accessible and style-able in countless ways.
So, how can you take dialogue box design beyond the generic look of frameworks and templates? How can you style them to reflect a brand’s visual identity and help to tell its stories? Here’s how I do it in CSS using ::backdrop, backdrop-filter, and animations.
Design by Andy Clarke, Stuff & Nonsense. Mike Worth’s website will launch in June 2025, but you can see examples from this article on CodePen.I mentioned before that Emmy-award-winning game composer Mike Worth hired me to create a highly graphical design. Mike loves ’90s animation, and he challenged me to find ways to incorporate its retro style without making a pastiche. However, I also needed to achieve that retro feel while maintaining accessibility, performance, responsiveness, and semantics.
A brief overview of dialog and ::backdropLet’s run through a quick refresher.
Note: While I mostly refer to “dialogue boxes” throughout, the HTML element is spelt dialog.
dialog is an HTML element designed for implementing modal and non-modal dialogue boxes in products and website interfaces. It comes with built-in functionality, including closing a box using the keyboard Esc key, focus trapping to keep it inside the box, show and hide methods, and a ::backdrop pseudo-element for styling a box’s overlay.
The HTML markup is just what you might expect:
<dialog> <h2>Keep me informed</h2> <!-- ... --> <button>Close</button></dialog>
This type of dialogue box is hidden by default, but adding the open attribute makes it visible when the page loads:
<dialog open> <h2>Keep me informed</h2> <!-- ... --> <button>Close</button></dialog>
I can’t imagine too many applications for non-modals which are open by default, so ordinarily I need a button which opens a dialogue box:
<dialog> <!-- ... --></dialog><button>Keep me informed</button>
Plus a little bit of JavaScript, which opens the modal:
const dialog = document.querySelector("dialog");const showButton = document.querySelector("dialog + button");showButton.addEventListener("click", () => { dialog.showModal();});
Closing a dialogue box also requires JavaScript:
const closeButton = document.querySelector("dialog button");closeButton.addEventListener("click", () => { dialog.close();});
Unless the box contains a form using method="dialog", which allows it to close automatically on submit without JavaScript:
<dialog> <form method="dialog"> <button>Submit</button> </form></dialog>
The dialog element was developed to be accessible out of the box. It traps focus, supports the Esc key, and behaves like a proper modal. But to help screen readers announce dialogue boxes properly, you’ll want to add an aria-labelledby attribute. This tells assistive technology where to find the dialogue box’s title so it can be read aloud when the modal opens.
<dialog aria-labelledby="dialog-title"> <h2 id="dialog-title">Keep me informed</h2> <!-- ... --></dialog>
Most tutorials I’ve seen include very little styling for dialog and ::backdrop, which might explain why so many dialogue boxes have little more than border radii and a box-shadow applied.
Out-of-the-box dialogue designsI believe that every element in a design — no matter how small or infrequently seen — is an opportunity to present a brand and tell a story about its products or services. I know there are moments during someone’s journey through a design where paying special attention to design can make their experience more memorable.
Dialogue boxes are just one of those moments, and Mike Worth’s design offers plenty of opportunities to reflect his brand or connect directly to someone’s place in Mike’s story. That might be by styling a newsletter sign-up dialogue to match the scrolls in his news section.
Mike Worth concept design, designed by Andy Clarke, Stuff & Nonsense.Or making the form modal on his error pages look like a comic-book speech balloon.
Mike Worth concept design, designed by Andy Clarke, Stuff & Nonsense.dialog in actionMike’s drop-down navigation menu looks like an ancient stone tablet.
Mike Worth, designed by Andy Clarke, Stuff & Nonsense.I wanted to extend this look to his dialogue boxes with a three-dimensional tablet and a jungle leaf-filled backdrop.
Mike Worth, designed by Andy Clarke, Stuff & Nonsense.This dialog contains a newsletter sign-up form with an email input and a submit button:
<dialog> <h2>Keep me informed</h2> <form> <label for="email" data-visibility="hidden">Email address</label> <input type="email" id="email" required> <button>Submit</button> </form> <button>x</button></dialog>
I started by applying dimensions to the dialog and adding the SVG stone tablet background image:
dialog { width: 420px; height: 480px; background-color: transparent; background-image: url("dialog.svg"); background-repeat: no-repeat; background-size: contain;}
Then, I added the leafy green background image to the dialogue box’s generated backdrop using the ::backdrop pseudo element selector:
dialog::backdrop { background-image: url("backdrop.svg"); background-size: cover;}
Mike Worth, designed by Andy Clarke, Stuff & Nonsense.I needed to make it clear to anyone filling in Mike’s form that their email address is in a valid format. So I combined :has and :valid CSS pseudo-class selectors to change the color of the submit button from grey to green:
dialog:has(input:valid) button { background-color: #7e8943; color: #fff;}
I also wanted this interaction to reflect Mike’s fun personality. So, I also changed the dialog background image and applied a rubberband animation to the box when someone inputs a valid email address:
dialog:has(input:valid) { background-image: url("dialog-valid.svg"); animation: rubberBand 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;}@keyframes rubberBand { from { transform: scale3d(1, 1, 1); } 30% { transform: scale3d(1.25, 0.75, 1); } 40% { transform: scale3d(0.75, 1.25, 1); } 50% { transform: scale3d(1.15, 0.85, 1); } 65% { transform: scale3d(0.95, 1.05, 1); } 75% { transform: scale3d(1.05, 0.95, 1); } to { transform: scale3d(1, 1, 1); }}
Tip: Daniel Eden’s Animate.css library is a fabulous source of “Just-add-water CSS animations” like the rubberband I used for this dialogue box.
Changing how an element looks when it contains a valid input is a fabulous way to add interactions that are, at the same time, fun and valuable for the user.
Mike Worth, designed by Andy Clarke, Stuff & Nonsense.That combination of :has and :valid selectors can even be extended to the ::backdrop pseudo-class, to change the backdrop’s background image:
dialog:has(input:valid)::backdrop { background-image: url("backdrop-valid.svg");}
Try it for yourself:
CodePen Embed FallbackConclusionWe often think of dialogue boxes as functional elements, as necessary interruptions, but nothing more. But when you treat them as opportunities for expression, even the smallest parts of a design can help shape a product or website’s personality.
The HTML dialog element, with its built-in behaviours and styling potential, opens up opportunities for branding and creative storytelling. There’s no reason a dialogue box can’t be as distinctive as the rest of your design.
Andy ClarkeOften referred to as one of the pioneers of web design, Andy Clarke has been instrumental in pushing the boundaries of web design and is known for his creative and visually stunning designs. His work has inspired countless designers to explore the full potential of product and website design.
Andy’s written several industry-leading books, including ‘Transcending CSS,’ ‘Hardboiled Web Design,’ and ‘Art Direction for the Web.’ He’s also worked with businesses of all sizes and industries to achieve their goals through design.
Visit Andy’s studio, Stuff & Nonsense, and check out his Contract Killer, the popular web design contract template trusted by thousands of web designers and developers.
Getting Creative With HTML Dialog originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Ready for the second part? We are still exploring the shape() function, and more precisely, the arc command. I hope you took the time to digest the first part because we will jump straight into creating more shapes!
As a reminder, the shape() function is only supported in Chrome 137+ and Safari 18.4+ as I’m writing this in May 2025.
Better CSS Shapes Using shape()1. Lines and Arcs
2. More on Arcs (you are here!)
3. Curves
Sector shapeAnother classic shape that can also be used in pie-like charts.
It’s already clear that we have one arc. As for the points, we have two points that don’t move and one that moves depending on how much the sector is filled.
The code will look like this:
.sector { --v: 35; /* [0 100]*/ aspect-ratio: 1; clip-path: shape(from top, arc to X Y of R, line to center);}
We define a variable that will control the filling of the sector. It has a value between 0 and 100. To draw the shape, we start from the top, create an arc until the point (X, Y), and then we move to the center.
Are we allowed to use keyword values like
topandcenter?
Yes! Unlike the polygon() function, we have keywords for the particular cases such as top, bottom, left, etc. It’s exactly like background-position that way. I don’t think I need to detail this part as it’s trivial, but it’s good to know because it can make your shape a bit easier to read.
The radius of the arc should be equal to 50%. We are working with a square element and the sector, which is a portion of a circle, need to fill the whole element so the radius is equal to half the width (or height).1
As for the point, it’s placed within that circle, and its position depends on the V value. You don’t want a boring math explanation, right? No need for it, here is the formula of X and Y:
X = 50% + 50% * sin(V * 3.6deg)Y = 50% - 50% * cos(V * 3.6deg)
Our code becomes:
.sector { --v: 35; /* [0 100] */ aspect-ratio: 1; clip-path: shape(from top, arc to calc(50% + 50% * sin(var(--v) * 3.6deg)) calc(50% - 50% * cos(var(--v) * 3.6deg)) of 50%, line to center);}
CodePen Embed FallbackHmm, the result is not good, but there are no mistakes in the code. Can you figure out what we are missing?
It’s the size and direction of the arc!
Remember what I told you in the last article? You will always have trouble with them, but if we try the different combinations, we can easily fix the issue. In our case, we need to use: small cw.
CodePen Embed FallbackBetter! Let’s try it with more values and see how the shape behaves:
CodePen Embed FallbackOops, some values are good, but others not so much. The direction needs to be clockwise, but maybe we should use large instead of small? Let’s try:
CodePen Embed FallbackStill not working. The issue here is that we are moving one point of the arc based on the V value, and this movement creates a different configuration for the arc command.
Here is an interactive demo to better visualize what is happening:
CodePen Embed FallbackWhen you update the value, notice how large cw always tries to follow the largest arc between the points, while small cw tries to follow the smallest one. When the value is smaller than 50, small cw gives us a good result. But when it’s bigger than 50, the large cw combination is the good one.
I know, it’s a bit tricky and I wanted to study this particular example to emphasize the fact that we can have a lot of headaches working with arcs. But the more issues we face, the better we get at fixing them.
The solution in this case is pretty simple. We keep the use of large cw and add a border-radius to the element. If you check the previous demo, you will notice that even if large cw is not producing a good result, it’s filling the area we want. All we need to do is clip the extra space and a simple border-radius: 50% will do the job!
CodePen Embed FallbackI am keeping the box-shadow in there so we can see the arc, but we can clearly see how border-radius is making a difference on the main shape.
There is still one edge case we need to consider. When the value is equal to 100, both points of the arc will have the same coordinates, which is logical since the sector is full and we have a circle. But when it’s the case, the arc will do nothing by definition and we won’t get a full circle.
To fix this, we can limit the value to, for example, 99.99 to avoid reaching 100. It’s kind of hacky, but it does the job.
.sector { --v: 35; /* [0 100]*/ --_v: min(99.99, var(--v)); aspect-ratio: 1; clip-path: shape(from top, arc to calc(50% + 50% * sin(var(--_v) * 3.6deg)) calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% large cw, line to center); border-radius: 50%;}
Now our shape is perfect! And don’t forget that you can apply it to image elements:
CodePen Embed FallbackArc shapeSimilar to the sector shape, we can also create an arc shape. After all, we are working with the arc command, so we have to do it.
We already have half the code since it’s basically a sector shape without the inner part. We simply need to add more commands to cut the inner part.
.arc { --v: 35; --b: 30px; --_v: min(99.99, var(--v)); aspect-ratio: 1; clip-path: shape(from top, arc to calc(50% + 50% * sin(var(--_v) * 3.6deg)) calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% cw large, line to calc(50% + (50% - var(--b)) * sin(var(--_v) * 3.6deg)) calc(50% - (50% - var(--b)) * cos(var(--_v) * 3.6deg)), arc to 50% var(--b) of calc(50% - var(--b)) large ); border-radius: 50%;}
From the sector shape, we remove the line to center piece and replace it with another line command that moves to a point placed on the inner circle. If you compare its coordinates with the previous point, you will see an offset equal to --b, which is a variable that defines the arc’s thickness. Then we draw an arc in the opposite direction (ccw) until the point 50% var(--b), which is also a point with an offset equal to --b from the top.
I am not defining the direction of the second arc since, by default, the browser will use ccw.
CodePen Embed FallbackAh, the same issue we hit with the sector shape is striking again! Not all the values are giving a good result due to the same logic we saw earlier, and, as you can see, border-radius is not fixing it. This time, we need to find a way to conditionally change the size of the arc based on the value. It should be large when V is bigger than 50, and small otherwise.
Conditions in CSS? Yes, it’s possible! First, let’s convert the V value like this:
--_f: round(down, var(--_v), 50)
The value is within the range [0 99.99] (don’t forget that we don’t want to reach the value 100). We use round() to make sure it’s always equal to a multiple of a specific value, which is 50 in our case. If the value is smaller than 50, the result is 0, otherwise it’s 50.
There are only two possible values, so we can easily add a condition. If --_f is equal to 0 we use small; otherwise, we use large:
.arc { --v: 35; --b: 30px; --_v: min(99.99, var(--v)); --_f: round(down,var(--_v), 50); --_c: if(style(--_f: 0): small; else: large); clip-path: shape(from top, arc to calc(50% + 50% * sin(var(--_v) * 3.6deg)) calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% cw var(--_c), line to calc(50% + (50% - var(--b)) * sin(var(--_v) * 3.6deg)) calc(50% - (50% - var(--b)) * cos(var(--_v) * 3.6deg)), arc to 50% var(--b) of calc(50% - var(--b)) var(--_c) );}
I know what you are thinking, but let me tell you that the above code is valid. You probably don’t know it yet, but CSS has recently introduced inline conditionals using an if() syntax. It’s still early to play with it, but we have found a perfect use case for it. Here is a demo that you can test using Chrome Canary:
CodePen Embed FallbackAnother way to express conditions is to rely on style queries that have better support:
.arc { --v: 35; --b: 30px; --_v: min(99.99, var(--v)); --_f: round(down, var(--_v), 50); aspect-ratio: 1; container-name: arc;}.arc:before { content: ""; clip-path: shape(from top, arc to calc(50% + 50% * sin(var(--_v) * 3.6deg)) calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% cw var(--_c, large), line to calc(50% + (50% - var(--b)) * sin(var(--_v) * 3.6deg)) calc(50% - (50% - var(--b)) * cos(var(--_v) * 3.6deg)), arc to 50% var(--b) of calc(50% - var(--b)) var(--_c, large) ); @container style(--_f: 0) { --_c: small }}
The logic is the same but, this feature requires a parent-child relation, which is why I am using a pseudo-element. By default, the size will be large, and if the value of --_f is equal to 0, we switch to small.
CodePen Embed FallbackNote that we have to register the variable --_f using @property to be able to either use the if() function or style queries.
Did you notice another subtle change I have made to the shape? I removed border-radius and I applied the conditional logic to the first arc. Both have the same issue, but border-radius can fix only one of them while the conditional logic can fix both, so we can optimize the code a little.
Arc shape with rounded edgesWhat about adding rounded edges to our arc? It’s better, right?
Can you see how it’s done? Take it as a small exercise and update the code from the previous examples to add those rounded edges. I hope you are able to find it by yourself because the changes are pretty straightforward — we update one line command with an arc command and we add another arc command at the end.
clip-path: shape(from top, arc to calc(50% + 50% * sin(var(--_v) * 3.6deg)) calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% cw var(--_c, large), arc to calc(50% + (50% - var(--b)) * sin(var(--_v) * 3.6deg)) calc(50% - (50% - var(--b)) * cos(var(--_v) * 3.6deg)) of 1% cw, arc to 50% var(--b) of calc(50% - var(--b)) var(--_c, large), arc to top of 1% cw);
If you do not understand the changes, get out a pen and paper, then draw the shape to better see the four arcs we are drawing. Previously, we had two arcs and two lines, but now we are working with arcs instead of lines.
And did you remember the trick of using a 1% value for the radius? The new arcs are half circles, so we can rely on that trick where you specify a tiny radius and the browser will do the job for you and find the correct value!
CodePen Embed FallbackConclusionWe are done — enough about the arc command! I had to write two articles that focus on this command because it’s the trickiest one, but I hope it’s now clear how to use it and how to handle the direction and size thing, as that is probably the source of most headaches.
By the way, I have only studied the case of circular arcs because, in reality, we can specify two radii and draw elliptical ones, which is even more complex. Unless you want to become a shape() master, you will rarely need elliptical arcs, so don’t bother yourself with them.
Until the next article, I wrote an article for Frontend Masters where you can create more fancy shapes using the arc command that is a good follow-up to this one.
Better CSS Shapes Using shape()1. Lines and Arcs
2. More on Arcs (you are here!)
3. Curves
Footnotes(1) The arc command is defined to draw elliptical arcs by taking two radii, but if we define one radius value, it means that the vertical and horizontal radius will use that same value and we have circular arcs. When it’s a length, it’s trivial, but when we use percentages, the value will resolve against the direction-agnostic size, which is equal to the length of the diagonal of the box, divided by sqrt(2).
In our case, we have a square element so 50% of the direction-agnostic size will be equal to 50% of sqrt(Width² + Height²)/sqrt(2). And since both width and height are equal, we end with 50% of the width (or the height). ⮑
Better CSS Shapes Using shape() — Part 2: More on Arcs originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
The reading-flow and reading-order proposed CSS properties are designed to specify the source order of HTML elements in the DOM tree, or in simpler terms, how accessibility tools deduce the order of elements. You’d use them to make the focus order of focusable elements match the visual order, as outlined in the Web Content Accessibility Guidelines (WCAG 2.2).
To get a better idea, let’s just dive in!
(Oh, and make sure that you’re using Chrome 137 or higher.)
reading-flow``reading-flow determines the source order of HTML elements in a flex, grid, or block layout. Again, this is basically to help accessibility tools provide the correct focus order to users.
The default value is normal (so, reading-flow: normal). Other valid values include:
flex-visualflex-flowgrid-rowsgrid-columnsgrid-ordersource-orderLet’s start with the flex-visual value. Imagine a flex row with five links. Assuming that the reading direction is left-to-right (by the way, you can change the reading direction with the direction CSS property), that’d look something like this:
CodePen Embed FallbackNow, if we apply flex-direction: row-reverse, the links are displayed 5-4-3-2-1. The problem though is that the focus order still starts from 1 (tab through them!), which is visually wrong for somebody that reads left-to-right.
CodePen Embed FallbackBut if we also apply reading-flow: flex-visual, the focus order also becomes 5-4-3-2-1, matching the visual order (which is an accessibility requirement!):
```
```
div { display: flex; flex-direction: row-reverse; reading-flow: flex-visual;}
CodePen Embed FallbackTo apply the default flex behavior, reading-flow: flex-flow is what you’re looking for. This is very akin to reading-flow: normal, except that the container remains a reading flow container, which is needed for reading-order (we’ll dive into this in a bit).
For now, let’s take a look at the grid-y values. In the grid below, the grid items are all jumbled up, and so the focus order is all over the place.
CodePen Embed FallbackWe can fix this in two ways. One way is that reading-flow: grid-rows will, as you’d expect, establish a row-by-row focus order:
```
```
div { display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: 100px; reading-flow: grid-rows; a:nth-child(2) { grid-row: 2 / 4; grid-column: 3; } a:nth-child(5) { grid-row: 1 / 3; grid-column: 1 / 3; }}
CodePen Embed FallbackOr, reading-flow: grid-columns will establish a column-by-column focus order:
CodePen Embed Fallbackreading-flow: grid-order will give us the default grid behavior (i.e., the jumbled up version). This is also very akin to reading-flow: normal (except that, again, the container remains a reading flow container, which is needed for reading-order).
There’s also reading-flow: source-order, which is for flex, grid, and block containers. It basically turns containers into reading flow containers, enabling us to use reading-order. To be frank, unless I’m missing something, this appears to make the flex-flow and grid-order values redundant?
reading-order``reading-order sort of does the same thing as reading-flow. The difference is that reading-order is for specific flex or grid items, or even elements in a simple block container. It works the same way as the order property, although I suppose we could also compare it to tabindex.
Note: To use reading-order, the container must have the reading-flow property set to anything other than normal.
I’ll demonstrate both reading-order and order at the same time. In the example below, we have another flex container where each flex item has the order property set to a different random number, making the order of the flex items random. Now, we’ve already established that we can use reading-flow to determine focus order regardless of visual order, but in the example below we’re using reading-order instead (in the exact same way as order):
div { display: flex; reading-flow: source-order; /* Anything but normal */ /* Features at the end because of the higher values */ a:nth-child(1) { /* Visual order */ order: 567; /* Focus order */ reading-order: 567; } a:nth-child(2) { order: 456; reading-order: 456; } a:nth-child(3) { order: 345; reading-order: 345; } a:nth-child(4) { order: 234; reading-order: 234; } /* Features at the beginning because of the lower values */ a:nth-child(5) { order: -123; reading-order: -123; }}
CodePen Embed FallbackYes, those are some rather odd numbers. I’ve done this to illustrate how the numbers don’t represent the position (e.g., order: 3 or reading-order: 3 doesn’t make it third in the order). Instead, elements with lower numbers are more towards the beginning of the order and elements with higher numbers are more towards the end. The default value is 0. Elements with the same value will be ordered by source order.
In practical terms? Consider the following example:
div { display: flex; reading-flow: source-order; a:nth-child(1) { order: 1; reading-order: 1; } a:nth-child(5) { order: -1; reading-order: -1; }}
CodePen Embed FallbackOf the five flex items, the first one is the one with order: -1 because it has the lowest order value. The last one is the one with order: 1 because it has the highest order value. The ones with no declaration default to having order: 0 and are thus ordered in source order, but otherwise fit in-between the order: -1 and order: 1 flex items. And it’s the same concept for reading-order, which in the example above mirrors order.
However, when reversing the direction of flex items, keep in mind that order and reading-order work a little differently. For example, reading-order: -1 would, as expected, and pull a flex item to the beginning of the focus order. Meanwhile, order: -1 would pull it to the end of the visual order because the visual order is reversed (so we’d need to use order: 1 instead, even if that doesn’t seem right!):
div { display: flex; flex-direction: row-reverse; reading-flow: source-order; a:nth-child(5) { /* Because of row-reverse, this actually makes it first */ order: 1; /* However, this behavior doesn’t apply to reading-order */ reading-order: -1; }}
CodePen Embed Fallbackreading-order overrides reading-flow. If we, for example, apply reading-flow: flex-visual, reading-flow: grid-rows, or reading-flow: grid-columns (basically, any declaration that does in fact change the reading flow), reading-order overrides it. We could say that reading-order is applied after reading-flow.
What if I don’t want to use flexbox or grid layout?Well, that obviously rules out all of the flex-y and grid-y reading-flow values; however, you can still set reading-flow: source-order on a block element and then manipulate the focus order with reading-order (as we did above).
How does this relate to the tabindex HTML attribute?They’re not equivalent. Negative tabindex values make targets unfocusable and values other than 0 and -1 aren’t recommended, whereas a reading-order declaration can use any number as it’s only contextual to the reading flow container that contains it.
For the sake of being complete though, I did test reading-order and tabindex together and reading-order appeared to override tabindex.
Going forward, I’d only use tabindex (specifically, tabindex="-1") to prevent certain targets from being focusable (the disabled attribute will be more appropriate for some targets though), and then reading-order for everything else.
Closing thoughtsBeing able to define reading order is useful, or at least it means that the order property can finally be used as intended. Up until now (or rather when all web browsers support reading-flow and reading-order, because they only work in Chrome 137+ at the moment), order hasn’t been useful because we haven’t been able to make the focus order match the visual order.
What We Know (So Far) About CSS Reading Order originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Creating CSS Shapes is a classic and one of my favorite exercise. Indeed, I have one of the biggest collections of CSS Shapes from where you can easily copy the code of any shape. I also wrote an extensive guide on how to create them: The Modern Guide For Making CSS Shapes.
Even if I have detailed most of the modern techniques and tricks, CSS keeps evolving, and new stuff always emerges to simplify our developer life. Recently, clip-path was upgraded to have a new shape() value. A real game changer!
Better CSS Shapes Using shape()1. Lines and Arcs (you are here!)
2. More on Arcs
3. Curves
Before we jump in, it’s worth calling out that the shape() function is currently only supported in Chrome 137+ and Safari 18.4+ as I’m writing this in May 2025.
What is shape()?Let me quote the description from the official specification:
While the
path()function allows reuse of the SVG path syntax to define more arbitrary shapes than allowed by more specialized shape functions, it requires writing a path as a single string (which is not compatible with, for example, building a path piecemeal with var()), and inherits a number of limitations from SVG, such as implicitly only allowing the px unit.The
shape()function uses a set of commands roughly equivalent to the ones used bypath(), but does so with more standard CSS syntax, and allows the full range of CSS functionality, such as additional units and math functions.
In other words, we have the SVG features in the CSS side that we can combine with existing features such as var(), calc(), different units, etc. SVG is already good at drawing complex shapes, so imagine what is possible with something more powerful.
If you keep reading the spec, you will find:
In that sense,
shape()is a superset ofpath(). Apath()can be easily converted to ashape(), but to convert ashape()back to apath()or to SVG requires information about the CSS environment.
And guess what? I already created an online converter from SVG to CSS. Save this tool because it will be very handy. If you are already good at creating SVG shapes or you have existing codes, no need to reinvent the wheel. You paste your code in the generator, and you get the CSS code that you can easily tweak later.
Let’s try with the CSS-Tricks logo. Here is the SVG I picked from the website:
<svg width="35px" height="35px" viewBox="0 0 362.62 388.52" > <path d="M156.58,239l-88.3,64.75c-10.59,7.06-18.84,11.77-29.43,11.77-21.19,0-38.85-18.84-38.85-40C0,257.83,14.13,244.88,27.08,239l103.6-44.74L27.08,148.34C13,142.46,0,129.51,0,111.85,0,90.66,18.84,73,40,73c10.6,0,17.66,3.53,28.25,11.77l88.3,64.75L144.81,44.74C141.28,20,157.76,0,181.31,0s40,18.84,36.5,43.56L206,149.52l88.3-64.75C304.93,76.53,313.17,73,323.77,73a39.2,39.2,0,0,1,38.85,38.85c0,18.84-12.95,30.61-27.08,36.5L231.93,194.26,335.54,239c14.13,5.88,27.08,18.83,27.08,37.67,0,21.19-18.84,38.85-40,38.85-9.42,0-17.66-4.71-28.26-11.77L206,239l11.77,104.78c3.53,24.72-12.95,44.74-36.5,44.74s-40-18.84-36.5-43.56Z"></path></svg>
You take the value inside the d attribute, paste it in the converter, and boom! You have the following CSS:
.shape { aspect-ratio: 0.933; clip-path: shape(from 43.18% 61.52%,line by -24.35% 16.67%,curve by -8.12% 3.03% with -2.92% 1.82%/-5.2% 3.03%,curve by -10.71% -10.3% with -5.84% 0%/-10.71% -4.85%,curve to 7.47% 61.52% with 0% 66.36%/3.9% 63.03%,line by 28.57% -11.52%,line to 7.47% 38.18%,curve to 0% 28.79% with 3.59% 36.67%/0% 33.33%,curve to 11.03% 18.79% with 0% 23.33%/5.2% 18.79%,curve by 7.79% 3.03% with 2.92% 0%/4.87% 0.91%,line by 24.35% 16.67%,line to 39.93% 11.52%,curve to 50% 0% with 38.96% 5.15%/43.51% 0%,smooth by 10.07% 11.21% with 11.03% 4.85%,line to 56.81% 38.48%,line by 24.35% -16.67%,curve to 89.29% 18.79% with 84.09% 19.7%/86.36% 18.79%,arc by 10.71% 10% of 10.81% 10.09% small cw,curve by -7.47% 9.39% with 0% 4.85%/-3.57% 7.88%,line to 63.96% 50%,line to 92.53% 61.52%,curve by 7.47% 9.7% with 3.9% 1.51%/7.47% 4.85%,curve by -11.03% 10% with 0% 5.45%/-5.2% 10%,curve by -7.79% -3.03% with -2.6% 0%/-4.87% -1.21%,line to 56.81% 61.52%,line by 3.25% 26.97%,curve by -10.07% 11.52% with 0.97% 6.36%/-3.57% 11.52%,smooth by -10.07% -11.21% with -11.03% -4.85%,close);}
Note that you don’t need to provide any viewBox data. The converter will automatically find the smallest rectangle for the shape and will calculate the coordinates of the points accordingly. No more viewBox headaches and no need to fight with overflow or extra spacing!
CodePen Embed FallbackHere is another example where I am applying the shape to an image element. I am keeping the original SVG so you can compare both shapes.
CodePen Embed FallbackWhen to use shape()I would be tempted to say “all the time” but in reality, not. In my guide, I distinguish between two types of shapes: The ones with only straight lines and the ones with curves. Each type can either have repetition or not. In the end, we have four categories of shapes.
If we don’t have curves and we don’t have repetition (the easiest case), then clip-path: polygon() should do the job. If we have a repetition (with or without curves), then mask is the way to go. With mask, we can rely on gradients that can have a specific size and repeat, but with clip-path we don’t have such options.
If you have curves and don’t have a repetition, the new shape() is the best option. Previously, we had to rely on mask since clip-path is very limited, but that’s no longer the case. Of course, these are not universal rules, but my own way to identify which option is the most suitable. At the end of the day, it’s always a case-by-case basis as we may have other things to consider, such as the complexity of the code, the flexibility of the method, browser support, etc.
Let’s draw some shapes!Enough talking, let’s move to the interesting part: drawing shapes. I will not write a tutorial to explain the “complex” syntax of shape(). It will be boring and not interesting. Instead, we will draw some common shapes and learn by practice!
RectangleTake the following polygon:
clip-path: polygon( 0 0, 100% 0, 100% 100%, 0 100%);
Technically, this will do nothing since it will draw a rectangle that already follows the element shape which is a rectangle, but it’s still the perfect starting point for us.
Now, let’s write it using shape().
clip-path: shape( from 0 0, line to 100% 0, line to 100% 100%, line to 0 100%);
The code should be self-explanatory and we already have two commands. The from command is always the first command and is used only once. It simply specifies the starting point. Then we have the line command that draws a line to the next point. Nothing complex so far.
We can still write it differently like below:
clip-path: shape( from 0 0, hline to 100%, vline to 100%, hline to 0);
Between the points 0 0 and 100% 0, only the first value is changing which means we are drawing a horizontal line from 0 0 to 100% 0, hence the use of hline to 100% where you only need to specify the horizontal offset. It’s the same logic using vline where we draw a vertical line between 100% 0 and 100% 100%.
I won’t advise you to draw your shape using hline and vline because they can be tricky and are a bit difficult to read. Always start by using line and then if you want to optimize your code you can replace them with hline or vline when applicable.
We have our first shape and we know the commands to draw straight lines:
CodePen Embed FallbackCircular Cut-OutNow, let’s try to add a circular cut-out at the top of our shape:
For this, we are going to rely on the arc command, so let’s understand how it works.
If we have two points, A and B, there are exactly two circles with a given radius that intersect with both points like shown in the figure. The intersection gives us four possible arcs we can draw between points A and B. Each arc is defined by a size and a direction.
There is also the particular case where the radius is equal to half the distance between A and B. In this case, only two arcs can be drawn and the direction will decide which one.
The syntax will look like this:
clip-path: shape( from Xa Ya, arc to Xb Yb of R [large or small] [cw or ccw]);
Let’s add this to our previous shape. No need to think about the values. Instead, let’s use random ones and see what happens:
clip-path: shape( from 0 0, arc to 40% 0 of 50px, line to 100% 0, line to 100% 100%, line to 0 100%);
CodePen Embed FallbackNot bad, we can already see the arc between 0 0 and 40% 0. Notice how I didn’t define the size and direction of the arc. By default, the browser will use small and ccw.
Let’s explicitly define the size and direction to see the four different cases:
CodePen Embed FallbackHmm, it’s working for the first two blocks but not the other ones. Quite strange, right?
Actually, everything is working fine. The arcs are drawn outside the element area so nothing is visible. If you add some box-shadow, you can see them:
CodePen Embed FallbackArcs can be tricky due to the size and direction thing, so get ready to be confused. If that happens, remember that you have four different cases, and trying all of them will help you find which one you need.
Now let’s try to be accurate and draw half a circle with a specific radius placed at the center:
We can define the radius as a variable and use what we have learned so far:
.shape { --r: 50px; clip-path: shape( from 0 0, line to calc(50% - var(--r)) 0, arc to calc(50% + var(--r)) 0 of var(--r), line to 100% 0, line to 100% 100%, line to 0 100% );}
CodePen Embed FallbackIt’s working fine, but the code can still be optimized. We can replace all the line commands with hline and vline like below:
.shape { --r: 50px; clip-path: shape(from 0 0, hline to calc(50% - var(--r)), arc to calc(50% + var(--r)) 0 of var(--r), hline to 100%, vline to 100%, hline to 0 );}
We can also replace the radius with 1%:
.shape { --r: 50px; clip-path: shape(from 0 0, hline to calc(50% - var(--r)), arc to calc(50% + var(--r)) 0 of 1%, hline to 100%, vline to 100%, hline to 0 );}
When you define a small radius (smaller than half the distance between both points), no circle can meet the condition we explained earlier (an intersection with both points), so we cannot draw an arc. This case falls within an error handling where the browser will scale the radius until we can have a circle that meets the condition. Instead of considering this case as invalid, the browser will fix “our mistake” and draw an arc.
This error handling is pretty cool as it allows us to simplify our shape() function. Instead of specifying the exact radius, I simply put a small value and the browser will do the job for me. This trick only works when the arc we want to draw is half a circle. Don’t try to apply it with any arc command because it won’t always work.
Another optimization is to update the following:
arc to calc(50% + var(--r)) 0 of 1%,
…with this:
arc by calc(2 * var(--r)) 0 of 1%,
Almost all the commands can either use a to directive or a by directive. The first one defines absolute coordinates like the one we use with polygon(). It’s the exact position of the point we are moving to. The second defines relative coordinates which means we need to consider the previous point to identify the coordinates of the next point.
In our case, we are telling the arc to consider the previous point (50% - R) 0 and move by 2*R 0, so the final point will be (50% - R + 2R) (0 + 0), which is the same as (50% + R) 0.
.shape { --r: 50px; clip-path: shape(from 0 0, hline to calc(50% - var(--r)), arc by calc(2 * var(--r)) 0 of 1px, hline to 100%, vline to 100%, hline to 0 );}
This last optimization is great because if we want to move the cutout from the center, we only need to update one value: the 50%.
.shape { --r: 50px; --p: 40%; clip-path: shape( from 0 0, hline to calc(var(--p) - var(--r)), arc by calc(2 * var(--r)) 0 of 1px, hline to 100%, vline to 100%, hline to 0 );}
CodePen Embed FallbackHow would you adjust the above to have the cut-out at the bottom, left, or right? That’s your first homework assignment! Try to do it before moving to the next part.
I will give my implementation so that you can compare with yours, but don’t cheat! If you can do this without referring to my work, you will be able to do more complex shapes more easily.
CodePen Embed FallbackRounded TabEnough cut-out, let’s try to create a rounded tab:
Can you see the puzzle of this one? Similar to the previous shape, it’s a bunch of arc and line commands. Here is the code:
.shape { --r: 26px; clip-path: shape( /* left part */ from 0 100%, arc by var(--r) calc(-1 * var(--r)) of var(--r), vline to var(--r), arc by var(--r) calc(-1 * var(--r)) of var(--r) cw, /* right part */ hline to calc(100% - 2 * var(--r)), arc by var(--r) var(--r) of var(--r) cw, vline to calc(100% - var(--r)), arc by var(--r) var(--r) of var(--r) );}
It looks a bit scary, but if you follow it command by command, it becomes a lot clearer to see what’s happening. Here is a figure to help you visualize the left part of it.
All the arc commands are using the by directive because, in all the cases, I always need to move by an offset equal to R, meaning I don’t have to calculate the coordinates of the points. And don’t try to replace the radius by 1% because it won’t work since we are drawing a quarter of a circle rather than half of a circle.
CodePen Embed FallbackFrom this, we can easily achieve the left and right variations:
CodePen Embed FallbackNotice how I am only using two arc commands instead of three. One rounded corner can be done with a classic border radius, so this can help us simplify the shape.
Inverted RadiusOne last shape, the classic inner curve at the corner also called an inverted radius. How many arc commands do we need for this one? Check the figure below and think about it.
If your answer is six, you have chosen the difficult way to do it. It’s logical to think about six arcs since we have six curves, but three of them can be done with a simple border radius, so only three arcs are needed. Always take the time to analyze the shape you are creating. Sometimes, basic CSS properties can help with creating the shape.
What are you waiting for? This is your next homework and I won’t help you with a figure this time. You have all that you need to easily create it. If you are struggling, give the article another read and try to study the previous shapes more in depth.
Here is my implementation of the four variations:
CodePen Embed FallbackConclusionThat’s all for this first part. You should have a good overview of the shape() function. We focused on the line and arc commands which are enough to create most of the common shapes.
Don’t forget to bookmark the SVG to CSS converter and keep an eye on my CSS Shape collection where you can find the code of all the shapes I create. And here is a last shape to end this article.
CodePen Embed FallbackBetter CSS Shapes Using shape()1. Lines and Arcs (you are here!)
2. More on Arcs
3. Curves
Better CSS Shapes Using shape() — Part 1: Lines and Arcs originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Clever, clever that Andy Bell. He shares a technique for displaying image alt text when the image fails to load. Well, more precisely, it’s a technique to apply styles to the alt when the image doesn’t load, offering a nice UI fallback for what would otherwise be a busted-looking error.
The recipe? First, make sure you’re using alt in the HTML. Then, a little JavaScript snippet that detects when an image fails to load:
const images = document.querySelectorAll("img");if (images) { images.forEach((image) => { image.onerror = () => { image.setAttribute("data-img-loading-error", ""); }; });}
That slaps an attribute on the image — data-img-loading-error — that is selected in CSS:
img[data-img-loading-error] { --img-border-style: 0.25em solid color-mix(in srgb, currentColor, transparent 75%); --img-border-space: 1em; border-inline-start: var(--img-border-style); border-block-end: var(--img-border-style); padding-inline-start: var(--img-border-space); padding-block: var(--img-border-space); max-width: 42ch; margin-inline: auto;}
And what you get is a lovely little presentation of the alt that looks a bit like a blockquote and is is only displayed when needed.
CodePen Embed FallbackAndy does note, however, that Safari does not render alt text if it goes beyond a single line, which 🤷♂️.
You can style alt text like any other text originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Shape master Temani Afif has what might be the largest collection of CSS shapes on the planet with all the tools to generate them on the fly. There’s a mix of clever techniques he’s typically used to make those shapes, many of which he’s covered here at CSS-Tricks over the years.
Some of the more complex shapes were commonly clipped with the path() function. That makes a lot of sense because it literally accepts SVG path coordinates that you can draw in an app like Figma and export.
But Temani has gone all-in on the newly-released shape() function which recently rolled out in both Chromium browsers and Safari. That includes a brand-new generator that converts path() shapes in shape() commands instead.
So, if we had a shape that was originally created with an SVG path, like this:
.shape { clip-path: path( M199.6,18.9 c-4.3-8.9-12.5-16.4-22.3-17.8 c-11.9-1.7-23.1,5.4-32.2,13.2 c-9.1,7.8-17.8,16.8-29.3,20.3 c-20.5,6.2-41.7-7.4-63.1-7.5 c38.7,27,24.8,33,15.2,43.3 c-35.5,38.2-0.1,99.4,40.6,116.2 c32.8,13.6,72.1,5.9,100.9-15 c27.4-19.9,44.3-54.9,47.4-88.6 c0.2-2.7,0.4-5.3,0.5-7.9 c204.8,38,203.9,27.8,199.6,18.9 z );}
…the generator will spit this out:
.shape { clip-path: shape( from 97.54% 10.91%, curve by -10.93% -10.76% with -2.11% -5.38%/-6.13% -9.91%, curve by -15.78% 7.98% with -5.83% -1.03%/-11.32% 3.26%, curve by -14.36% 12.27% with -4.46% 4.71%/-8.72% 10.15%, curve by -30.93% -4.53% with -10.05% 3.75%/-20.44% -4.47%, curve to 7.15% 25.66% with 18.67% 15.81%/11.86% 19.43%, curve by 19.9% 70.23% with -17.4% 23.09%/-0.05% 60.08%, curve by 49.46% -9.07% with 16.08% 8.22%/35.34% 3.57%, curve by 23.23% -53.55% with 13.43% -12.03%/21.71% -33.18%, curve by 0.25% -4.77% with 0.1% -1.63%/0.2% -3.2%, curve to 97.54% 10.91% with 100.09% 22.46%/99.64% 16.29%, close );}
Pretty cool!
CodePen Embed FallbackHonestly, I’m not sure how often I’ll need to convert path() to shape(). Seems like a stopgap sorta thing where the need for it dwindles over time as shape() is used more often — and it’s not like the existing path() function is broken or deprecated… it’s just different. But still, I’m using the generator a LOT as I try to wrap my head around shape() commands. Seeing the commands in context is invaluable which makes it an excellent learning tool.
SVG to CSS Shape Converter originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
You’ve played Flexbox Froggy before, right? Or maybe Grid Garden? They’re both absolute musts for learning the basics of modern CSS layout using Flexbox and CSS Grid. I use both games in all of the classes I teach and I never get anything but high-fives from my students because they love them so much.
As widely known as those games are, you may be less familiar with the name of the developer who made them. That would be Thomas Park, and he has a couple of CSS-Tricks articles notched in his belt. He also has a horde of other games in his CodePip collection of free and premium games for learning front-end techniques.
Thomas wrote in to share his latest game with us: Anchoreum.
I’ll bet the two nickels in my pocket that you know this game’s all about CSS Anchor Positioning. I love that Thomas has jumped on this so quickly because the feature is still fresh, and indeed is currently only supported in a couple of browsers at the moment.
This is the perfect time to learn about anchor positioning. It’s still relatively early days, but things are baked enough to be supported in Chrome and Edge so you can access the games. If you haven’t seen Juan’s big ol’ guide on anchor positioning, that’s another dandy way to get up to speed.
The objective is less on-the-nose than Flexbox Froggy and Grid Garden, which both lean heavily into positioning elements to complete game tasks. For example, Flexbox Froggy is about positioning frogs safely on lilypads. Grid Garden wants you to water specific garden areas to feed your carrots. Anchoreum? You’re in a museum and need to anchor labels to museum artifacts. I know, attaching target elements to the same anchor over and again could get boring. But thankfully the game goes beyond simple positioning by getting into multiple anchors, spanning, and position fallbacks.
Whatever the objective, the repetition is good for developing muscle memory and the overall outcome is still the same: learn CSS Anchor Positioning. I’m already planning how and where I’m going to use Anchoreum in my curriculum. It’s not often we get a fun interactive learning resource like this for such a new web feature and I think it’s worth jumping on it sooner rather than later.
Thomas prepped a video trailer for the game so I thought I’d drop that for reference.
Anchoreum: A New Game for Learning Anchor Positioning originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Another title from A Book Apart has been re-released for free. The latest? Tim Brown’s Flexible Typesetting. I may not be the utmost expert on typography and its best practices but I do remember reading this book (it’s still on the shelf next to me!) thinking maybe, just maybe, I might be able to hold a conversation about it with Robin when I finished it.
I still think I’m in “maybe” territory but that’s not Tim’s fault — I found the book super helpful and approachable for noobs like me who want to up our game. For the sake of it, I’ll drop the chapter titles here to give you an idea of what you’ll get.
Tim Brown: Flexible Typesetting is now yours, for free originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
If all you have is a hammer, everything looks like a nail.
Abraham Maslow
It’s easy to default to what you know. When it comes to toggling content, that might be reaching for display: none or opacity: 0 with some JavaScript sprinkled in. But the web is more “modern” today, so perhaps now is the right time to get a birds-eye view of the different ways to toggle content — which native APIs are actually supported now, their pros and cons, and some things about them that you might not know (such as any pseudo-elements and other non-obvious stuff).
So, let’s spend some time looking at disclosures (<details> and <summary>), the Dialog API, the Popover API, and more. We’ll look at the right time to use each one depending on your needs. Modal or non-modal? JavaScript or pure HTML/CSS? Not sure? Don’t worry, we’ll go into all that.
Disclosures (<details> and <summary>)Use case: Accessibly summarizing content while making the content details togglable independently, or as an accordion.
CodePen Embed FallbackGoing in release order, disclosures — known by their elements as <details> and <summary> — marked the first time we were able to toggle content without JavaScript or weird checkbox hacks. But lack of web browser support obviously holds new features back at first, and this one in particular came without keyboard accessibility. So I’d understand if you haven’t used it since it came to Chrome 12 way back in 2011. Out of sight, out of mind, right?
Here’s the low-down:
appearance: none or the like.Marking up disclosuresWhat you’re looking for is this:
```
``
Behind the scenes, the content’s wrapped in a pseudo-element that as of 2024 we can select using::details-content. To add to this, there’s a::marker` pseudo-element that indicates whether the disclosure’s open or closed, which we can customize.
With that in mind, disclosures actually look like this under the hood:
```
``
To have the disclosure open by default, givetheopen` attribute, which is what happens behind the scenes when disclosures are opened anyway.
```
``
Styling disclosuresLet’s be real: you probably just want to lose that annoying marker. Well, you can do that by setting thedisplayproperty ofto anything butlist-item`:
summary { display: block; /* Or anything else that isn't list-item */}
CodePen Embed FallbackAlternatively, you can modify the marker. In fact, the example below utilizes Font Awesome to replace it with another icon, but keep in mind that ::marker doesn’t support many properties. The most flexible workaround is to wrap the content of <summary> in an element and select it in CSS.
```
```
details { /* The marker */ summary::marker { content: "\f150"; font-family: "Font Awesome 6 Free"; } /* The marker when <details> is open */ &[open] summary::marker { content: "\f151"; } /* Because ::marker doesn’t support many properties */ summary span { margin-left: 1ch; display: inline-block; } }
CodePen Embed FallbackCreating an accordion with multiple disclosuresCodePen Embed FallbackTo create an accordion, name multiple disclosures (they don’t even have to be siblings) with a name attribute and a matching value (similar to how you’d implement <input type="radio">):
```
``` Using a wrapper, we can even turn these into horizontal tabs:
CodePen Embed Fallback ```
```
div { gap: 1ch; display: flex; position: relative; details { min-height: 106px; /* Prevents content shift */ &[open] summary, &[open]::details-content { background: #eee; } &[open]::details-content { left: 0; position: absolute; } }}
…or, using 2024’s Anchor Positioning API, vertical tabs (same HTML):
div { display: inline-grid; anchor-name: --wrapper; details[open] { summary, &::details-content { background: #eee; } &::details-content { position: absolute; position-anchor: --wrapper; top: anchor(top); left: anchor(right); } }}
CodePen Embed FallbackIf you’re looking for some wild ideas on what we can do with the Popover API in CSS, check out John Rhea’s article in which he makes an interactive game solely out of disclosures!
Adding JavaScript functionalityWant to add some JavaScript functionality?
// Optional: select and loop multiple disclosuresdocument.querySelectorAll("details").forEach(details => { details.addEventListener("toggle", () => { // The disclosure was toggled if (details.open) { // The disclosure was opened } else { // The disclosure was closed } }); });
Creating accessible disclosuresDisclosures are accessible as long as you follow a few rules. For example, <summary> is basically a <label>, meaning that its content is announced by screen readers when in focus. If there isn’t a <summary> or <summary> isn’t a direct child of <details> then the user agent will create a label for you that normally says “Details” both visually and in assistive tech. Older web browsers might insist that it be the first child, so it’s best to make it so.
To add to this, <summary> has the role of button, so whatever’s invalid inside a <button> is also invalid inside a <summary>. This includes headings, so you can style a <summary> as a heading, but you can’t actually insert a heading into a <summary>.
The Dialog element (<dialog>)Use case: Modals
CodePen Embed FallbackNow that we have the Popover API for non-modal overlays, I think it’s best if we start to think of dialogs as modals even though the show() method does allow for non-modal dialogs. The advantage that the popover attribute has over the <dialog> element is that you can use it to create non-modal overlays without JavaScript, so in my opinion there’s no benefit to non-modal dialogs anymore, which do require JavaScript. For clarity, a modal is an overlay that makes the main document inert, whereas with non-modal overlays the main document remains interactive. There are a few other features that modal dialogs have out-of-the-box as well, including:
<dialog> (or, as a backup, the <dialog> itself — include an aria-label in this case),esc key closes the dialog, andStart with the <dialog> element:
<dialog> ... </dialog>
It’s hidden by default and, similar to <details>, we can have it open when the page loads, although it isn’t modal in this scenario since it does not contain interactive content because it doesn’t opened with showModal().
<dialog open> ... </dialog>
I can’t say that I’ve ever needed this functionality. Instead, you’ll likely want to reveal the dialog upon some kind of interaction, such as the click of a button — so here’s that button:
<button data-dialog="dialogA">Open dialogA</button>
Wait, why are we using data attributes? Well, because we might want to hand over an identifier that tells the JavaScript which dialog to open, enabling us to add the dialog functionality to all dialogs in one snippet, like this:
// Select and loop all elements with that data attributedocument.querySelectorAll("[data-dialog]").forEach(button => { // Listen for interaction (click) button.addEventListener("click", () => { // Select the corresponding dialog const dialog = document.querySelector(`#${ button.dataset.dialog }`); // Open dialog dialog.showModal(); // Close dialog dialog.querySelector(".closeDialog").addEventListener("click", () => dialog.close()); });});
Don’t forget to add a matching id to the <dialog> so it’s associated with the <button> that shows it:
<dialog id="dialogA"> <!-- id and data-dialog = dialogA --> ... </dialog>
And, lastly, include the “close” button:
<dialog id="dialogA"> <button class="closeDialog">Close dialogA</button></dialog>
Note: <form method="dialog"> (that has a <button>) or <button formmethod="dialog"> (wrapped in a <form>) also closes the dialog.
How to prevent scrolling when the dialog is openPrevent scrolling while the modal’s open, with one line of CSS:
body:has(dialog:modal) { overflow: hidden; }
Styling the dialog’s backdropAnd finally, we have the backdrop to reduce distraction from what’s underneath the top layer (this applies to modals only). Its styles can be overwritten, like this:
::backdrop { background: hsl(0 0 0 / 90%); backdrop-filter: blur(3px); /* A fun property just for backdrops! */}
On that note, the <dialog> itself comes with a border, a background, and some padding, which you might want to reset. Actually, popovers behave the same way.
Dealing with non-modal dialogsTo implement a non-modal dialog, use:
show() instead of showModal()dialog[open] (targets both) instead of dialog:modalAlthough, as I said before, the Popover API doesn’t require JavaScript, so for non-modal overlays I think it’s best to use that.
The Popover API (<element popover>)Use case: Non-modal overlays
CodePen Embed FallbackPopups, basically. Suitable use cases include tooltips (or toggletips — it’s important to know the difference), onboarding walkthroughs, notifications, togglable navigations, and other non-modal overlays where you don’t want to lose access to the main document. Obviously these use cases are different to those of dialogs, but nonetheless popovers are extremely awesome. Functionally they’re just like just dialogs, but not modal and don’t require JavaScript.
Marking up popoversTo begin, the popover needs an id as well as the popover attribute with the manual value (which means clicking outside of the popover doesn’t close it), the auto value (clicking outside of the popover does close it), or no value (which means the same thing). To be semantic, the popover can be a <dialog>.
<dialog id="tooltipA" popover> ... </dialog>
Next, add the popovertarget attribute to the <button> or <input type="button"> that we want to toggle the popover’s visibility, with a value matching the popover’s id attribute (this is optional since clicking outside of the popover will close it anyway, unless popover is set to manual):
<dialog id="tooltipA" popover> <button popovertarget="tooltipA">Hide tooltipA</button></dialog>
Place another one of those buttons in your main document, so that you can show the popover. That’s right, popovertarget is actually a toggle (unless you specify otherwise with the popovertargetaction attribute that accepts show, hide, or toggle as its value — more on that later).
Styling popoversCodePen Embed FallbackBy default, popovers are centered within the top layer (like dialogs), but you probably don’t want them there as they’re not modals, after all.
```
``` You can easily pull them into a corner using fixed positioning, but for a tooltip-style popover you’d want it to be relative to the trigger that opens it. CSS Anchor Positioning makes this super easy:
main [popovertarget] { anchor-name: --trigger;}[popover] { margin: 0; position-anchor: --trigger; top: calc(anchor(bottom) + 10px); justify-self: anchor-center;}/* This also works but isn’t neededunless you’re using the display property[popover]:popover-open { ...}*/
The problem though is that you have to name all of these anchors, which is fine for a tabbed component but overkill for a website with quite a few tooltips. Luckily, we can match an id attribute on the button to an anchor attribute on the popover, which isn’t well-supported as of November 2024 but will do for this demo:
CodePen Embed Fallback ```
```
main [popovertarget] { anchor-name: --anchorA; } /* No longer needed */[popover] { margin: 0; position-anchor: --anchorA; /* No longer needed */ top: calc(anchor(bottom) + 10px); justify-self: anchor-center;}
The next issue is that we expect tooltips to show on hover and this doesn’t do that, which means that we need to use JavaScript. While this seems complicated considering that we can create tooltips much more easily using ::before/::after/content:, popovers allow HTML content (in which case our tooltips are actually toggletips by the way) whereas content: only accepts text.
Adding JavaScript functionalityWhich leads us to this…
CodePen Embed FallbackOkay, so let’s take a look at what’s happening here. First, we’re using anchor attributes to avoid writing a CSS block for each anchor element. Popovers are very HTML-focused, so let’s use anchor positioning in the same way. Secondly, we’re using JavaScript to show the popovers (showPopover()) on mouseover. And lastly, we’re using JavaScript to hide the popovers (hidePopover()) on mouseout, but not if they contain a link as obviously we want them to be clickable (in this scenario, we also don’t hide the button that hides the popover).
```
```
[popover] { margin: 0; top: calc(anchor(bottom) + 10px); justify-self: anchor-center; /* No link? No button needed */ &:not(:has(a)) [popovertarget] { display: none; }}
/* Select and loop all popover triggers */document.querySelectorAll("main [popovertarget]").forEach((popovertarget) => { /* Select the corresponding popover */ const popover = document.querySelector(`#${popovertarget.getAttribute("popovertarget")}`); /* Show popover on trigger mouseover */ popovertarget.addEventListener("mouseover", () => { popover.showPopover(); }); /* Hide popover on trigger mouseout, but not if it has a link */ if (popover.matches(":not(:has(a))")) { popovertarget.addEventListener("mouseout", () => { popover.hidePopover(); }); }});
Implementing timed backdrops (and sequenced popovers)At first, I was sure that popovers having backdrops was an oversight, the argument being that they shouldn’t obscure a focusable main document. But maybe it’s okay for a couple of seconds as long as we can resume what we were doing without being forced to close anything? At least, I think this works well for a set of onboarding tips:
CodePen Embed Fallback ```
```
::backdrop { animation: 2s fadeInOut;}[popover] { margin: 0; align-self: anchor-center; left: calc(anchor(right) + 10px);}
/*After users have had a couple ofseconds to breathe, start the onboarding*/setTimeout(() => { document.querySelector("#onboardingTipA").showPopover();}, 2000);
Again, let’s unpack. Firstly, setTimeout() shows the first onboarding tip after two seconds. Secondly, a simple fade-in-fade-out background animation runs on the backdrop and all subsequent backdrops. The main document isn’t made inert and the backdrop doesn’t persist, so attention is diverted to the onboarding tips while not feeling invasive.
Thirdly, each popover has a button that triggers the next onboarding tip, which triggers another, and so on, chaining them to create a fully HTML onboarding flow. Typically, showing a popover closes other popovers, but this doesn’t appear to be the case if it’s triggered from within another popover. Also, re-showing a visible popover rolls the onboarding back to that step, and, hiding a popover hides it and all subsequent popovers — although that only appears to work when popover equates to auto. I don’t fully understand it but it’s enabled me to create “restart onboarding” and “cancel onboarding” buttons.
With just HTML. And you can cycle through the tips using esc and return.
Creating modal popoversHear me out. If you like the HTML-ness of popover but the semantic value of <dialog>, this JavaScript one-liner can make the main document inert, therefore making your popovers modal:
document.querySelectorAll("dialog[popover]").forEach(dialog => dialog.addEventListener("toggle", () => document.body.toggleAttribute("inert")));
However, the popovers must come after the main document; otherwise they’ll also become inert. Personally, this is what I’m doing for modals anyway, as they aren’t a part of the page’s content.
```
``` Aaaand… breatheYeah, that was a lot. But…I think it’s important to look at all of these APIs together now that they’re starting to mature, in order to really understand what they can, can’t, should, and shouldn’t be used for. As a parting gift, I’ll leave you with a transition-enabled version of each API:
The Different (and Modern) Ways to Toggle Content originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
The State of CSS 2024 survey wrapped up and the results are interesting, as always. Even though each section is worth analyzing, we are usually most hyped about the section on the most used CSS features. And if you are interested in writing about web development (maybe start writing with us 😉), you will specifically want to check out the feature’s Reading List section. It holds the features that survey respondents wish to read about after completing the survey and is usually composed of up-and-coming features with low community awareness.
One of the features I was excited to see was my 2024 top pick: CSS Anchor Positioning, ranking in the survey’s Top 4. Just below, you can find Scroll-Driven Animations, another amazing feature that gained broad browser support this year. Both are elegant and offer good DX, but combining them opens up new possibilities that clearly fall into what most of us would have considered JavaScript territory just last year.
I want to show one of those possibilities while learning more about both features. Specifically, we will make the following blog post in which footnotes pop up as comments on the sides of each text.
CodePen Embed FallbackFor this demo, our requirements will be:
The FoundationTo start, we will use the following everyday example of a blog post layout: title, cover image, and body of text:
CodePen Embed FallbackThe only thing to notice about the markup is that now and then we have a paragraph with a footnote at the end:
```
Super intereseting information! A footnote about it
``` Positioning the FootnotesIn that demo, the footnotes are located inside the body of the post just after the text we want to note. However, we want them to be attached as floating bubbles on the side of the text. In the past, we would probably need a mix of absolute and relative positioning along with finding the correct inset properties for each footnote.
However, we can now use anchor positioning for the job, a feature that allows us to position absolute elements relative to other elements — rather than just relative to the containment context it is in. We will be talking about “anchors” and “targets” for a while, so a little terminology as we get going:
I won’t get into each detail, but if you want to learn more about it I highly recommend our Anchor Positioning Guide for complete information and examples.
The Anchor and TargetIt’s easy to know that each .footnote is a target element. Picking our anchor, however, requires more nuance. While it may look like each .note element should be an anchor element, it’s better to choose the whole .post as the anchor. Let me explain if we set the .footnote position to absolute:
.footnote { position: absolute;}
You will notice that the .footnote elements on the post are removed from the normal document flow and they hover visually above their .note elements. This is great news! Since they are already aligned on the vertical axis, we just have to move them on the horizontal axis onto the sides using the post as an anchor.
This is when we would need to find the correct inset property to place them on the sides. While this is doable, it’s a painful choice since:
Elements aren’t anchors by default, so to register the post as an anchor, we have to use the anchor-name property and give it a dashed-ident (a custom name starting with two dashes) as a name.
.post { anchor-name: --post;}
In this case, our target element would be the .footnote. To use a target element, we can keep the absolute positioning and select an anchor element using the position-anchor property, which takes the anchor’s dashed ident. This will make .post the default anchor for the target in the following step.
.footnote { position: absolute; position-anchor: --post;}
Moving the Target AroundInstead of choosing an arbitrary inset value for the .footnote‘s left or right properties, we can use the anchor() function. It returns a <length> value with the position of one side of the anchor, allowing us to always set the target’s inset properties correctly. So, we can connect the left side of the target to the right side of the anchor and vice versa:
.footnote { position: absolute; position-anchor: --post; /* To place them on the right */ left: anchor(right); /* or to place them on the left*/ right: anchor(left); /* Just one of them at a time! */}
However, you will notice that it’s stuck to the side of the post with no space in between. Luckily, the margin property works just as you are hoping it does with target elements and gives a little space between the footnote target and the post anchor. We can also add a little more styles to make things prettier:
.footnote { /* ... */ background-color: #fff; border-radius: 20px; margin: 0px 20px; padding: 20px;}
Lastly, all our .footnote elements are on the same side of the post, if we want to arrange them one on each side, we can use the nth-of-type() selector to select the even and odd notes and set them on opposite sides.
.note:nth-of-type(odd) .footnote { left: anchor(right);}.note:nth-of-type(even) .footnote { right: anchor(left);}
We use nth-of-type() instead of nth-child since we just want to iterate over .note elements and not all the siblings.
Just remember to remove the last inset declaration from .footnote, and tada! We have our footnotes on each side. You will notice I also added a little triangle on each footnote, but that’s beyond the scope of this post:
CodePen Embed FallbackThe View-Driven AnimationLet’s get into making the pop-up animation. I find it the easiest part since both view and scroll-driven animation are built to be as intuitive as possible. We will start by registering an animation using an everyday @keyframes. What we want is for our footnotes to start being invisible and slowly become bigger and visible:
@keyframes pop-up { from { opacity: 0; transform: scale(0.5); } to { opacity: 1; }}
That’s our animation, now we just have to add it to each .footnote:
.footnote { /* ... */ animation: pop-up linear;}
This by itself won’t do anything. We usually would have set an animation-duration for it to start. However, view-driven animations don’t run through a set time, rather the animation progression will depend on where the element is on the screen. To do so, we set the animation-timeline to view().
.footnote { /* ... */ animation: pop-up linear; animation-timeline: view();}
This makes the animation finish just as the element is leaving the screen. What we want is for it to finish somewhere more readable. The last touch is setting the animation-range to cover 0% cover 40%. This translates to, “I want the element to start its animation when it’s 0% in the view and end when it’s at 40% in the view.”
.footnote { /* ... */ animation: pop-up linear; animation-timeline: view(); animation-range: cover 0% cover 40%;}
This amazing tool by Bramus focused on scroll and view-driven animation better shows how the animation-range property works.
What About Mobile?You may have noticed that this approach to footnotes doesn’t work on smaller screens since there is no space at the sides of the post. The fix is easy. What we want is for the footnotes to display as normal notes on small screens and as comments on larger screens, we can do that by making our comments only available when the screen is bigger than a certain threshold, which is about 1000px. If it isn’t, then the notes are displayed on the body of the post as any other note you may find on the web.
.footnote { display: flex; gap: 10px; border-radius: 20px; padding: 20px; background-color: #fce6c2; &::before { content: "Note:"; font-weight: 600; }}@media (width > 1000px) { /* Styles */}
Now our comments should be displayed on the sides only when there is enough space for them:
CodePen Embed FallbackWrapping UpIf you also like writing about something you are passionate about, you will often find yourself going into random tangents or wanting to add a comment in each paragraph for extra context. At least, that’s my case, so having a way to dynamically show comments is a great addition. Especially when we achieved using only CSS — in a way that we couldn’t just a year ago!
Popping Comments With CSS Anchor Positioning and View-Driven Animations originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
We all know how to do responsive design, right? We use media queries. Well no, we use container queries now, don’t we? Sometimes we get inventive with flexbox or autoflowing grids. If we’re feeling really adventurous we can reach for fluid typography.
I’m a bit uncomfortable that responsive design is often pushed into discreet chunks, like “layout A up to this size, then layout B until there’s enough space for layout C.” It’s OK, it works and fits into a workflow where screens are designed as static layouts in PhotoFigVa (caveat, I made that up). But the process feels like a compromise to me. I’ve long believed that responsive design should be almost invisible to the user. When they visit my site on a mobile device while waiting in line for K-Pop tickets, they shouldn’t notice that it’s different from just an hour ago, sitting at the huge curved gaming monitor they persuaded their boss they needed.
Consider this simple hero banner and its mobile equivalent. Sorry for the unsophisticated design. The image is AI generated, but It’s the only thing about this article that is.
The meerkat and the text are all positioned and sized differently. The traditional way to pull this off is to have two layouts, selected by a ~~media~~, sorry, container query. There might be some flexibility in each layout, perhaps centering the content, and a little fluid typography on the font-size, but we’re going to choose a point at which we flip the layout in and out of the stacked version. As a result, there are likely to be widths near the breakpoint where the layout looks either a little empty or a little congested.
Is there another way?
It turns out there is. We can apply the concept of fluid typography to almost anything. This way we can have a layout that fluidly changes with the size of its parent container. Few users will ever see the transition, but they will all appreciate the results. Honestly, they will.
Let’s get this styled upFor the first step, let’s style the layouts individually, a little like we would when using width queries and a breakpoint. In fact, let’s use a container query and a breakpoint together so that we can easily see what properties need to change.
This is the markup for our hero, and it won’t change:
```
Eagle Defense System
``` This is the relevant CSS for the wide version:
```
``
I’ve attached the background image to a::beforepseudo-element so I can use container queries on it (because containers cannot query themselves). We’ll keep this later on so that we can use inline container query (cqi`) units. For now, here’s the container query that just shows the values we’re going to make fluid:
@container (max-width: 800px) { #hero { .details { top: 50px; left: 20px; h1 { font-size: 3.5rem; } p { font-size: 2rem; } } &::before { background-position-x: -310px; background-position-y: -25px; background-size: auto 710px; } }}
You can see the code running in a live demo — it’s entirely static to show the limitations of a typical approach.
Let’s get fluidNow we can take those start and end points for the size and position of both the text and background and make them fluid. The text size uses fluid typography in a way you are already familiar with. Here’s the result — I’ll explain the expressions once you’ve looked at the code.
First the changes to the position and size of the text:
/* Line changes * -12,27 +12,32 */ .details { /* ... lines 14-16 unchanged */ /* Evaluates to 50px for a 360px wide container, and 220px for 1200px */ top: clamp(50px, 20.238cqi - 22.857px, 220px); /* Evaluates to 20px for a 360px wide container, and 565px for 1200px */ left: clamp(20px, 64.881cqi - 213.571px, 565px); /* ... lines 20-25 unchanged */ h1 { /* Evaluates to 3.5rem for a 360px wide container, and 5rem for 1200px */ font-size: clamp(3.5rem, 2.857rem + 2.857cqi, 5rem); /* ... font-weight unchanged */ } p { /* Evaluates to 2rem for a 360px wide container, and 2.5rem for 1200px */ font-size: clamp(2rem, 1.786rem + 0.952cqi, 2.5rem); }}
And here’s the background position and size for the meerkat image:
/* Line changes * -50,3 +55,8 *//* Evaluates to -310px for a 360px wide container, and 0px for 1200px */background-position-x: clamp(-310px, 36.905cqi - 442.857px, 0px);/* Evaluates to -25px for a 360px wide container, and 0px for 1200px */background-position-y: clamp(-25px, 2.976cqi);/* Evaluates to 710px for a 360px wide container, and 589px for 1200px */background-size: auto clamp(589px, 761.857px - 14.405cqi, 710px);
Now we can drop the container query entirely.
Let’s explain those clamp() expressions. We’ll start with the expression for the top property.
/* Evaluates to 50px for a 360px wide container, and 220px for 1200px */top: clamp(50px, 20.238cqi - 22.857px, 220px);
You’ll have noticed there’s a comment there. These expressions are a good example of how magic numbers are a bad thing. But we can’t avoid them here, as they are the result of solving some simultaneous equations — which CSS cannot do!
The upper and lower bounds passed to clamp() are clear enough, but the expression in the middle comes from these simultaneous equations:
f + 12v = 220f + 3.6v = 50
…where f is the number of fixed-size length units (i.e., px) and v is the variable-sized unit (cqi). In the first equation, we are saying that we want the expression to evaluate to 220px when 1cqi is equal to 12px. In the second equation, we’re saying we want 50px when 1cqi is 3.6px, which solves to:
f = -22.857v = 20.238
…and this tidies up to 20.238cqi – 22.857px in a calc()-friendly expression.
When the fixed unit is different, we must change the size of the variable units accordingly. So for the <h1> element’s font-size we have;
/* Evaluates to 2rem for a 360px wide container, and 2.5rem for 1200px */font-size: clamp(2rem, 1.786rem + 0.952cqi, 2.5rem);
This is solving these equations because, at a container width of 1200px, 1cqi is the same as 0.75rem (my rems are relative to the default UA stylesheet, 16px), and at 360px wide, 1cqi is 0.225rem.
f + 0.75v = 2.5f + 0.225v = 2
This is important to note: The equations are different depending on what unit you are targeting.
Honestly, this is boring math to do every time, so I made a calculator you can use. Not only does it solve the equations for you (to three decimal places to keep your CSS clean) it also provides that helpful comment to use alongside the expression so that you can see where they came from and avoid magic numbers. Feel free to use it. Yes, there are many similar calculators out there, but they concentrate on typography, and so (rightly) fixate on rem units. You could probably port the JavaScript if you’re using a CSS preprocessor.
The clamp() function isn’t strictly necessary at this point. In each case, the bounds of clamp() are set to the values of when the container is either 360px or 1200px wide. Since the container itself is constrained to those limits — by setting min-width and max-width values — the clamp() expression should never invoke either bound. However, I prefer to keep clamp() there in case we ever change our minds (which we are about to do) because implicit bounds like these are difficult to spot and maintain.
Avoiding injuryWe could consider our work finished, but we aren’t. The layout still doesn’t quite work. The text passes right over the top of the meerkat’s head. While I have been assured this causes the meerkat no harm, I don’t like the look of it. So, let’s make some changes to make the text avoid hitting the meerkat.
The first is simple. We’ll move the meerkat to the left more quickly so that it gets out of the way. This is done most easily by changing the lower end of the interpolation to a wider container. We’ll set it so that the meerkat is fully left by 450px rather than down to 360px. There’s no reason the start and end points for all of our fluid expressions need to align with the same widths, so we can keep the other expressions fluid down to 360px.
Using my trusty calculator, all we need to do is change the clamp() expressions for the background-position properties:
/* Line changes * -55,5 +55,5 *//* Evaluates to -310px for a 450px wide container, and 0px for 1200px */background-position-x: clamp(-310px, 41.333cqi - 496px, 0px);/* Evaluates to -25px for a 450px wide container, and 0px for 1200px */background-position-y: clamp(-25px, 3.333cqi - 40px, 0px);
This improves things, but not totally. I don’t want to move it any quicker, so next we’ll look at the path the text takes. At the moment it moves in a straight line, like this:
But can we bend it? Yes, we can.
A Bend in the pathOne way we can do this is by defining two different interpolations for the top coordinate that places the line at different angles and then choosing the smallest one. This way, it allows the steeper line to “win” at larger container widths, and the shallower line becomes the value that wins when the container is narrower than about 780px. The result is a line with a bend that misses the meerkat.
All we’re changing is the top value, but we must calculate two intermediate values first:
/* Line changes * -18,2 +18,9 @@ *//* Evaluates to 220px for a 1200px wide container, and -50px for 360px */--top-a: calc(32.143cqi - 165.714px);/* Evaluates to 120px for a 1200px wide container, and 50px for 360px */--top-b: calc(20px + 8.333cqi);/* By taking the max, --topA is used at lower widths, with --topB taking over when wider.We only need to apply clamp when the value is actually used */top: clamp(50px, max(var(--top-a), var(--top-b)), 220px);
For these values, rather than calculating them formally using a carefully chosen midpoint, I experimented with the endpoints until I got the result I wanted. Experimentation is just as valid as calculation as a way of getting the result you need. In this case, I started with duplicates of the interpolation in custom variables. I could have split the path into explicit sections using a container query, but that doesn’t reduce the math overhead, and using the min() function is cleaner to my eye. Besides, this article isn’t strictly about container queries, is it?
Now the text moves along this path. Open up the live demo to see it in action.
CSS can’t do everythingAs a final note on the calculations, it’s worth pointing out that there are restrictions as far as what we can and can’t do. The first, which we have already mitigated a little, is that these interpolations are linear. This means that easing in or out, or other complex behavior, is not possible.
Another major restriction is that CSS can only generate length values this way, so there is no way in pure CSS to apply, for example, opacity or a rotation angle that is fluid based on the container or viewport size. Preprocessors can’t help us here either because the limitation is on the way calc() works in the browser.
Both of these restrictions can be lifted if you’re prepared to rely on a little JavaScript. A few lines to observe the width of the container and set a CSS custom property that is unitless is all that’s needed. I’m going to use that to make the text follow a quadratic Bezier curve, like this:
There’s too much code to list here, and too much math to explain the Bezier curve, but go take a look at it in action in this live demo.
We wouldn’t even need JavaScript if expressions like calc(1vw / 1px) didn’t fail in CSS. There is no reason for them to fail since they represent a ratio between two lengths. Just as there are 2.54cm in 1in, there are 8px in 1vw when the viewport is 800px wide, so calc(1vw / 1px) should evaluate to a unitless 8 value.
They do fail though, so all we can do is state our case and move on.
Fluid everything doesn’t solve all layoutsThere will always be some layouts that need size queries, of course; some designs will simply need to snap changes at fixed breakpoints. There is no reason to avoid that if it’s right. There is also no reason to avoid mixing the two, for example, by fluidly sizing and positioning the background while using a query to snap between grid definitions for the text placement. My meerkat example is deliberately contrived to be simple for the sake of demonstration.
One thing I’ll add is that I’m rather excited by the possibility of using the new Anchor Positioning API for fluid positioning. There’s the possibility of using anchor positioning to define how two elements might flow around the screen together, but that’s for another time.
Fluid Everything Else originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
We had fun in my previous article exploring the goodness of scrolly animations supported in today’s versions of Chrome and Edge (and behind a feature flag in Firefox for now). Those are by and large referred to as “scroll-driven” animations. However, “scroll triggering” is something the Chrome team is still working on. It refers to the behavior you might have seen in the wild in which a point of no return activates a complete animation like a trap after our hapless scrolling user ventures past a certain point. You can see JavaScript examples of this on the Wow.js homepage which assembles itself in a sequence of animated entrances as you scroll down. There is no current official CSS solution for scroll-triggered animations — but Ryan Mulligan has shown how we can make it work by cleverly combining the animation-timeline property with custom properties and style queries.
That is a very cool way to combine new CSS features. But I am not done being overly demanding toward the awesome emergent animation timeline technology I didn’t know existed before I read up on it last month. I noticed scroll timelines and view timelines are geared toward animations that play backward when you scroll back up, unlike the Wow.js example where the dogs roll in and then stay. Bramus mentions the same point in his exploration of scroll-triggered animations. The animations run in reverse when scrolling back up. This is not always feasible. As a divorced Dad, I can attest that the Tinder UI is another example of a pattern in which scrolling and swiping can have irreversible consequences.
Scroll till the cows come home with Web-Slinger.cssBelieve it or not, with a small amount of SCSS and no JavaScript, we can build a pure CSS replacement of the Wow.js library, which I hereby christen “Web-Slinger.css.” It feels good to use the scroll-driven optimized standards already supported by some major browsers to make a prototype library. Here’s the finished demo and then we will break down how it works. I have always enjoyed the deliberately lo-fi aesthetic of the original Wow.js page, so it’s nice to have an excuse to create a parody. Much profession, so impress.
CodePen Embed FallbackTeach scrolling elements to roll over and stayWeb-Slinger.css introduces a set of class names in the format .scroll-trigger-n and .on-scroll-trigger-n. It also defines --scroll-trigger-n custom properties, which are inherited from the document root so we can access them from any CSS class. These conventions are more verbose than Wow.js but also more powerful. The two types of CSS classes decouple the triggers of our one-off animations from the elements they trigger, which means we can animate anything on the page based on the user reaching any scroll marker.
Here’s a basic example that triggers the Animate.css animation “flipInY” when the user has scrolled to the <div> marked as .scroll-trigger-8.
```
```
A more advanced use is the sticky “Cownter” (trademark pending) at the top of the demo page, which takes advantage of the ability of one trigger to activate an arbitrary number of animations anywhere in the document. The Cownter increments as new cows appear then displays a reset button once we reach the final scroll trigger at the bottom of the page.
Here is the markup for the Cownter:
```
``` …and the CSS:
.header { .cownter::after { --cownter: calc(var(--scroll-trigger-2) + var(--scroll-trigger-4) + var(--scroll-trigger-8) + var(--scroll-trigger-11)); --pluralised-cow: 'cows'; counter-set: cownter var(--cownter); content: "Have " counter(cownter) " " var(--pluralised-cow) ", man"; } @container style(--scroll-trigger-2: 1) and style(--scroll-trigger-4: 0) { .cownter::after { --pluralised-cow: 'cow'; } } a { text-decoration: none; color:blue; }}:root:has(.reset:active) * { animation-name: none;}
The demo CodePen references Web-Slinger.css from a separate CodePen, which I reference in my final demo the same way I would an external resource.
Sidenote: If you have doubts about the utility of style queries, behold the age-old cow pluralization problem solved in pure CSS.
How does Web Slinger like to sling it?The secret is based on an iconic thought experiment by the philosopher Friedrich Nietzsche who once asked: If the view() function lets you style an element once it comes into view, what if you take that opportunity to style it so it can never be scrolled out of view? Would that element not stare back into you for eternity?
.scroll-trigger { animation-timeline: view(); animation-name: stick-to-the-top; animation-fill-mode: both; animation-duration: 1ms;}@keyframes stick-to-the-top { .1%, to { position: fixed; top: 0; }}
This idea sounded too good to be true, reminiscent of the urge when you meet a genie to ask for unlimited wishes. But it works! The next puzzle piece is how to use this one-way animation technique to control something we’d want to display to the user. Divs that instantly stick to the ceiling as soon as they enter the viewport might have their place on a page discussing the movie Alien, but most of the time this type of animation won’t be something we want the user to see.
That’s where named view progress timelines come in. The empty scroll trigger element only has the job of sticking to the top of the viewport as soon as it enters. Next, we set the timeline-scope property of the <body> element so that it matches the sticky element’s view-timeline-name. Now we can apply Ryan’s toggle custom property and style query tricks to let each sticky element trigger arbitrary one-off animations anywhere on the page!
View CSS code
/** Each trigger element will cause a toggle named with * the convention `--scroll-trigger-n` to be flipped * from 0 to 1, which will unpause the animation on * any element with the class .on-scroll-trigger-n **/:root { animation-name: run-scroll-trigger-1, run-scroll-trigger-2 /*etc*/; animation-duration: 1ms; animation-fill-mode: forwards; animation-timeline: --trigger-timeline-1, --trigger-timeline-2 /*etc*/; timeline-scope: --trigger-timeline-1, --trigger-timeline-2 /*etc*/;}@property --scroll-trigger-1 { syntax: "<integer>"; initial-value: 0; inherits: true;}@keyframes run-scroll-trigger-1 { to { --scroll-trigger-1: 1; }}/** Add this class to arbitrary elements we want * to only animate once `.scroll-trigger-1` has come * into view, default them to paused state otherwise **/.on-scroll-trigger-1 { animation-play-state: paused;}/** The style query hack will run the animations on * the element once the toggle is set to true **/@container style(--scroll-trigger-1: 1) { .on-scroll-trigger-1 { animation-play-state: running; }}/** The trigger element which sticks to the top of * the viewport and activates the one-way animation * that will unpause the animation on the * corresponding element marked with `.on-scroll-trigger-n` **/.scroll-trigger-1 { view-timeline-name: --trigger-timeline-1;}
Trigger warningWe generate the genericized Web-Slinger.css in 95 lines of SCSS, which isn’t too bad. The drawback is that the more triggers we need, the larger the compiled CSS file. The numbered CSS classes also aren’t semantic, so it would be great to have native support for linking a scroll-triggered element to its trigger based on IDs, reminiscent of the popovertarget attribute for HTML buttons — except this hypothetical attribute would go on each target element and specify the ID of the trigger, which is the opposite of the way popovertarget works.
```
``` Do androids dream of standardized scroll triggers?As I mentioned at the start, Bramus has teased that scroll-triggered animations are something we’d like to ship in a future version of Chrome, but it still needs a bit of work before we can do that. I’m looking forward to standardized scroll-triggered animations built into the browser. We could do worse than a convention resembling Web-Slinger.css for declaratively defining scroll-triggered animations, but I know I am not objective about Web Slinger as its creator. It’s become a bit of a sacred cow for me so I shall stop milking the topic — for now.
Feel free to reference the prototype Web-Slinger.css library in your experimental CodePens, or fork the library itself if you have better ideas about how scroll-triggered animations could be standardized.
Web-Slinger.css: Like Wow.js But With CSS-y Scroll Animations originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
They’re out! Like many of you, I look forward to these coming out each year. I don’t put much stock in surveys but they can be insightful and give a snapshot of the CSS zeitgeist. There are a few little nuggets in this year’s results that I find interesting. But before I get there, you’ll want to also check out what others have already written about it.
Oh, I guess that’s it — at least it’s the most formal write-up I’ve seen. There’s a little summary by Ahmad Shadeed at the end of the survey that generally rounds things up. I’ll drop in more links as I find ’em.
In no particular order…
DemographicsJosh has way more poignant thoughts on this than I do. He rightfully calls out discrepancies in gender pay and regional pay, where men are way more compensated than women (a nonsensical and frustratingly never-ending trend) and the United States boasts more $100,000 salaries than anywhere else. The countries with the highest salaries were also the most represented in survey responses, so perhaps the results are no surprise. We’re essentially looking at a snapshot of what it’s like to be a rich, white male developer in the West.
Besides pay, my eye caught the Age Group demographics. As an aging front-ender, I often wonder what we all do when we finally get to retirement age. I officially dropped from the most represented age group (30-39, 42%) a few years ago into the third most represented tier (40-49, 21%). Long gone are my days being with the cool kids (20-29, 27%).
And if the distribution is true to life, I’m riding fast into my sunset years and will be only slightly more represented than those getting into the profession. I don’t know if anyone else feels similarly anxious about aging in this industry — but if you’re one of the 484 folks who identify with the 50+ age group, I’d love to talk with you.
Before we plow ahead, I think it’s worth calling out how relatively “new” most people are to front-end development.
Wow! Forty-freaking-four percent of respondents have less than 10 years of experience. Yes, 10 years is a high threshold, but we’re still talking about a profession that popped up in recent memory.
For perspective, someone developing for 10 years came to the field around 2014. That’s just when we were getting Flexbox, and several years after the big bang of CSS 3 and HTML 5. That’s just under half of developers who never had to deal with the headaches of table layouts, clearfix hacks, image sprites, spacer images, and rasterized rounded corners. Ethan Marcotte’s seminal article on “Responsive Web Design” predates these folks by a whopping four years!
That’s just wild. And exciting. I’m a firm believer in the next generation of front-enders but always hope that they learn from our past mistakes and become masters at the basics.
FeaturesI’m not entirely sure what to make of this section. When there are so many CSS features, how do you determine which are most widely used? How do you pare it down to just 50 features? Like, are filter effects really the most widely used CSS feature? So many questions, but the results are always interesting nonetheless.
What I find most interesting are the underused features. For example, hanging-punctuation comes in dead last in usage (1.57%) but is the feature that most developers (52%) have on their reading list. (If you need some reading material on it, Chris initially published the Almanac entry for hanging-punctuation back in 2013.)
I also see Anchor Positioning at the end of the long tail with reported usage at 4.8%. That’ll go up for sure now that we have at least one supporting browser engine (Chromium) but also given all of the tutorials that have sprung up in the past few months. Yes, we’ve contributed to that noise… but it’s good noise! I think Juan published what might be the most thorough and thoughtful guide on the topic yet.
I’m excited to see Cascade Layers falling smack dab in the middle of the pack at a fairly robust 18.7%. Cascade Layers are super approachable and elegantly designed that I have trouble believing anybody these days when they say that the CSS Cascade is difficult to manage. And even though @scope is currently low on the list (4.8%, same as Anchor Positioning), I’d bet the crumpled gum wrapper in my pocket that the overall sentiment of working with the Cascade will improve dramatically. We’ll still see “CSS is Awesome” memes galore, but they’ll be more like old familiar dad jokes in good time.
(Aside: Did you see the proposed designs for a new CSS logo? You can vote on them as of yesterday, but earlier versions played off the “CSS is Awesome” mean quite beautifully.)
Interestingly enough, viewport units come in at Number 11 with 44.2% usage… which lands them at Number 2 for most experience that developers have with CSS layout. Does that suggest that layout features are less widely used than CSS filters? Again, so many questions.
FrameworksHow many of you were surprised that Tailwind blew past Bootstrap as Top Dog framework in CSS Land? Nobody, right?
More interesting to me is that “No CSS framework” clocks in at Number 13 out of 21 list frameworks. Sure, its 46 votes are dwarfed by the 138 for Material UI at Number 10… but the fact that we’re seeing “no framework” as a ranking option at all would have been unimaginable just three years ago.
The same goes for CSS pre/post-processing. Sass (67%) and PostCSS (38%) are the power players, but “None” comes in third at 19%, ahead of Less, Stylus, and Lightning CSS.
It’s a real testament to the great work the CSSWG is doing to make CSS better every day. We don’t thank the CSSWG enough — thank you, team! Y’all are heroes around these parts.
CSS UsageJosh already has a good take on the fact that only 67% of folks say they test their work on mobile phones. It should be at least tied with the 99% who test on desktops, right? Right?! Who knows, maybe some responses consider things like “Responsive Design Mode” desktop features to be the equivalent of testing on real mobile devices. I find it hard to believe that only 67% of us test mobile.
Oh, and The Great Divide is still alive and well if the results are true and 53% write more JavsScript than CSS in their day-to-day.
Missing CSS FeaturesThis is always a fun topic to ponder. Some of the most-wanted CSS features have been lurking around 10+ years. But let’s look at the top three form this year’s survey:
We’re in luck team! There’s movement on all three of those fronts:
if() conditional to the CSS Values Module Level 5 specification. (Read our notes.)ResourcesThis is where I get to toot our own horn a bit because CSS-Tricks continues to place first among y’all when it comes to the blogs you follow for CSS happenings.
I’m also stoked to see Smashing Magazine right there as well. It was fifth in 2023 and I’d like to think that rise is due to me joining the team last year. Correlation implies causation, amirite?
But look at Kevin Powell and Josh in the Top 10. That’s just awesome. It speaks volumes about their teaching talents and the hard work they put into “helping people fall in love with CSS” as Kevin might say it. I was able to help Kevin with a couple of his videos last year (here’s one) and can tell you the guy cares a heckuva lot about making CSS approachable and fun.
Honestly, the rankings are not what we live for. Now that I’ve been given a second wind to work on CSS-Tricks, all I want is to publish things that are valuable to your everyday work as front-enders. That’s traditionally happened as a stream of daily articles but is shifting to more tutorials and resources, whether it’s guides (we’ve published four new ones this year), taking notes on interesting developments, spotlighting good work with links, or expanding the ol’ Almanac to account for things like functions, at-rules, and pseudos (we have lots of work to do).
My 2024 PickNo one asked my opinion but I’ll say it anyway: Personal blogging. I’m seeing more of us in the front-end community getting back behind the keyboards of their personal websites and I’ve never been subscribed to more RSS feeds than I am today. Some started blogging as a “worry stone” during the 2020 lockdown. Some abandoned socials when ~~Twitter~~ X imploded. Some got way into the IndieWeb. Webrings and guestbooks are even gaining new life. Sure, it can be tough keeping up, but what a good problem to have! Let’s make RSS king once and for all.
That’s a wrap!Seriously, a huge thanks to Sacha Greif and the entire Devographics team for the commitment to putting this survey together every year. It’s always fun. And the visualizations are always to die for.
State of CSS 2024 Results originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
In this article, I try to summarize the best practices mentioned by various accessibility experts and their work (like this, this, and this) into a single article that’s easy to read, understand, and apply.
Let’s begin.
What are tooltips?Tooltips are used to provide simple text hints for UI controls. Think of them as tips for tools. They’re basically little bubbles of text content that pop up when you hover over an unnamed control (like the bell icon in Stripe).
The “Notifications” text that pops up when you hover over Stripe’s bell icon is a tooltip.If you prefer more of a formal definition, Sarah Highley provides us with a pretty good one:
A “tooltip” is a non-modal (or non-blocking) overlay containing text-only content that provides supplemental information about an existing UI control. It is hidden by default, and becomes available on hover or focus of the control it describes.
She further goes on to say:
That definition could even be narrowed down even further by saying tooltips must provide only descriptive text.
This narrowed definition is basically (in my experience) how every accessibility expert defines tooltips:
Heydon Pickering takes things even further, saying: If you’re thinking of adding interactive content (even an ok button), you should be using dialog instead.
In his words:
You’re thinking of dialogs. Use a dialog.
Two kinds of tooltipsTooltips are basically only used for two things:
Heydon separates these cleanly into two categories, “Primary Label” and “Auxiliary description” in his Inclusive Components article on tooltips and toggletips).
LabelingIf your tooltip is used to label an icon — using only one or two words — you should use the aria-labelledby attribute to properly label it since it is attached to nothing else on the page that would help identify it.
<button aria-labelledby="notifications"> ... </button><div role="tooltip" id="notifications">Notifications</div>
You could provide contextual information, like stating the number of notifications, by giving a space-separated list of ids to aria-labelledby.
<button aria-labelledby="notifications-count notifications-label"> <!-- bell icon here --> <span id="notifications-count">3</span></button> <div role="tooltip" id="notifications-label">Notifications</div>
Providing contextual descriptionIf your tooltip provides a contextual description of the icon, you should use aria-describedby. But, when you do this, you also need to provide an accessible name for the icon.
In this case, Heydon recommends including the label as the text content of the button. This label would be hidden visually from sighted users but read for screen readers.
Then, you can add aria-describedby to include the auxiliary description.
<button class="notifications" aria-describedby="notifications-desc"> <!-- icon for bell here --> <span id="notifications-count">3</span> <span class="visually-hidden">Notifications</span></button> <div role="tooltip" id="notifications-desc">View and manage notifications settings</div>
Here, screen readers would say “3 notifications” first, followed by “view and manage notifications settings” after a brief pause.
Additional tooltip dos and don’tsHere are a couple of additional points you should be aware of:
Do:
aria-labellebdy or aria-describedby attributes depending on the type of tooltip you’re building.tooltip role even if it doesn’t do much in screen readers today, because it may extend accessibility support for some software.mouseover or focus, and close them on mouseout or blur.Escape button, per WCAG Success Criterion 1.4.13.Don’t:
title attribute. Much has been said about this so I shall not repeat them.aria-haspopup attribute with the tooltip role because aria-haspopup signifies interactive content while tooltip should contain non-interactive content.aria-labelledby or aria-describedby. (It’s rare, but possible.)Tooltip limitations and alternativesTooltips are inaccessible to most touch devices because:
The best alternative is not to use tooltips, and instead, find a way to include the label or descriptive text in the design.
If the “tooltip” contains a lot of content — including interactive content — you may want to display that information with a Toggletip (or just use a <dialog> element).
Heydon explains toggletips nicely and concisely:
Toggletips exist to reveal information balloons. Often they take the form of little “i” icons.
These informational icons should be wrapped within a <button> element. When opened, screen readers can announce the text contained in it through a live region with the status role.
<span class="tooltip-container"> <button type="button" aria-label="more info">i</button> <span role="status">This clarifies whatever needs clarifying</span></span>
Speaking anymore about toggletips detracts this article from tooltips so I’ll point you to Heydon’s “Tooltips and Toggletips” article if you’re interested in chasing this short rabbit hole.
That’s all you need to know about tooltips and their current best practices!
Further reading* Clarifying the Relationship Between Popovers and Dialogs (Zell Liew)
* Tooltips and Toggletips (Inclusive Components)
* Tooltips in the time of WCAG 2.1 (Sarah Higley)
* Short note on aria-label, aria-labelledby, and aria-describedby (Léonie Watson)
* Some Hands-On with the HTML Dialog Element (Chris Coyier)
Tooltip Best Practices originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
You’d be forgiven for thinking coding up both a dark and a light mode at once is a lot of work. You have to remember @media queries based on prefers-color-scheme as well as extra complications that arise when letting visitors choose whether they want light or dark mode separately from the OS setting. And let’s not forget the color palette itself! Switching from a “light” mode to a “dark” mode may involve new variations to get the right amount of contrast for an accessible experience.
It is indeed a lot of work. But I’m here to tell you it’s now a lot simpler with modern CSS!
Default HTML color scheme(s)We all know the “naked” HTML theme even if we rarely see it as we’ve already applied a CSS reset or our favorite boilerplate CSS before we even open localhost. But here’s a news flash: HTML doesn’t only have the standard black-on-white theme, there is also a native white-on-black version.
We have two color schemes available to use right out of the box!If you want to create a dark mode interface, this is a great base to work with and saves you from having to account for annoying details, like dark inputs, buttons, and other interactive elements.
Live Demo on CodePenSwitching color schemes automatically based on OS preferenceWithout any @media queries — or any other CSS at all — if all we did was declare color-scheme: light dark on the root element, the page will apply either the light or dark color scheme automatically by looking at the visitor’s operating system (OS) preferences. Most OSes have a built-in accessibility setting for your preferred color scheme — “light”, “dark”, or even “auto” — and browsers respect that setting.
html { color-scheme: light dark;}
We can even accomplish this without CSS directly in the HTML document in a <meta> tag:
<meta name="color-scheme" content="light dark">
Whether you go with CSS or the HTML route, it doesn’t matter — they both work the same way: telling the browser to make both light and dark schemes available and apply the one that matches the visitor’s preferences. We don’t even need to litter our styles with prefers-color-scheme instances simply to swap colors because the logic is built right in!
You can apply light or dark values to the color-scheme property. At the same time, I’d say that setting color-scheme: light is redundant, as this is the default color scheme with or without declaring it.
You can, of course, control the <meta> tag or the CSS property with JavaScript.
There’s also the possibility of applying the color-scheme property on specific elements instead of the entire page in one fell swoop. Then again, that means you are required to explicitly declare an element’s color and background-color properties; otherwise the element is transparent and inherits its text color from its parent element.
What values should you give it? Try:
Default text and background color variablesThe “black” colors of these native themes aren’t always completely black but are often off-black, making the contrast a little easier on the eyes. It’s worth noting, too, that there’s variation in the blackness of “black” between browsers.
What is very useful is that this default not-pure-black and maybe-not-pure-white background-color and text color are available as <system-color> variables. They also flip their color values automatically with color-scheme!
They are: Canvas and CanvasText.
These two variables can be used anywhere in your CSS to call up the current default background color (Canvas) or text color (CanvasText) based on the current color scheme. If you’re familiar with the currentColor value in CSS, it seems to function similarly. CanvasText, meanwhile, remains the default text color in that it can’t be changed the way currentColor changes when you assign something to color.
In the following examples, the only change is the color-scheme property:
Not bad! There are many, many more of these system variables. They are case-insensitive, often written in camelCase or PascalCase for readability. MDN lists 19 <system-color> variables and I’m dropping them in below for reference.
Open to view 19 system color names and descriptions * AccentColor: The background color for accented user interface controls
* AccentColorText: The text color for accented user interface controls
* ActiveText: The text color of active links
* ButtonBorder: The base border color for controls
* ButtonFace: The background color for controls
* ButtonText: The text color for controls
* Canvas: The background color of an application’s content or documents
* CanvasText: The text color used in an application’s content or documents
* Field: The background color for input fields
* FieldText: The text color inside form input fields
* GrayText: The text color for disabled items (e.g., a disabled control)
* Highlight: The background color for selected items
* HighlightText: The text color for selected items
* LinkText: The text color used for non-active, non-visited links
* Mark: The background color for text marked up in a <mark> element
* MarkText: The text color for text marked up in a <mark> element
* SelectedItem: The background color for selected items (e.g., a selected checkbox)
* SelectedItemText: The text color for selected items
* VisitedText: The text visited links
Cool, right? There are many of them! There are, unfortunately, also discrepancies as far as how these color keywords are used and rendered between different OSes and browsers. Even though “evergreen” browsers arguably support all of them, they don’t all actually match what they’re supposed to, and fail to flip with the CSS color-scheme property as they should.
Egor Kloos (also known as dutchcelt) is keeping an eye on the current status of system colors, including which ones exist and the browsers that support them, something he does as part of a classless CSS framework cleverly called system.css.
CodePen Embed FallbackDeclaring colors for both modes togetherOK good, so now you have a page that auto-magically flips dark and light colors according to system preferences. Whether you choose to use these system colors or not is up to you. I just like to point out that “dark” doesn’t always have to mean pure “black” just as “light” doesn’t have to mean pure “white.” There are lots more colors to pair together!
But what’s the best or simplest way to declare colors so they work in both light and dark mode?
In my subjective reverse-best order:
Third place: Declare color opacityYou could keep all the same background colors in dark and light modes, but declare them with an opacity (i.e. rgb(128 0 0 / 0.5) or #80000080). Then they’ll have the Canvas color shine through.
It’s unusable in this way for text colors, and you may end up with somewhat muted colors. But it is a nice easy way to get some theming done fast. I did this for the code blocks on this old light and dark mode demo.
Second place: Use color-mix()Like this:
color-mix(in oklab, Canvas 75%, RebeccaPurple);
Similar (but also different) to using opacity to mute a color is mixing colors in CSS. We can even mix the system color variables! For example, one of the colors can be either Canvas or CanvasText so that the background color always mixes with Canvas and the text color always mixes with CanvasText.
We now have the CSS color-mix() function to help us with this. The first argument in the function defines the color space where the color mixing happens. For example, we can tell the function that we are working in the OKLAB color space, which is a rectangular color space like sRGB making it ideal to mix with sRGB color values for predictable results. You can certainly mix colors from different color spaces — the OKLAB/sRGB combination happens to work for me in this instance.
The second and third arguments are the colors you want to mix, and in what proportion. Proportions are optional but expressed in percentages. Without declaring a proportion, the mix is an even 50%-50% split. If you add percentages for both colors and they don’t match up to 100%, it does a little math for you to prevent breakages.
The color-mix() approach is useful if you’re happy to keep the same hues and color saturations regardless of whether the mode is light or dark.
In this example, as you change the value of the hue slider, you’ll see color changes in the themed boxes, following the theme color but mixed with Canvas and CanvasText:
CodePen Embed FallbackYou may have noticed that I used OKLCH and HSL color spaces in that last example. You may also have noticed that the HSL-based theme color and the themed paragraph were a lot more “flashy” as you moved the hue slider.
I’ve declared colors using a polar color space, like HSL, for years, loving that you can easily take a hue and go up or down the saturation and lightness scales based on need. But, I concede that it’s problematic if you’re working with multiple hues while trying to achieve consistent perceived lightness and saturation across them all. It can be difficult to provide ample contrast across a spectrum of colors with HSL.
The OKLCH color space is also polar just like HSL, with the same benefits. You can pick your hue and use the chroma value (which is a bit like saturation in HSL) and the lightness scales accurately in the same way. Both OKLCH and OKLAB are designed to better match what our eyes perceive in terms of brightness and color compared to transitioning between colors in the sRGB space.
While these color spaces may not explicitly answer the age-old question, Is my blue the same as your blue? the colors are much more consistent and require less finicking when you decide to base your whole website’s palette on a different theme color. With these color spaces, the contrasts between the computed colors remain much the same.
First place (winner!): Use light-dark()Like this:
light-dark(lavender, saddlebrown);
With the previous color-mix() example, if you choose a pale lavender in light mode, its dark mode counterpart is very dark lavender.
The light-dark() function, conversely, provides complete control. You might want that element to be pale lavender in light mode and a deep burnt sienna brown in dark mode. Why not? You can still use color-mix() within light-dark() if you like — declare the colors however you like, and gain much more fine-grained control over your colors.
Feel free to experiment in the following editable demo:
CodePen Embed FallbackUsing color-scheme: light dark; — or the corresponding meta tag in HTML on your page —is a prerequisite for the light-dark() function because it allows the function to respect a person’s system preference, or whichever single light or dark value you have set on color-scheme.
Another consideration is that light-dark() is newly available across browsers, with just over 80% coverage across all users at the time I’m writing this. So, you might consider including a fallback in your CSS for browsers that lack support for the function.
What makes using color-scheme and light-dark() better than using @media queries?@media queries have been excellent tools, but using them to query prefers-color-scheme only ever follows the preference set within the person’s operating system. This is fine until you (rightfully) want to offer the visitor more choices, decoupled from whether they prefer the UI on their device to be dark or light.
We’re already capable of doing that, of course. We’ve become used to a lot of jiggery-pokery with extra CSS classes, using duplicated styles, or employing custom properties to make it happen.
The joy of using color-scheme is threefold:
light-dark() functions will follow it.Light, dark, and auto mode controlsEssentially, all we are doing is setting one of three options for whether the color-scheme is light, dark, or updates auto-matically.
I advise offering all three as discrete options, as it removes some complications for you! Any new visitor to the site will likely be in auto mode because accepting the visitor’s OS setting is the least jarring default state. You then give that person the choice to stay with that or swap it out for a different color scheme. This way, there’s no need to sniff out what mode someone prefers to, for example, display the correct icon on a toggle and make it perform the correct action. There is also no need to keep an event listener on prefers-color-scheme in case of changes — your color-scheme: light dark declaration in CSS handles that for you.
Adjusting color-scheme in pure CSSYes, this is totally possible! But the approach comes with a few caveats:
<button> — only radio inputs, or <options> in a <select> element.:has() pseudo-selector. Most modern browsers do, but some folks using older devices might miss out on the experience.Using the :has() pseudo-selectorThis approach is almost alarmingly simple and is fantastic for a simple one-pager! Most of the heavy lifting is done with this:
/* default, or 'auto' */html { color-scheme: light dark;}html:has([value="light"]:checked) { color-scheme: light;}html:has([value="dark"]:checked) { color-scheme: dark;}
The second and third rulesets above look for an attribute called value on any element that has “light” or “dark” assigned to it, then change the color-scheme to match only if that element is :checked.
This approach is not very efficient if you have a huge page full of elements. In those cases, it’s better to be more specific. In the following two examples, the CSS selectors check for value only within an element containing id="mode-switcher".
html:has(#mode-switcher [value="light"]:checked) { color-scheme: light }/* Did you know you don't need the ";" for a one-liner? Now you do! */
Using a <select> element:
CodePen Embed FallbackUsing <input type="radio">:
CodePen Embed FallbackWe could theoretically use checkboxes for this, but since checkboxes are not supposed to be used for mutually exclusive options, I won’t provide an example here. What happens in the case of more than one option being checked? The last matching CSS declaration wins (which is dark in the examples above).
Adjusting color-scheme in HTML with JavaScriptI subscribe to Jeremy Keith’s maxim when it comes to reaching for JavaScript:
JavaScript should only do what only JavaScript can do.
This is exactly that kind of situation.
If you want to allow visitors to change the color scheme using buttons, or you would like the option to be saved the next time the visitor comes to the site, then we do need at least some JavaScript. Rather than using the :has() pseudo-selector in CSS, we have a few alternative approaches for changing the color-scheme when we add JavaScript to the mix.
Using <meta> tagsIf you have set your color-scheme within a meta tag in the <head> of your HTML:
<meta name="color-scheme" content="light dark">
…you might start by making a useful constant like so:
const colorScheme = document.querySelector('meta[name="color-scheme"]');
And then you can manipulate that, assigning it light or dark as you see fit:
colorScheme.setAttribute("content", "light"); // to light modecolorScheme.setAttribute("content", "dark"); // to dark modecolorScheme.setAttribute("content", "light dark"); // to auto mode
This is a very similar approach to using <meta> tags but is different if you are setting the color-scheme property in CSS:
html { color-scheme: light dark; }
Instead of setting a colorScheme constant as we just did in the last example with the <meta> tag, you might select the <html> element instead:
const html = document.querySelector('html');
Now your manipulations look like this:
html.style.setProperty("color-scheme", "light"); // to light modehtml.style.setProperty("color-scheme", "dark"); // to dark modehtml.style.setProperty("color-scheme", "light dark"); // to auto mode
I like to turn those manipulations into functions so that I can reuse them:
function switchAuto() { html.style.setProperty("color-scheme", "light dark");}function switchLight() { html.style.setProperty("color-scheme", "light");}function switchDark() { html.style.setProperty("color-scheme", "dark");}
Alternatively, you might like to stay as DRY as possible and do something like this:
function switchMode(mode) { html.style.setProperty("color-scheme", mode === "auto" ? "light dark" : mode);}
The following demo shows how this JavaScript-based approach can be used with buttons, radio buttons, and a <select> element. Please note that not all of the controls are hooked up to update the UI — the demo would end up too complicated since there’s no world where all three types of controls would be used in the same UI!
CodePen Embed FallbackI opted to use onchange and onclick in the HTML elements mainly because I find them readable and neat. There’s nothing wrong with instead attaching a change event listener to your controls, especially if you need to trigger other actions when the options change. Using onclick on a button doesn’t only work for clicks, the button is still keyboard-focusable and can be triggered with Spacebar and Enter too, as usual.
Remembering the selection for repeat visitsThe biggest caveat to everything we’ve covered so far is that this only works once. In other words, once the visitor has left the site, we’re doing nothing to remember their color scheme preference. It would be a better user experience to store that preference and respect it anytime the visitor returns.
The Web Storage API is our go-to for this. And there are two available ways for us to store someone’s color scheme preference for future visits.
localStorageLocal storage saves values directly on the visitor’s device. This makes it a nice way to keep things off your server, as the stored data never expires, allowing us to call it anytime. That said, we’re prone to losing that data whenever the visitor clears cookies and cache and they’ll have to make a new selection that is freshly stored in localStorage.
You pick a key name and give it a value with .setItem():
localStorage.setItem("mode", "dark");
The key and value are saved by the browser, and can be called up again for future visits:
const mode = localStorage.getItem("mode");
You can then use the value stored in this key to apply the person’s preferred color scheme.
sessionStorageSession storage is thrown away as soon as a visitor browses away to another site or closes the current window/tab. However, the data we capture in sessionStorage persists while the visitor navigates between pages or views on the same domain.
It looks a lot like localStorage:
sessionStorage.setItem("mode", "dark");const mode = sessionStorage.getItem("mode");
Which storage method should I use?Personally, I started with sessionStorage because I wanted my site to be as simple as possible, and to avoid anything that would trigger the need for a GDPR-compliant cookie banner if we were holding onto the person’s preference after their session ends. If most of your traffic comes from new visitors, then I suggest using sessionStorage to prevent having to do extra work on the GDPR side of things.
That said, if your traffic is mostly made up of people who return to the site again and again, then localStorage is likely a better approach. The convenience benefits your visitors, making it worth the GDPR work.
The following example shows the localStorage approach. Open it up in a new window or tab, pick a theme other than what’s set in your operating system’s preferences, close the window or tab, then re-open the demo in a new window or tab. Does the demo respect the color scheme you selected? It should!
CodePen Embed FallbackChoose the “Auto” option to go back to normal.
If you want to look more closely at what is going on, you can open up the developer tools in your browser (F12 for Windows, CTRL+ click and select “Inspect” for macOS). From there, go into the “Application” tab and locate https://cdpn.io in the list of items stored in localStorage. You should see the saved key (mode) and the value (dark or light). Then start clicking on the color scheme options again and watch the mode update in real-time.
AccessibilityCongratulations! If you have got this far, you are considering or already providing versions of your website that are more comfortable for different people to use.
For example:
So, providing both versions leaves fewer people straining their eyes to access the content.
Contrast levelsI want to include a small addendum to this provision of a light and dark mode. An easy temptation is to go full monochrome black-on-white or white-on-black. It’s striking and punchy! I get it. But that’s just it — striking and punchy can also trigger migraines for some people who do a lot better with lower contrasts.
Providing high contrast is great for the people who need it. Some visual impairments do make it impossible to focus and get a sharp image, and a high contrast level can help people to better make out the word shapes through a blur. Minimum contrast levels are important and should be exceeded.
Thankfully, alongside other media queries, we can also query prefers-contrast which accepts values for no-preference, more, less, or custom.
In the following example (which uses :has() and color-mix()), a <select> element is displayed to offer contrast settings. When “Low” is selected, a filter of contrast(75%) is placed across the page. When “High” is selected, CanvasText and Canvas are used unmixed for text color and background color:
CodePen Embed FallbackAdding a quick high and low contrast theme gives your visitors even more choice for their reading comfort. Look at that — now you have three contrast levels in both dark and light modes — six color schemes to choose from!
ARIA-pressedARIA stands for Accessible Rich Internet Applications and is designed for adding a bit of extra info where needed to screen readers and other assistive tech.
The words “where needed” do heavy lifting here. It has been said that, like apostrophes, no ARIA is better than bad ARIA. So, best practice is to avoid putting it everywhere. For the most part (with only a few exceptions) native HTML elements are good to go out of the box, especially if you put useful text in your buttons!
The little bit of ARIA I use in this demo is for adding the aria-pressed attribute to the buttons, as unlike a radio group or select element, it’s otherwise unclear to anyone which button is the “active” one, and ARIA helps nicely with this use case. Now a screen reader will announce both its accessible name and whether it is in a pressed or unpressed state along with a button.
Following is an example code snippet with all the ARIA code bolded — yes, suddenly there’s lots more! You may find more elegant (or DRY-er) ways to do this, but showing it this way first makes it more clear to demonstrate what’s happening.
Our buttons have ids, which we have used to target them with some more handy consts at the top. Each time we switch mode, we make the button’s aria-pressed value for the selected mode true, and the other two false:
const html = document.querySelector("html");const mode = localStorage.getItem("mode");const lightSwitch = document.querySelector('#lightSwitch');const darkSwitch = document.querySelector('#darkSwitch');const autoSwitch = document.querySelector('#autoSwitch');if (mode === "light") switchLight();if (mode === "dark") switchDark();function switchAuto() { html.style.setProperty("color-scheme", "light dark"); localStorage.removeItem("mode"); lightSwitch.setAttribute("aria-pressed","false"); darkSwitch.setAttribute("aria-pressed","false"); autoSwitch.setAttribute("aria-pressed","true");}function switchLight() { html.style.setProperty("color-scheme", "light"); localStorage.setItem("mode", "light"); lightSwitch.setAttribute("aria-pressed","true"); darkSwitch.setAttribute("aria-pressed","false"); autoSwitch.setAttribute("aria-pressed","false");}function switchDark() { html.style.setProperty("color-scheme", "dark"); localStorage.setItem("mode", "dark"); lightSwitch.setAttribute("aria-pressed","false"); darkSwitch.setAttribute("aria-pressed","true"); autoSwitch.setAttribute("aria-pressed","false");}
On load, the buttons have a default setting, which is when the “Auto” mode button is active. Should there be any other mode in the localStorage, we pick it up immediately and run either switchLight() or switchDark(), both of which contain the aria-pressed changes relevant to that mode.
<button id="autoSwitch" aria-pressed="true" type="button" onclick="switchAuto()">Auto</button><button id="lightSwitch" aria-pressed="false" type="button" onclick="switchLight()">Light</button><button id="darkSwitch" aria-pressed="false" type="button" onclick="switchDark()">Dark</button>
The last benefit of aria-pressed is that we can also target it for styling purposes:
button[aria-pressed="true"] { background-color: transparent; border-width: 2px;}
Finally, we have a nice little button switcher, with its state clearly shown and announced, that remembers your choice when you come back to it. Done!
CodePen Embed FallbackOutroductionOr whatever the opposite of an introduction is…
…don’t let yourself get dragged into the old dark vs light mode argument. Both are good. Both are great! And both modes are now easy to create at once. At the start of your next project, work or hobby, do not give in to fear and pick a side — give both a try, and give in to choice.
Come to the light-dark() Side originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
A whole bunch of years ago, we posted on this idea here on CSS-Tricks. We figured it was time to update that and do the subject justice.
Imagine a scenario where you need to split a layout in half. Content on the left and content on the right. Basically two equal height columns are needed inside of a container. Each side takes up exactly half of the container, creating a distinct break between one. Like many things in CSS, there are a number of ways to go about this and we’re going to go over many of them right now!
Update (Oct. 25, 2024): Added an example that uses CSS Anchor Positioning.
Using Background GradientOne simple way we can create the appearance of a changing background is to use gradients. Half of the background is set to one color and the other half another color. Rather than fade from one color to another, a zero-space color stop is set in the middle.
.container { background: linear-gradient( to right, #ff9e2c 0%, #ff9e2c 50%, #b6701e 50%, #b6701e 100% );}
This works with a single container element. However, that also means that it will take working with floats or possibly some other layout method if content needs to fill both sides of the container.
CodePen Embed FallbackUsing Absolute PositioningAnother route might be to set up two containers inside of a parent container, position them absolutely, split them up in halves using percentages, then apply the backgrounds. The benefit here is that now we have two separate containers that can hold their own content.
CodePen Embed FallbackAbsolute positioning is sometimes a perfect solution, and sometimes untenable. The parent container here will need to have a set height, and setting heights is often bad news for content (content changes!). Not to mention absolute positioned elements are out of the document flow. So it would be hard to get this to work while, say, pushing down other content below it.
Using (fake) TablesYeah, yeah, tables are so old school (not to mention fraught with accessibility issues and layout inflexibility). Well, using the display: table-cell; property can actually be a handy way to create this layout without writing table markup in HTML. In short, we turn our semantic parent container into a table, then the child containers into cells inside the table — all in CSS!
CodePen Embed FallbackYou could even change the display properties at breakpoints pretty easily here, making the sides stack on smaller screens. display: table; (and friends) is supported as far back as IE 8 and even old Android, so it’s pretty safe!
Using FloatsWe can use our good friend the float to arrange the containers beside each other. The benefit here is that it avoids absolute positioning (which as we noted, can be messy).
CodePen Embed FallbackIn this example, we’re explicitly setting heights to get them to be even. But you don’t really get that ability with floats by default. You could use the background gradient trick we already covered so they just look even. Or look at fancy negative margin tricks and the like.
Also, remember you may need to clear the floats on the parent element to keep the document flow happy.
Using Inline-BlockIf clearing elements after floats seems like a burden, then using display: inline-block is another option. The trick here is to make sure that the elements for the individual sides have no breaks or whitespace in between them in the HTML. Otherwise, that space will be rendered as a literal space and the second half will break and fall.
CodePen Embed FallbackAgain there is nothing about inline-block that helps us equalize the heights of the sides, so you’ll have to be explicit about that.
There are also other potential ways to deal with that spacing problem described above.
Using FlexboxFlexbox is a pretty fantastic way to do this, just note that it’s limited to IE 10 and up and you may need to get fancy with the prefixes and values to get the best support.
Using this method, we turn our parent container into a flexible box with the child containers taking up an equal share of the space. No need to set widths or heights! Flexbox just knows what to do, because the defaults are set up perfectly for this. For instance, flex-direction: row; and align-items: stretch; is what we’re after, but those are the defaults so we don’t have to set them. To make sure they are even though, setting flex: 1; on the sides is a good plan. That forces them to take up equal shares of the space.
CodePen Embed FallbackIn this demo we’re making the side flex containers as well, just for fun, to handle the vertical and horizontal centering.
Using Grid LayoutFor those living on the bleeding edge, the CSS Grid Layout technique is like the Flexbox and Table methods merged into one. In other words, a container is defined, then split into columns and cells which can be filled flexibly with child elements.
CodePen Embed FallbackCSS Anchor PositioningThis started rolling out in 2024 and we’re still waiting for full browser support. But we can use CSS Anchor Positioning to “attach” one element to another — even if those two elements are completely unrelated in the markup.
The idea is that we have one element that’s registered as an “anchor” and another element that’s the “target” of that anchor. It’s like the target element is pinned to the anchor. And we get to control where we pin it!
.anchor { anchor-name: --anchor;}.target { anchor-position: --anchor; position: absolute; /* required */}
This sets up an .anchor and establishes a relationship with a .target element. From here, we can tell the target which side of the anchor it should pin to.
.anchor { anchor-name: --anchor;}.target { anchor-position: --anchor; position: absolute; /* required */ left: anchor(right);}
CodePen Embed FallbackIsn’t it cool how many ways there are to do things in CSS?
Left Half and Right Half Layout – Many Different Ways originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Terence Eden on using text-wrap: balance for more than headings:
But the name is, I think, slightly misleading. It doesn’t only work on text. It will work on any content. For example – I have a row of icons at the bottom of this page. If the viewport is too narrow, a single icon might drop to the next line. That can look a bit weird.
Heck yeah. I may have reached for some sort of auto-fitting grid approach, but hey, may as well go with a one-liner if you can! And while we’re on the topic, I just wanna mention that, yes, text-wrap: balance will work on any content. — just know that the spec is a little opinionated on this and make sure that the content is fewer than five lines.
There’s likely more nuance to come if the note for Issue 6 in the spec is any indication about possibly allowing for a line length minimum:
Suggestion for value space is
match-indent|<length>|<percentage>(with Xch given as an example to make that use case clear). Alternately<integer>could actually count the characters.
You can use text-wrap: balance; on icons originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
The difference between Popovers (i.e., the popover attribute) and Dialogs (i.e., both the <dialog> element and the dialog accessible role) is incredibly confusing — so much that many articles (like this, this, and this) have tried to shed some light on the issue.
If you’re still feeling confused, I hope this one clears up that confusion once and for all.
Distinguishing Popovers From DialogsLet’s pull back on the technical implementations and consider the greater picture that makes more sense and puts everything into perspective.
The reason for this categorization comes from a couple of noteworthy points.
First, we know that a popover is content that “pops” up when a user clicks a button (or hovers over it, or focuses on it). In the ARIA world, there is a useful attribute called aria-haspopup that categorizes such popups into five different roles:
menulistboxtreegriddialogStrictly speaking, there’s a sixth value, true, that evaluates to menu. I didn’t include it above since it’s effectively just menu.
By virtue of dialog being on this list, we already know that dialog is a type of popover. But there’s more evidence behind this too.
The Three Types of DialoguesSince we’re already talking about the dialog role, let’s further expand that into its subcategories:
Dialogs can be categorized into three main kinds:
This brings us to another reason why a dialog is considered a popover.
Some people may say that popovers are strictly non-modal, but this seems to be a major misunderstanding — because popovers have a ::backdrop pseudo-element on the top layer. The presence of ::backdrop indicates that popovers are modal. Quoting the CSS-Tricks almanac:
The
::backdropCSS pseudo-element creates a backdrop that covers the entire viewport and is rendered immediately below a<dialog>, an element with thepopupattribute, or any element that enters fullscreen mode using the Fullscreen API.
That said, I don’t recommend using the Popover API for modality because it doesn’t have a showModal() method (that <dialog> has) that creates inertness, focus trapping, and other necessary features to make it a real modal. If you only use the Popover API, you’ll need to build those features from scratch.
So, the fact that popovers can be modal means that a dialog is simply one kind of popover.
A Popover Needs an Accessible RolePopovers need a role to be accessible. Hidde has a great article on selecting the right role, but I’m going to provide some points in this article as well.
To start, you can use one of the aria-haspopup roles mentioned above:
menulistboxtreegriddialogYou could also use one of the more complex roles like:
treegridalertdialogThere are two additional roles that are slightly more contentious but may do just fine.
tooltipstatusTo understand why tooltip and status could be valid popover roles, we need to take a detour into the world of tooltips.
A Note on TooltipsFrom a visual perspective, a tooltip is a popover because it contains a tiny window that pops up when the tooltip is displayed.
I included tooltip in the mental model because it is reasonable to implement tooltip with the Popover API.
```
``
Thetooltiprole doesn’t do much in screen readers today so you need to usearia-describedby` to create accessible tooltips. But it is still important because it may extend accessibility support for some software.
But, from an accessibility standpoint, tooltips are not popovers. In the accessibility world, tooltips must not contain interactive content. If they contain interactive content, you’re not looking at a tooltip, but a dialog.
You’re thinking of dialogs. Use a dialog.
Heydon Pickering, “Your Tooltips are Bogus”
This is also why aria-haspopup doesn’t include tooltip —aria-haspopup is supposed to signify interactive content but a tooltip must not contain interactive content.
With that, let’s move on to status which is an interesting role that requires some explanation.
Why status?Tooltips have a pretty complex history in the world of accessible interfaces so there’s a lot of discussion and contention over it.
To keep things short (again), there’s an accessibility issue with tooltips since tooltips should only show on hover. This means screen readers and mobile phone users won’t be able to see those tooltips (since they can’t hover on the interface).
Steve Faulkner created an alternative — toggletips — to fill the gap. In doing so, he explained that toggletip content must be announced by screen readers through live regions.
When initially displayed content is announced by (most) screen readers that support
aria-live
Heydon Pickering later added that status can be used in his article on toggletips.
We can supply an empty live region, and populate it with the toggletip “bubble” when it is invoked. This will both make the bubble appear visually and cause the live region to announce the tooltip’s information.
```
This clarifies whatever needs clarifying
``
This is whystatuscan be a potential role for apopover`, but you must use discretion when creating it.
That said, I’ve chosen not to include the status role in the Popover mental model because status is a live region role and hence different from the rest.
In SummaryHere’s a quick summary of the mental model:
When you internalize this, it’s not hard to see why the Popover API can be used with the dialog element.
```
``
When choosing arole` for your popover, you can use one of these roles safely.
menulistboxtreegridtreegriddialogalertdialogThe added benefit is most of these roles work together with aria-haspopup which gained decent support in screen readers last year.
Of course, there are a couple more you can use like status and tooltip, but you won’t be able to use them together with aria-haspopup.
Further Reading* aria-haspopup property (WAI-ARIA Specification, Version 1.2)
* Semantics and the popover attribute: which role to use when? (Hidde de Vries)
* aria-hasPopUp less is more (html5accessibility.com)
* Tooltips & Toggletips (Inclusive Components)
* What’s the Difference Between HTML’s Dialog Element and Popovers? (Chris Coyier)
Clarifying the Relationship Between Popovers and Dialogs originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
There’s a lot of math behind fluid typography. CSS does make the math a lot easier these days, but even if you’re comfortable with that, writing the full declaration can be verbose and tough to remember. I know I often have to look it back up, despite having written it maybe a hundred times.
Silvestar made a little VS Code helper to abstract all that. Type in the target values you’re aiming for and the helper expands it on the spot.
He says ChatGPT did the initial lifting before he refined it. I can get behind this sort of AI-flavored usage. Start with an idea, find a starting point, look deeper at it, and shape it into something incredibly useful for a small, single purpose.
Clamp it! VS Code extension originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
I’m utterly behind in learning about scroll-driven animations apart from the “reading progress bar” experiments all over CodePen. Well, I’m not exactly “green” on the topic; we’ve published a handful of articles on it including this neat-o one by Lee Meyer published the other week.
Our “oldest” article about the feature is by Bramus, dated back to July 2021. We were calling it “scroll-linked” animation back then. I specifically mention Bramus because there’s no one else working as hard as he is to discover practical use cases where scroll-driven animations shine while helping everyone understand the concept. He writes about it exhaustively on his personal blog in addition to writing the Chrome for Developers documentation on it.
But there’s also this free course he calls “Unleash the Power of Scroll-Driven Animations” published on YouTube as a series of 10 short videos. I decided it was high time to sit, watch, and learn from one of the best. These are my notes from it.
Introduction A scroll-driven animation is an animation that responds to scrolling. There’s a direct link between scrolling progress and the animation’s progress.
* Scroll-driven animations are different than scroll-triggered* animations, which execute on scroll and run in their entirety. Scroll-driven animations pause, play, and run with the direction of the scroll. It sounds to me like scroll-triggered animations are a lot like the CSS version of the JavaScript intersection observer that fires and plays independently of scroll.
* Why learn this? It’s super easy to take an existing CSS animation or a WAAPI animation and link it up to scrolling. The only “new” thing to learn is how to attach an animation to scrolling. Plus, hey, it’s the platform!
* There are also performance perks. JavsScript libraries that establish scroll-driven animations typically respond to scroll events on the main thread, which is render-blocking… and JANK! We’re working with hardware-accelerated animations… and NO JANK. Yuriko Hirota has a case study on the performance of scroll-driven animations published on the Chrome blog.
* Supported in Chrome 115+. Can use @supports (animation-timeline: scroll()). However, I recently saw Bramus publish an update saying we need to look for animation-range support as well.
@supports ((animation-timeline: scroll()) and (animation-range: 0% 100%)) { /* Scroll-Driven Animations related styles go here */ /* This check excludes Firefox Nightly which only has a partial implementation at the moment of posting (mid-September 2024). */}
* Remember to use prefers-reduced-motion and be mindful of those who may not want them.
Video OneCore Concepts: scroll() and ScrollTimelineLet’s take an existing CSS animation.
@keyframes grow-progress { from { transform: scaleX(0); } to { transform: scaleX(1); }}#progress { animation: grow-progress 2s linear forwards;}
Translation: Start with no width and scale it to its full width. When applied, it takes two seconds to complete and moves with linear easing just in the forwards direction.
This just runs when the #progress element is rendered. Let’s attach it to scrolling.
animation-timeline: The timeline that controls the animation’s progress.scroll(): Creates a new scroll timeline set up to track the nearest ancestor scroller in the block direction.```
``
That’s it! We’re linked up. Now we can remove theanimation-durationvalue from the mix (or set it toauto`):
```
``
Note that we’re unable to plop theanimation-timelineproperty on theanimationshorthand, at least for now. Bramus calls it a “reset-only sub-property of the shorthand” which is a new term to me. Its value gets reset when you use the shorthand the same waybackground-coloris reset bybackground. That means the best practice is to declareanimation-timeline*after*animation`.
/* YEP! */#progress { animation: grow-progress linear forwards; animation-timeline: scroll();}/* NOPE! */#progress { animation-timeline: scroll(); animation: grow-progress linear forwards;}
Let’s talk about the scroll() function. It creates an anonymous scroll timeline that “walks up” the ancestor tree from the target element to the nearest ancestor scroll. In this example, the nearest ancestor scroll is the :root element, which is tracked in the block direction.
We can name scroll timelines, but that’s in another video. For now, know that we can adjust which axis to track and which scroller to target in the scroll() function.
animation-timeline: scroll(<axis> <scroller>);
* <axis>: The axis — be it block (default), inline, y, or x.
* <scroller>: The scroll container element that defines the scroll position that influences the timeline’s progress, which can be nearest (default), root (the document), or self.
If the root element does not have an overflow, then the animation becomes inactive. WAAPI gives us a way to establish scroll timelines in JavaScript with ScrollTimeline.
const $progressbar = document.querySelector(#progress);$progressbar.style.transformOrigin = '0% 50%';$progressbar.animate( { transform: ['scaleX(0)', 'scaleY()'], }, { fill: 'forwards', timeline: new ScrollTimeline({ source: document.documentElement, // root element // can control `axis` here as well }), })
Video TwoCore Concepts: view() and ViewTimelineFirst, we oughta distinguish a scroll container from a scroll port. Overflow can be visible or clipped. Clipped could be scrolling.
Those two bordered boxes show how easy it is to conflate scrollports and scroll containers. The scrollport is the visible part and coincides with the scroll container’s padding-box. When a scrollbar is present, that plus the scroll container is the root scroller, or the scroll container.
A view timeline tracks the relative position of a subject within a scrollport. Now we’re getting into IntersectionObserver territory! So, for example, we can begin an animation on the scroll timeline when an element intersects with another, such as the target element intersecting the viewport, then it progresses with scrolling.
Bramus walks through an example of animating images in long-form content when they intersect with the viewport. First, a CSS animation to reveal an image from zero opacity to full opacity (with some added clipping).
@keyframes reveal { from { opacity: 0; clip-path: inset(45% 20% 45% 20%); } to { opacity: 1; clip-path: inset(0% 0% 0% 0%); }}.revealing-image { animation: reveal 1s linear both;}
This currently runs on the document’s timeline. In the last video, we used scroll() to register a scroll timeline. Now, let’s use the view() function to register a view timeline instead. This way, we’re responding to when a .revealing-image element is in, well, view.
.revealing-image { animation: reveal 1s linear both; /* Rember to declare the timeline after the shorthand */ animation-timeline: view();}
At this point, however, the animation is nice but only completes when the element fully exits the viewport, meaning we don’t get to see the entire thing. There’s a recommended way to fix this that Bramus will cover in another video. For now, we’re speeding up the keyframes instead by completing the animation at the 50% mark.
@keyframes reveal { from { opacity: 0; clip-path: inset(45% 20% 45% 20%); } 50% { opacity: 1; clip-path: inset(0% 0% 0% 0%); }}
More on the view() function:
animation-timeline: view(<axis> <view-timeline-inset>);
We know <axis> from the scroll() function — it’s the same deal. The <view-timeline-inset> is a way of adjusting the visibility range of the view progress (what a mouthful!) that we can set to auto (default) or a <length-percentage>. A positive inset moves in an outward adjustment while a negative value moves in an inward adjustment. And notice that there is no <scroller> argument — a view timeline always tracks its subject’s nearest ancestor scroll container.
OK, moving on to adjusting things with ViewTimeline in JavaScript instead.
const $images = document.querySelectorAll(.revealing-image);$images.forEach(($image) => { $image.animate( [ { opacity: 0, clipPath: 'inset(45% 20% 45% 20%)', offset: 0 } { opacity: 1; clipPath: 'inset(0% 0% 0% 0%)', offset: 0.5 } ], { fill: 'both', timeline: new ViewTimeline({ subject: $image, axis: 'block', // Do we have to do this if it's the default? }), } })
This has the same effect as the CSS-only approach with animation-timeline.
Video ThreeTimeline Ranges DemystifiedLast time, we adjusted where the image’s reveal animation ends by tweaking the keyframes to end at 50% rather than 100%. We could have played with the inset(). But there is an easier way: adjust the animation attachment range,
Most scroll animations go from zero scroll to 100% scroll. The animation-range property adjusts that:
animation-range: normal normal;
Those two values: the start scroll and end scroll, default:
animation-range: 0% 100%;
Other length units, of course:
animation-range: 100px 80vh;
The example we’re looking at is a “full-height cover card to fixed header”. Mouthful! But it’s neat, going from an immersive full-page header to a thin, fixed header while scrolling down the page.
@keyframes sticky-header { from { background-position: 50% 0; height: 100vh; font-size: calc(4vw + 1em); } to { background-position: 50% 100%; height: 10vh; font-size: calc(4vw + 1em); background-color: #0b1584; }}
If we run the animation during scroll, it takes the full animation range, 0%-100%.
.sticky-header { position: fixed; top: 0; animation: sticky-header linear forwards; animation-timeline: scroll();}
Like the revealing images from the last video, we want the animation range a little narrower to prevent the header from animating out of view. Last time, we adjusted the keyframes. This time, we’re going with the property approach:
.sticky-header { position: fixed; top: 0; animation: sticky-header linear forwards; animation-timeline: scroll(); animation-range: 0vh 90vh;}
We had to subtract the full height (100vh) from the header’s eventual height (10vh) to get that 90vh value. I can’t believe this is happening in CSS and not JavaScript! Bramus sagely notes that font-size animation happens on the main thread — it is not hardware-accelerated — and the entire scroll-driven animation runs on the main as a result. Other properties cause this as well, notably custom properties.
Back to the animation range. It can be diagrammed like this:
The animation “cover range”. The dashed area represents the height of the animated target element.Notice that there are four points in there. We’ve only been chatting about the “start edge” and “end edge” up to this point, but the range covers a larger area in view timelines. So, this:
animation-range: 0% 100%; /* same as 'normal normal' */
…to this:
animation-range: cover 0% cover 100%; /* 'cover normal cover normal' */
…which is really this:
animation-range: cover;
So, yeah. That revealing image animation from the last video? We could have done this, rather than fuss with the keyframes or insets:
animation-range: cover 0% cover 50%;
So nice. The demo visualization is hosted at scroll-driven-animations.style. Oh, and we have keyword values available: contain, entry, exit, entry-crossing, and exit-crossing.
contain``entry``exitThe examples so far are based on the scroller being the root element. What about ranges that are taller than the scrollport subject? The ranges become slightly different.
Just have to be aware of the element’s size and how it impacts the scrollport.This is where the entry-crossing and entry-exit values come into play. This is a little mind-bendy at first, but I’m sure it’ll get easier with use. It’s clear things can get complex really quickly… which is especially true when we start working with multiple scroll-driven animation with their own animation ranges. Yes, that’s all possible. It’s all good as long as the ranges don’t overlap. Bramus uses a contact list demo where contact items animate when they enter and exit the scrollport.
@keyframes animate-in { 0% { opacity: 0; transform: translateY: 100%; } 100% { opacity: 1; transform: translateY: 0%; }}@keyframes animate-out { 0% { opacity: 1; transform: translateY: 0%; } 100% { opacity: 0; transform: translateY: 100%; }}.list-view li { animation: animate-in linear forwards, animate-out linear forwards; animation-timeline: view(); animation-range: entry, exit; /* animation-in, animation-out */}
Another way, using entry and exit keywords directly in the keyframes:
@keyframes animate-in { entry 0% { opacity: 0; transform: translateY: 100%; } entry 100% { opacity: 1; transform: translateY: 0%; }}@keyframes animate-out { exit 0% { opacity: 1; transform: translateY: 0%; } exit 100% { opacity: 0; transform: translateY: 100%; }}.list-view li { animation: animate-in linear forwards, animate-out linear forwards; animation-timeline: view();}
Notice that animation-range is no longer needed since its values are declared in the keyframes. Wow.
OK, ranges in JavaScript.:
const timeline = new ViewTimeline({ subjext: $li, axis: 'block',})// Animate in$li.animate({ opacity: [ 0, 1 ], transform: [ 'translateY(100%)', 'translateY(0)' ],}, { fill: 'forwards', // One timeline instance with multiple ranges timeline, rangeStart: 'entry: 0%', rangeEnd: 'entry 100%',})
Video FourCore Concepts: Timeline Lookup and Named TimelinesThis time, we’re learning how to attach an animation to any scroll container on the page without needing to be an ancestor of that element. That’s all about named timelines.
But first, anonymous timelines track their nearest ancestor scroll container.
<html> <!-- scroll --> <body> <div class="wrapper"> <div style="animation-timeline: scroll();"></div> </div> </body></html>
Some problems might happen like when overflow is hidden from a container:
<html> <!-- scroll --> <body> <div class="wrapper" style="overflow: hidden;"> <!-- scroll --> <div style="animation-timeline: scroll();"></div> </div> </body></html>
Hiding overflow means that the element’s content block is clipped to its padding box and does not provide any scrolling interface. However, the content must still be scrollable programmatically meaning this is still a scroll container. That’s an easy gotcha if there ever was one! The better route is to use overflow: clip rather than hidden because that prevents the element from becoming a scroll container.
Hiding oveflow = scroll container. Clipping overflow = no scroll container. Bramus says he no longer sees any need to use overflow: hidden these days unless you explicitly need to set a scroll container. I might need to change my muscle memory to make that my go-to for ~~hiding~~ clipping overflow.
Another funky thing to watch for: absolute positioning on a scroll animation target in a relatively-positioned container. It will never match an outside scroll container that is scroll(inline-nearest) since it is absolute to its container like it’s unable to see out of it.
We don’t have to rely on the “nearest” scroll container or fuss with different overflow values. We can set which container to track with named timelines.
.gallery { position: relative;}.gallery__scrollcontainer { overflow-x: scroll; scroll-timeline-name: --gallery__scrollcontainer; scroll-timeline-axis: inline; /* container scrolls in the inline direction */}.gallery__progress { position: absolute; animation: progress linear forwards; animation-timeline: scroll(inline nearest);}
We can shorten that up with the scroll-timeline shorthand:
.gallery { position: relative;}.gallery__scrollcontainer { overflow-x: scroll; scroll-timeline: --gallery__scrollcontainer inline;}.gallery__progress { position: absolute; animation: progress linear forwards; animation-timeline: scroll(inline nearest);}
Note that block is the scroll-timeline-axis initial value. Also, note that the named timeline is a dashed-ident, so it looks like a CSS variable.
That’s named scroll timelines. The same is true of named view timlines.
.scroll-container { view-timeline-name: --card; view-timeline-axis: inline; view-timeline-inset: auto; /* view-timeline: --card inline auto */}
Bramus showed a demo that recreates Apple’s old cover-flow pattern. It runs two animations, one for rotating images and one for setting an image’s z-index. We can attach both animations to the same view timeline. So, we go from tracking the nearest scroll container for each element in the scroll:
.covers li { view-timeline-name: --li-in-and-out-of-view; view-timeline-axis: inline; animation: adjust-z-index linear both; animation-timeline: view(inline);}.cards li > img { animation: rotate-cover linear both; animation-timeline: view(inline);}
…and simply reference the same named timelines:
.covers li { view-timeline-name: --li-in-and-out-of-view; view-timeline-axis: inline; animation: adjust-z-index linear both; animation-timeline: --li-in-and-out-of-view;;}.cards li > img { animation: rotate-cover linear both; animation-timeline: --li-in-and-out-of-view;;}
In this specific demo, the images rotate and scale but the updated sizing does not affect the view timeline: it stays the same size, respecting the original box size rather than flexing with the changes.
Phew, we have another tool for attaching animations to timelines that are not direct ancestors: timeline-scope.
timeline-scope: --example;
This goes on an parent element that is shared by both the animated target and the animated timeline. This way, we can still attach them even if they are not direct ancestors.
```
``` It accepts multiple comma-separated values:
timeline-scope: --one, --two, --three;/* or */timeline-scope: all; /* Chrome 116+ */
There’s no Safari or Firefox support for the all kewword just yet but we can watch for it at Caniuse (or the newer BCD Watch!).
This video is considered the last one in the series of “core concepts.” The next five are more focused on use cases and examples.
Video FiveAdd Scroll Shadows to a Scroll ContainerIn this example, we’re conditionally showing scroll shadows on a scroll container. Chris calls scroll shadows one his favorite CSS-Tricks of all time and we can nail them with scroll animations.
Here is the demo Chris put together a few years ago:
CodePen Embed FallbackThat relies on having a background with multiple CSS gradients that are pinned to the extremes with background-attachment: fixed on a single selector. Let’s modernize this, starting with a different approach using pseudos with sticky positioning:
.container::before,.container::after { content: ""; display: block; position: sticky; left: 0em; right 0em; height: 0.75rem; &::before { top: 0; background: radial-gradient(...); } &::after { bottom: 0; background: radial-gradient(...); }}
The shadows fade in and out with a CSS animation:
@keyframes reveal { 0% { opacity: 0; } 100% { opacity: 1; }}.container { overflow:-y auto; scroll-timeline: --scroll-timeline block; /* do we need `block`? */ &::before, &::after { animation: reveal linear both; animation-timeline: --scroll-timeline; }}
This example rocks a named timeline, but Bramus notes that an anonymous one would work here as well. Seems like anonymous timelines are somewhat fragile and named timelines are a good defensive strategy.
The next thing we need is to set the animation’s range so that each pseudo scrolls in where needed. Calculating the range from the top is fairly straightforward:
.container::before { animation-range: 1em 2em;}
The bottom is a little tricker. It should start when there are 2em of scrolling and then only travel for 1em. We can simply reverse the animation and add a little calculation to set the range based on it’s bottom edge.
.container::after { animation-direction: reverse; animation-range: calc(100% - 2em) calc(100% - 1em);}
Still one more thing. We only want the shadows to reveal when we’re in a scroll container. If, for example, the box is taller than the content, there is no scrolling, yet we get both shadows.
This is where the conditional part comes in. We can detect whether an element is scrollable and react to it. Bramus is talking about an animation keyword that’s new to me: detect-scroll.
@keyframes detect-scroll { from, to { --can-scroll: ; /* value is a single space and acts as boolean */ }}.container { animation: detect-scroll; animation-timeline: --scroll-timeline; animation-fill-mode: none;}
Gonna have to wrap my head around this… but the general idea is that --can-scroll is a boolean value we can use to set visibility on the pseudos:
.content::before,.content::after { --vis-if-can-scroll: var(--can-scroll) visible; --vis-if-cant-scroll: hidden; visibility: var(--vis-if-can-scroll, var(--vis-if-cant-scroll));}
Bramus points to this CSS-Tricks article for more on the conditional toggle stuff.
Video SixAnimate Elements in Different DirectionsThis should be fun! Let’s say we have a set of columns:
```
``
The goal is getting the two outerreverse` columns to scroll in the opposite direction as the inner column scrolls in the other direction. Classic JavaScript territory!
The columns are set up in a grid container. The columns flex in the column direction.
/* run if the browser supports it */@supports (animation-timeline: scroll()) { .column-reverse { transform: translateY(calc(-100% + 100vh)); flex-direction: column-reverse; /* flows in reverse order */ } .columns { overflow-y: clip; /* not a scroll container! */ }}
First, the outer columns are pushed all the way up so the bottom edges are aligned with the viewport’s top edge. Then, on scroll, the outer columns slide down until their top edges re aligned with the viewport’s bottom edge.
The CSS animation:
@keyframes adjust-position { from /* the top */ { transform: translateY(calc(-100% + 100vh)); } to /* the bottom */ { transform: translateY(calc(100% - 100vh)); }}.column-reverse { animation: adjust-position linear forwards; animation-timeline: scroll(root block); /* viewport in block direction */}
The approach is similar in JavaScript:
const timeline = new ScrollTimeline({ source: document.documentElement,});document.querySelectorAll(".column-reverse").forEach($column) => { $column.animate( { transform: [ "translateY(calc(-100% + 100vh))", "translateY(calc(100% - 100vh))" ] }, { fill: "both", timeline, } );}
Video SevenAnimate 3D Models and More on ScrollThis one’s working with a custom element for a 3D model:
<model-viewer alt="Robot" src="robot.glb"></model-viewer>
First, the scroll-driven animation. We’re attaching an animation to the component but not defining the keyframes just yet.
@keyframes foo {}model-viewer { animation: foo linear both; animation-timeline: scroll(block root); /* root scroller in block direction */}
There’s some JavaScript for the full rotation and orientation:
// Bramus made a little helper for handling the requested animation framesimport { trackProgress } from "https://esm.sh/@bramus/sda-utilities";// Select the componentconst $model = document.QuerySelector("model-viewer");// Animation begins with the first iterationconst animation = $model.getAnimations()[0];// Variable to get the animation's timing infolet progress = animation.effect.getComputedTiming().progress * 1;// If when finished, $progress = 1if (animation.playState === "finished") progress = 1;progress = Math.max(0.0, Math.min(1.0, progress)).toFixed(2);// Convert this to degrees$model.orientation = `0deg 0deg $(progress * -360)deg`;
We’re using the effect to get the animation’s progress rather than the current timed spot. The current time value is always measured relative to the full range, so we need the effect to get the progress based on the applied animation.
Video EightScroll Velocity DetectionThe video description is helpful:
Bramus goes full experimental and uses Scroll-Driven Animations to detect the active scroll speed and the directionality of scroll. Detecting this allows you to style an element based on whether the user is scrolling (or not scrolling), the direction they are scrolling in, and the speed they are scrolling with … and this all using only CSS.
First off, this is a hack. What we’re looking at is expermental and not very performant. We want to detect the animations’s velocity and direction. We start with two custom properties.
@keyframes adjust-pos { from { --scroll-position: 0; --scroll-position-delayed: 0; } to { --scroll-position: 1; --scroll-position-delayed: 1; }}:root { animation: adjust-pos linear both; animation-timeline: scroll(root);}
Let’s register those custom properties so we can interpolate the values:
@property --scroll-position { syntax: "<number>"; inherits: true; initial-value: 0;}@property --scroll-position-delayed { syntax: "<number>"; inherits: true; initial-value: 0;}
As we scroll, those values change. If we add a little delay, then we can stagger things a bit:
:root { animation: adjust-pos linear both; animation-timeline: scroll(root);}body { transition: --scroll-position-delayed 0.15s linear;}
The fact that we’re applying this to the body is part of the trick because it depends on the parent-child relationship between html and body. The parent element updates the values immediately while the child lags behind just a tad. The evaluate to the same value, but one is slower to start.
We can use the difference between the two values as they are staggered to get the velocity.
:root { animation: adjust-pos linear both; animation-timeline: scroll(root);}body { transition: --scroll-position-delayed 0.15s linear; --scroll-velocity: calc( var(--scroll-position) - var(--scroll-position-delayed) );}
Clever! If --scroll-velocity is equal to 0, then we know that the user is not scrolling because the two values are in sync. A positive number indicates the scroll direction is down, while a negative number indicates scrolling up,.
There’s a little discrepancy when scrolling abruptly changes direction. We can fix this by tighening the transition delay of --scroll-position-delayed but then we’re increasing the velocity. We might need a multiplier to further correct that… that’s why this is a hack. But now we have a way to sniff the scrolling speed and direction!
Here’s the hack using math functions:
body { transition: --scroll-position-delayed 0.15s linear; --scroll-velocity: calc( var(--scroll-position) - var(--scroll-position-delayed) ); --scroll-direction: sign(var(--scroll-velocity)); --scroll-speed: abs(var(--scroll-velocity));}
This is a little funny because I’m seeing that Chrome does not yet support sign() or abs(), at least at the time I’m watching this. Gotta enable chrome://flags. There’s a polyfill for the math brought to you by Ana Tudor right here on CSS-Tricks.
So, now we could theoretically do something like skew an element by a certain amount or give it a certain level of background color saturation depending on the scroll speed.
.box { transform: skew(calc(var(--scroll-velocity) * -25deg)); transition: background 0.15s ease; background: hsl( calc(0deg + (145deg * var(--scroll-direction))) 50 % 50% );}
We could do all this with style queries should we want to:
@container style(--scroll-direction: 0) { /* idle */ .slider-item { background: crimson; }}@container style(--scroll-direction: 1) { /* scrolling down */ .slider-item { background: forestgreen; }}@container style(--scroll-direction: -1) { /* scrolling down */ .slider-item { background: lightskyblue; }}
Custom properties, scroll-driven animations, and style queries — all in one demo! These are wild times for CSS, tell ya what.
Video NineOutroThe tenth and final video! Just a summary of the series, so no new notes here. But here’s a great demo to cap it off.
CodePen Embed FallbackVideo Ten
Unleash the Power of Scroll-Driven Animations originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Change can certainly be scary whenever a beloved, independent software library becomes a part of a larger organization. I’m feeling a bit more excitement than concern this time around, though.
If you haven’t heard, GSAP (GreenSock Animation Platform) is teaming up with the visual website builder, Webflow. This mutually beneficial advancement not only brings GSAP’s powerful animation capabilities to Webflow’s graphical user interface but also provides the GSAP team the resources necessary to take development to the next level.
GSAP has been independent software for nearly 15 years (since the Flash and ActionScript days!) primarily supported by Club GSAP memberships, their paid tiers which offer even more tools and plugins to enhance GSAP further. GSAP is currently used on more than 12 million websites.
I chatted with Cassie Evans — GSAP’s Lead Bestower of Animation Superpowers and CSS-Tricks contributor — who confidently expressed that GSAP will remain available for the wider web.
It’s a big change, but we think it’s going to be a good one – more resources for the core library, more people maintaining the GSAP codebase, money for events and merch and community support, a VISUAL GUI in the pipeline.
The Webflow community has cause for celebration as well, as direct integration with GSAP has been a wishlist item for a while.
The webflow community is so lovely and creative and supportive and friendly too. It’s a good fit.
I’m so happy for Jack, Cassie, and Rodrigo, as well as super excited to see what happens next. If you don’t want to take my word for it, check out what Brody has to say about it.
Combining forces, GSAP & Webflow! originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Kate Kaplan hits on something over at Nielsen Norman Group’s blog that’s been bugging me:
The challenge with this icon is sparkle ambiguity: Participants in our recent research study generally agreed that it represented something a little special. But, what was that something? And why was it special? That was less obvious. We encountered widely and wildly varied interpretations.
Man, I hate those sparkles. Correction: I loathe it as an icon but I use the heck out the ✨ emoji. I may even go so far as to say that my favorite thing about Dave Rupert’s introduction to web components is that it is littered with ✨ emoji every time the word “superpower” is evoked.
(Correction for the correction: I love everything about Dave’s introduction to web components. Take the full course!)
Sparkles get closer to becoming the unofficial AI icon every time a new one makes its way into some new app’s UI — the same way some menus became hamburgers and others became kebabs.
It’s ambiguous, right? I will say that I was stoked to see Notion roll out a fresh new icon for their AI feature just this week:
A face is interesting! I find human heads less compelling, especially when they’re realistic. Same deal with robot heads, which is another theme you can spot in the wild. But a face, particularly one that’s on the whimsical side as a line drawing, looks like it could work in this context that’s specific to Notion. I imagine another company or app having a tough time pulling off the same icon because this one is so closely tied to Notion’s overall branding:
See how nice it is next to the rest of Notion’s icons?I also like how Notion has several versions of the icon for use in different situations.
And, yes, it animates as well:
It’s the button that persists in the bottom-right corner.I’m not saying Notion’s landed on a silver bullet. What I am saying is that they’re doing a great job transitioning from an ambiguous one to a more meaningful one, something that Kate articulates extremely well:
[S]parkles are used frequently to represent not only AI features and capabilities but also completely unrelated features and content, such as visual effects, deals or rewards, personalized ads, and new content.
I get worked up about this because I own a pessimistic and assumptive view that the proliferation of sparkles icons reeks of marketing. There’s no way for me to know this, of course, but I’ll unabashedly don my tinfoil hat for this one.
Anyway, Kate’s article is a much more thorough investigation that’s worth the deep dive — it’s a selection pulled from an entire book on the topic of successful icon design. I’ll leave you with a sobering quote:
Finally, I also predict that the icon’s association with AI-driven features will get stronger in the immediate future. So, for the time being, using it to indicate AI-driven features (or even simply new features) may be useful. Over time, as AI-driven features become more common or even expected across interfaces, there will be less of a need to call them out. It won’t matter that the features are AI-driven; it will matter only that they are present and meet user needs.
The Proliferation and Problem of the ✨ Sparkles ✨ Icon originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Not long ago, if we wanted a tooltip or popover positioned on top of another element, we would have to set our tooltip’s position to something other than static and use its inset/transform properties to place it exactly where we want. This works, but the element’s position is susceptible to user scrolls, zooming, or animations since the tooltip could overflow off of the screen or wind up in an awkward position. The only way to solve this was using JavaScript to check whenever the tooltip goes out of bounds so we can correct it… again in JavaScript.
CSS Anchor Positioning gives us a simple interface to attach elements next to others just by saying which sides to connect — directly in CSS. It also lets us set a fallback position so that we can avoid the overflow issues we just described. For example, we might set a tooltip element above its anchor but allow it to fold underneath the anchor when it runs out of room to show it above.
Anchor positioning is different from a lot of other features as far as how quickly it’s gained browser support: its first draft was published on June 2023 and, just a year later, it was released on Chrome 125. To put it into perspective, the first draft specification for CSS variables was published in 2012, but it took four years for them to gain wide browser support.
So, let’s dig in and learn about things like attaching target elements to anchor elements and positioning and sizing them.
Quick reference
/* Define an anchor element */.anchor { anchor-name: --my-anchor;}
/* Anchor a target element */.target { position: absolute; position-anchor: --my-anchor;}
/* Position a target element */.target { position-area: start end;}
Table of contents 1. Basics and terminology
2. Attaching targets to anchors
3. Positioning targets
4. Setting fallback positions
5. Custom position and size fallbacks
6. Multiple anchors
7. Accessibility
8. Browser support
9. Spec changes
10. Known bugs
11. Almanac references
12. Further reading
Basics and terminology At its most basic, CSS Anchor Positioning introduces a completely new way of placing elements on the page relative to one another. To make our lives easier, we’re going to use specific names to clarify which element is connecting to which:
For the following code examples and demos, you can think of these as just two <div> elements next to one another.
```
``
CSS Anchor Positioning is all about elements with absolute positioning (i.e.,display: absolute`), so there are also some concepts we have to review before diving in.
static or certain values in properties like contain or filter.top, right, bottom, left, etc.) reduce the size of the containing block into which it is sized and positioned, resulting in a new box called the inset-modified containing block, or IMCB for short. This is a vital concept to know since properties we’re covering in this guide — like position-area and position-try-order — rely on this concept.
Attaching targets to anchors We’ll first look at the two properties that establish anchor positioning. The first, anchor-name, establishes the anchor element, while the second, position-anchor, attaches a target element to the anchor element.anchor-nameA normal element isn’t an anchor by default — we have to explicitly make an element an anchor. The most common way is by giving it a name, which we can do with the anchor-name property.
anchor-name: none | <dashed-ident>#
The name must be a <dashed-ident>, that is, a custom name prefixed with two dashes (--), like --my-anchor or --MyAnchor.
.anchor { anchor-name: --my-anchor;}
This gives us an anchor element. All it needs is something anchored to it. That’s what we call the “target” element which is set with the position-anchor property.
position-anchorThe target element is an element with an absolute position linked to an anchor element matching what’s declared on the anchor-name property. This attaches the target element to the anchor element.
position-anchor: auto | <anchor-element>
It takes a valid <anchor-element>. So, if we establish another element as the “anchor” we can set the target with the position-anchor property:
.target { position: absolute; position-anchor: --my-anchor;}
Normally, if a valid anchor element isn’t found, then other anchor properties and functions will be ignored.
Positioning targets Now that we know how to establish an anchor-target relationship, we can work on positioning the target element in relation to the anchor element. The following two properties are used to set which side of the anchor element the target is positioned on (position-area) and conditions for hiding the target element when it runs out of room (position-visibility).
position-areaThe next step is positioning our target relative to its anchor. The easiest way is to use the position-area property, which creates an imaginary 3×3 grid around the anchor element and lets us place the target in one or more regions of the grid.
position-area: auto | <position-area>
It works by setting the row and column of the grid using logical values like start and end (dependent on the writing mode); physical values like top, left, right, bottom and the center shared value, then it will shrink the target’s IMCB into the region of the grid we chose.
.target { position-area: top right; /* or */ position-area: start end;}
Logical values refer to the containing block’s writing mode, but if we want to position our target relative to its writing mode we would prefix it with the self value.
.target { position-area: self-start self-end;}
There is also the center value that can be used in every axis.
.target { position-area: center right; /* or */ position-area: start center;}
To place a target across two adjacent grid regions, we can use the prefix span- on any value (that isn’t center) a row or column at a time.
.target { position-area: span-top left; /* or */ position-area: span-start start;}
Finally, we can span a target across three adjacent grid regions using the span-all value.
.target { position-area: bottom span-all; /* or */ position-area: end span-all;}
You may have noticed that the position-area property doesn’t have a strict order for physical values; writing position-area: top left is the same as position-area: left top, but the order is important for logical value since position-area: start end is completely opposite to position-area: end start.
We can make logical values interchangeable by prefixing them with the desired axis using y-, x-, inline- or block-.
.target { position-area: inline-end block-start; /* or */ position-area: y-start x-end;}
CodePen Embed FallbackCodePen Embed Fallbackposition-visibilityIt provides certain conditions to hide the target from the viewport.
position-visibility: always | anchors-visible | no-overflow
* always: The target is always displayed without regard for its anchors or its overflowing status.
* no-overflow: If even after applying the position fallbacks, the target element is still overflowing its containing block, then it is strongly hidden.
* anchors-visible: If the anchor (not the target) has completely overflowed its containing block or is completely covered by other elements, then the target is strongly hidden.
position-visibility: always | anchors-visible | no-overflow
CodePen Embed Fallback Setting fallback positions Once the target element is positioned against its anchor, we can give the target additional instructions that tell it what to do if it runs out of space. We’ve already looked at the position-visibility property as one way of doing that — we simply tell the element to hide. The following two properties, however, give us more control to re-position the target by trying other sides of the anchor (position-try-fallbacks) and the order in which it attempts to re-position itself (position-try-order).
The two properties can be declared together with the position-try shorthand property — we’ll touch on that after we look at the two constituent properties.
position-try-fallbacksThis property accepts a list of comma-separated position fallbacks that are tried whenever the target overflows out of space in its containing block. The property attempts to reposition itself using each fallback value until it finds a fit or runs out of options.
position-try-fallbacks: none | [ [<dashed-ident> || <try-tactic>] | <'inset-area'> ]#
* none: Leaves the target’s position options list empty.
* <dashed-ident>: Adds to the options list a custom @position-try fallback with the given name. If there isn’t a matching @position-try, the value is ignored.
* <try-tactic>: Creates an option list by flipping the target’s current position on one of three axes, each defined by a distinct keyword. They can also be combined to add up their effects.
+ The flip-block keyword swaps the values in the block axis.
+ The flip-inline keyword swaps the values in the inline axis.
+ The flip-start keyword swaps the values diagonally.
* <dashed-ident> || <try-tactic>: Combines a custom @try-option and a <try-tactic> to create a single-position fallback. The <try-tactic> keywords can also be combined to sum up their effects.
* <"position-area"> Uses the position-area syntax to move the anchor to a new position.
.target { position-try-fallbacks: --my-custom-position, --my-custom-position flip-inline, bottom left;}
position-try-orderThis property chooses a new position from the fallback values defined in the position-try-fallbacks property based on which position gives the target the most space. The rest of the options are reordered with the largest available space coming first.
position-try-order: normal | most-width | most-height | most-block-size | most-inline-size
What exactly does “more space” mean? For each position fallback, it finds the IMCB size for the target. Then it chooses the value that gives the IMCB the widest or tallest size, depending on which option is selected:
most-widthmost-heightmost-block-sizemost-inline-size.target { position-try-fallbacks: --custom-position, flip-start; position-try-order: most-width;}
position-tryThis is a shorthand property that combines the position-try-fallbacks and position-try-order properties into a single declaration. It accepts first the order and then the list of possible position fallbacks.
position-try: < "position-try-order" >? < "position-try-fallbacks" >;
So, we can combine both properties into a single style rule:
.target { position-try: most-width --my-custom-position, flip-inline, bottom left;}
Custom position and size fallbacks @position-tryThis at-rule defines a custom position fallback for the position-try-fallbacks property.
@position-try <dashed-ident> { <declaration-list>}
It takes various properties for changing a target element’s position and size and grouping them as a new position fallback for the element to try.
Imagine a scenario where you’ve established an anchor-target relationship. You want to position the target element against the anchor’s top-right edge, which is easy enough using the position-area property we saw earlier:
.target { position: absolute; position-area: top right; width: 100px;}
See how the .target is sized at 100px? Maybe it runs out of room on some screens and is no longer able to be displayed at anchor’s the top-right edge. We can supply the .target with the fallbacks we looked at earlier so that it attempts to re-position itself on an edge with more space:
.target { position: absolute; position-area: top right; position-try-fallbacks: top left; position-try-order: most-width; width: 100px;}
And since we’re being good CSSer’s who strive for clean code, we may as well combine those two properties with the position-try shorthand property:
.target { position: absolute; position-area: top right; position-try: most-width, flip-inline, bottom left; width: 100px;}
So far, so good. We have an anchored target element that starts at the top-right corner of the anchor at 100px. If it runs out of space there, it will look at the position-try property and decide whether to reposition the target to the anchor’s top-left corner (declared as flip-inline) or the anchor’s bottom-left corner — whichever offers the most width.
But what if we want to simulataneously re-size the target element when it is re-positioned? Maybe the target is simply too dang big to display at 100px at either fallback position and we need it to be 50px instead. We can use the @position-try to do exactly that:
@position-try --my-custom-position { position-area: top left; width: 50px;}
With that done, we now have a custom property called --my-custom-position that we can use on the position-try shorthand property. In this case, @position-try can replace the flip-inline value since it is the equivalent of top left:
@position-try --my-custom-position { position-area: top left; width: 50px;}.target { position: absolute; position-area: top right; position-try: most-width, --my-custom-position, bottom left; width: 100px;}
This way, the .target element’s width is re-sized from 100px to 50px when it attempts to re-position itself to the anchor’s top-right edge. That’s a nice bit of flexibility that gives us a better chance to make things fit together in any layout.
Anchor functions anchor()You might think of the CSS anchor() function as a shortcut for attaching a target element to an anchor element — specify the anchor, the side we want to attach to, and how large we want the target to be in one fell swoop. But, as we’ll see, the function also opens up the possibility of attaching one target element to multiple anchor elements.
This is the function’s formal syntax, which takes up to three arguments:
anchor( <anchor-element>? && <anchor-side>, <length-percentage>? )
So, we’re identifying an anchor element, saying which side we want the target to be positioned on, and how big we want it to be. It’s worth noting that anchor() can only be declared on inset-related properties (e.g. top, left, inset-block-end, etc.)
.target { top: anchor(--my-anchor bottom); left: anchor(--my-anchor end, 50%);}
Let’s break down the function’s arguments.
We also have the choice of not supplying an anchor at all. In that case, the target element uses an implicit anchor element defined in position-anchor. If there isn’t an implicit anchor, the function resolves to its fallback. Otherwise, it is invalid and ignored.
top, left, bottom, right, etc.
But we have more options than that, including logical side keywords (inside, outside), logical direction arguments relative to the user’s writing mode (start, end, self-start, self-end) and, of course, center.
<anchor-side>: Resolves to the <length> of the corresponding side of the anchor element. It has physical arguments (top, left, bottom right), logical side arguments (inside, outside), logical direction arguments relative to the user’s writing mode (start, end, self-start, self-end) and the center argument.<percentage>: Refers to the position between the start (0%) and end (100%). Values below 0% and above 100% are allowed.
<length> or <percentage> relative to its containing block.Let’s look at examples using different types of arguments because they all do something a little different.
Using physical argumentsPhysical arguments (top, right, bottom, left) can be used to position the target regardless of the user’s writing mode. For example, we can position the right and bottom inset properties of the target at the anchor(top) and anchor(left) sides of the anchor, effectively positioning the target at the anchor’s top-left corner:
.target { bottom: anchor(top); right: anchor(left);}
Using logical side keywordsLogical side arguments (i.e., inside, outside), are dependent on the inset property they are in. The inside argument will choose the same side as its inset property, while the outside argument will choose the opposite. For example:
.target { left: anchor(outside); /* is the same as */ left: anchor(right); top: anchor(inside); /* is the same as */ top: anchor(top);}
Using logical directionsLogical direction arguments are dependent on two factors:
start, end) or the target’s own writing mode (self-start, self-end).So for example, using physical inset properties in a left-to-right horizontal writing would look like this:
.target { left: anchor(start); /* is the same as */ left: anchor(left); top: anchor(end); /* is the same as */ top: anchor(bottom);}
In a right-to-left writing mode, we’d do this:
.target { left: anchor(start); /* is the same as */ left: anchor(right); top: anchor(end); /* is the same as */ top: anchor(bottom);}
That can quickly get confusing, so we should also use logical arguments with logical inset properties so the writing mode is respected in the first place:
.target { inset-inline-start: anchor(end); inset-block-start: anchor(end);}
Using percentage valuesPercentages can be used to position the target from any point between the start (0%) and end (100% ) sides. Since percentages are relative to the user writing mode, is preferable to use them with logical inset properties.
.target { inset-inline-start: anchor(100%); /* is the same as */ inset-inline-start: anchor(end); inset-block-end: anchor(0%); /* is the same as */ inset-block-end: anchor(start);}
Values smaller than 0% and bigger than 100% are accepted, so -100% will move the target towards the start and 200% towards the end.
.target { inset-inline-start: anchor(200%); inset-block-end: anchor(-100%);}
Using the center keywordThe center argument is equivalent to 50%. You could say that it’s “immune” to direction, so there is no problem if we use it with physical or logical inset properties.
.target { position: absolute; position-anchor: --my-anchor; left: anchor(center); bottom: anchor(top);}
anchor-size()The anchor-size() function is unique in that it sizes the target element relative to the size of the anchor element. This can be super useful for ensuring a target scales in size with its anchor, particularly in responsive designs where elements tend to get shifted, re-sized, or obscured from overflowing a container.
The function takes an anchor’s side and resolves to its <length>, essentially returning the anchor’s width, height, inline-size or block-size.
anchor-size( [ <anchor-element> || <anchor-size> ]? , <length-percentage>? )
Here are the arguments that can be used in the anchor-size() function:
<anchor-size>: Refers to the side of the anchor element.<length-percentage>: This optional argument can be used as a fallback whenever the target doesn’t have a valid anchor or size. It returns a fixed <length> or <percentage> relative to its containing block.And we can declare the function on the target element’s width and height properties to size it with the anchor — or both at the same time!
.target { width: anchor-size(width, 20%); /* uses default anchor */` height: anchor-size(--other-anchor inline-size, 100px);}
Multiple anchors We learned about the anchor() function in the last section. One of the function’s quirks is that we can only declare it on inset-based properties, and all of the examples we saw show that. That might sound like a constraint of working with the function, but it’s actually what gives anchor() a superpower that anchor positioning properties don’t: we can declare it on more than one inset-based property at a time. As a result, we can set the function multiple anchors on the same target element!
Here’s one of the first examples of the anchor() function we looked at in the last section:
.target { top: anchor(--my-anchor bottom); left: anchor(--my-anchor end, 50%);}
We’re declaring the same anchor element named --my-anchor on both the top and left inset properties. That doesn’t have to be the case. Instead, we can attach the target element to multiple anchor elements.
.anchor-1 { anchor-name: --anchor-1; }.anchor-2 { anchor-name: --anchor-2; }.anchor-3 { anchor-name: --anchor-3; }.anchor-4 { anchor-name: --anchor-4; }.target { position: absolute; inset-block-start: anchor(--anchor-1); inset-inline-end: anchor(--anchor-2); inset-block-end: anchor(--anchor-3); inset-inline-start: anchor(--anchor-4);}
Or, perhaps more succintly:
.anchor-1 { anchor-name: --anchor-1; }.anchor-2 { anchor-name: --anchor-2; }.anchor-3 { anchor-name: --anchor-3; }.anchor-4 { anchor-name: --anchor-4; }.target { position: absolute; inset: anchor(--anchor-1) anchor(--anchor-2) anchor(--anchor-3) anchor(--anchor-4);}
The following demo shows a target element attached to two <textarea> elements that are registered anchors. A <textarea> allows you to click and drag it to change its dimensions. The two of them are absolutely positioned in opposite corners of the page. If we attach the target to each anchor, we can create an effect where resizing the anchors stretches the target all over the place almost like a tug-o-war between the two anchors.
CodePen Embed FallbackThe demo is only supported in Chrome at the time we’re writing this guide, so let’s drop in a video so you can see how it works.
Accessibility The most straightforward use case for anchor positioning is for making tooltips, info boxes, and popovers, but it can also be used for decorative stuff. That means anchor positioning doesn’t have to establish a semantic relationship between the anchor and target elements. You can probably spot the issue right away: non-visual devices, like screen readers, are left in the dark about how to interpret two seemingly unrelated elements.
As an example, let’s say we have an element called .tooltip that we’ve set up as a target element anchored to another element called .anchor.
```
```
.anchor { anchor-name: --my-anchor;}.toolip { position: absolute; position-anchor: --my-anchor; position-area: top;}
We need to set up a connection between the two elements in the DOM so that they share a context that assistive technologies can interpret and understand. The general rule of thumb for using ARIA attributes to describe elements is generally: don’t do it. Or at least avoid doing it unless you have no other semantic way of doing it.
This is one of those cases where it makes sense to reach for ARIA atributes. Before we do anything else, a screen reader currently sees the two elements next to one another without any remarking relationship. That’s a bummer for accessibility, but we can easily fix it using the corresponding ARIA attribute:
```
``` And now they are both visually and semantically linked together! If you’re new to ARIA attributes, you ought to check out Adam Silver’s “Why, How, and When to Use Semantic HTML and ARIA” for a great introduction.
Browser support This browser support data is from Caniuse, which has more detail. A number indicates that browser supports the feature at that version and up.
Desktop
| Chrome | Firefox | IE | Edge | Safari | | --- | --- | --- | --- | --- | | 125 | No | No | 125 | No |
Mobile / Tablet
| Android Chrome | Android Firefox | Android | iOS Safari | | --- | --- | --- | --- | | 129 | No | 129 | No |
Spec changes CSS Anchor Positioning has undergone several changes since it was introduced as an Editor’s Draft. The Chrome browser team was quick to hop on board and implement anchor positioning even though the feature was still being defined. That’s caused confusion because Chromium-based browsers implemented some pieces of anchor positioning while the specification was being actively edited.
We are going to outline specific cases for you where browsers had to update their implementations in response to spec changes. It’s a bit confusing, but as of Chrome 129+, this is the stuff that was shipped but changed:
position-areaThe inset-area property was renamed to position-area (#10209), but it will be supported until Chrome 131.
.target { /* from */ inset-area: top right; /* to */ position-area: top right;}
position-try-fallbacksThe position-try-options was renamed to position-try-fallbacks (#10395).
.target { /* from */ position-try-options: flip-block, --smaller-target; /* to */ position-try-fallbacks: flip-block, --smaller-target;}
inset-area()The inset-area() wrapper function doesn’t exist anymore for the position-try-fallbacks (#10320), you can just write the values without the wrapper:
.target { /* from */ position-try-options: inset-area(top left); /* to */ position-try-fallbacks: top left;}
anchor(center)In the beginning, if we wanted to center a target from the center, we would have to write this convoluted syntax:
.target { --center: anchor(--x 50%); --half-distance: min(abs(0% - var(--center)), abs(100% - var(--center))); left: calc(var(--center) - var(--half-distance)); right: calc(var(--center) - var(--half-distance));}
The CWSSG working group resolved (#8979) to add the anchor(center) argument to prevent us from having to do all that mental juggling:
.target { left: anchor(center);}
Known bugs Yes, there are some bugs with CSS Anchor Positioning, at least at the time this guide is being written. For example, the specification says that if an element doesn’t have a default anchor element, then the position-area does nothing. This is a known issue (#10500), but it’s still possible to replicate.
So, the following code…
.container { position: relative;}.element { position: absolute; position-area: center; margin: auto;}
…will center the .element inside its container, at least in Chrome:
CodePen Embed FallbackCredit to Afif13 for that great demo!
Another example involves the position-visibility property. If your anchor element is out of sight or off-screen, you typically want the target element to be hidden as well. The specification says that property’s the default value is anchors-visible, but browsers default to always instead.
The current implemenation in Chrome isn’t reflecting the spec; it indeed is using
alwaysas the initial value. But the spec is intentional: if your anchor is off-screen or otherwise scrolled off, you usually want it to hide. (#10425)
Almanac references Anchor position properties Almanac on Sep 17, 2024 anchor-name .anchor { anchor-name: --my-anchor; } anchor positioning Geoff Graham Almanac on Sep 12, 2024 position-anchor .target { position-anchor: --my-anchor; } anchor positioning Juan Diego Rodríguez Almanac on Sep 8, 2024 position-area .target { position-area: bottom end; } anchor positioning Juan Diego Rodríguez Almanac on Sep 14, 2024 position-try-fallbacks .target { position-try-fallbacks: flip-inline, bottom left; } anchor positioning Juan Diego Rodríguez Almanac on Sep 8, 2024 position-try-order .element { position-try-order: most-width; } anchor positioning Juan Diego Rodríguez Almanac on Sep 8, 2024 position-visibility .target { position-visibility: no-overflow; } anchor positioning Juan Diego Rodríguez Anchor position functions Almanac on Sep 14, 2024 anchor() .target { top: anchor(--my-anchor bottom); } anchor positioning Juan Diego Rodríguez Almanac on Sep 18, 2024 anchor-size() .target { width: anchor-size(width); } anchor positioning Juan Diego Rodríguez Anchor position at-rules Almanac on Sep 28, 2024 @position-try @position-try --my-position { position-area: top left; } anchor positioning Juan Diego Rodríguez Further reading * “CSS Anchor Positioning” (CSSWG)
* “Using CSS anchor positioning” (MDN)
* “Introducing the CSS anchor positioning API” (Una Kravets)
CSS Anchor Positioning Guide originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
An approach for creating masonry layouts in vanilla CSS is one of those “holy grail” aspirations. I actually tend to plop masonry and the classic “Holy Grail” layout in the same general era of web design. They’re different types of layouts, of course, but the Holy Grail was a done deal when we got CSS Grid.
That leaves masonry as perhaps the last standing layout from the CSS 3 era that is left without a baked-in solution. I might argue that masonry is no longer en vogue so to speak, but there clearly are use cases for packing items with varying sizes into columns based on available space. And masonry is still very much in the wild.
Steam is picking up on a formal solution. We even have a CSSWG draft specification for it. But notice how the draft breaks things out.
Grid-integrated syntax? Grid-independent syntax? We’ve done gone and multiplied CSS!
That’s the context for this batch of notes. There are two competing proposals for CSS masonry at the time of writing and many opinions are flying around advocating one or the other. I have personal thoughts on it, but that’s not important. I’ll be happy with whatever the consensus happens to be. Both proposals have merits and come with potential challenges — it’s a matter of what you prioritize which, in this case, I believe is a choice between leveraging existing CSS layout features and the ergonomics of a fresh new approach.
But let’s get to some notes from discussions that are already happening to help get a clearer picture of things!
What is masonry layout?Think of it like erecting a wall of stones or bricks.
The sizes of the bricks and stones don’t matter — the column (or less commonly a row) is the boss of sizing things. Pack as many stones or bricks in the nearest column and then those adapt to the column’s width. Or more concisely, we’re laying out unevenly sized items in a column such that there aren’t uneven gaps between them.
Examples, please?Here’s perhaps the most widely seen example in a CodePen, courtesy of Dave DeSandro, using his Masonry.js tool:
CodePen Embed FallbackI use this example because, if I remember correctly, Masonry.js was what stoked the masonry trend in, like 2010 or something. Dave implemented it on Beyoncé’s website which certainly gave masonry a highly visible profile. Sometimes you might hear masonry called a “Pinterest-style” layout because, well, that’s been the site’s signature design — perhaps even its brand — since day one.
Here’s a faux example Jhey put together using flexbox:
CodePen Embed FallbackChris also rounded up a bunch of other workarounds in 2019 that get us somewhat there, under ideal conditions. But none of these are based on standardized approaches or features. I mean, columns and flexbox are specced but weren’t designed with masonry in mind. But with masonry having a long track record of being used, it most certainly deserves a place in the CSS specs.
There are two competing proposalsThis isn’t exactly news. In fact, we can get earlier whiffs of this looking back to 2020. Rachel Andrew introduced the concept of making masonry a sub-feature of grid in a Smashing Magazine article.
Let’s fast-forward to 2022. We had an editor’s draft for CSS Masonry baked into the CSS Grid Layout Module 3 specification. Jenn Simmons motioned for the CSSWG to move it forward to be a first public working draft. Five days later, Chromium engineer Ian Kilpatrick raised two concerns about moving things forward as part of the CSS Grid Layout module, the first being related to sizing column tracks and grid’s layout algorithm:
Grid works by placing everything in the grid ahead of time, then sizing the rows/columns to fit the items. Masonry fundamentally doesn’t work this way as you need to size the rows/columns ahead of time – then place items within those rows/columns.
As a result the way the current specification re-uses the grid sizing logic leads to poor results when intrinsically sizing tracks, and if the grid is intrinsically-sized itself (e.g. if its within a grid/flex/table, etc).
Good point! Grid places grid items in advance ahead of sizing them to fit into the available space. Again, it’s the column’s size that bosses things around in masonry. It logically follows that we would need to declare masonry and configure the column track sizes in advance to place things according to space. The other concern concerns accessibility as far as visual and reading order.
That stopped Jenn’s motion for first public working draft status dead in its tracks in early 2023. If we fast-forward to July of this year, we get Ian’s points for an alternative path forward for masonry. That garnered support from all sorts of CSS heavyweights, including Rachel Andrew who authored the CSS Grid specification.
And, just a mere three weeks ago from today, fantasai shared a draft for an alternate proposal put together with Tab Atkins. This proposal, you’ll see, is specific to masonry as its own module.
And thus we have two competing proposals to solve masonry in CSS.
The case for merging masonry and gridRounding up comments from GitHub tickets and blog posts…
Flexbox is really designed for putting things into a line and distributing spare space. So that initial behaviour of putting all your things in a row is a great starting point for whatever you might want to do. It may be all you need to do. It’s not difficult as a teacher to then unpack how to add space inside or outside items, align them, or make it a column rather than a row. Step by step, from the defaults.
I want to be able to take the same approach with
display: masonry.[…]
We can’t do that as easily with grid, because of the pre-existing initial values. The good defaults for grid don’t work as well for masonry. Currently you’d need to:
- Add
display: grid, to get a single column grid layout.- Add
grid-template-columns: <track-listing>, and at the moment there’s no way to auto-fillautosized tracks so you’ll need to decide on how many. Usinggrid-template-columns: repeat(3, auto), for example.- Add
grid-template-rows: masonry.- Want to define rows instead? Switch the
masonryvalue to apply togrid-template-columnsand now define your rows. Once again, you have to explicitly define rows.Rachel Andrew, “Masonry and good defaults”
For what it’s worth, Rachel has been waving this flag since at least 2020. The ergonomics of display: masonry with default configurations that solve baseline functionality are clear and compelling. The default behavior oughta match the feature’s purpose and grid just ain’t a great set of default configurations to jump into a masonry layout. Rachel’s point is that teaching and learning grid to get to understand masonry behavior unnecessarily lumps two different formatting contexts into one, which is a certain path to confusion. I find it tough to refute this, as I also come at this from a teaching perspective. Seen this way, we might say that merging features is another lost entry point into front-end development.
In recent years, the two primary methods we’ve used to pull off masonry layouts are:
- Flexbox for consistent row sizes. We adjust the
flex-basisbased on the item’s expected percentage of the total row width.- Grid for consistent column sizes. We set the row span based on the expected aspect ratio of the content, either server-side for imagery or client-side for dynamic content.
What I’ve personally observed is:
- Neither feels more intuitive than the other as a starting point for masonry. So it feels a little itchy to single out Grid as a foundation.
- While there is friction when teaching folks when to use a Flexbox versus a Grid, it’s a much bigger leap for contributors to wrap their heads around properties that significantly change behavior (such as
flex-wraporgrid-auto-flow: dense).Tyler Sticka, commenting on GitHub Issue #9041
It’s true! If I had to single out either flexbox or grid as the starting poit for masonry (and I doubt I would either way), I might lean flexbox purely for the default behavior of aligning flexible items in a column.
The syntax and semantics of the CSS that will drive masonry layout is a concern that is separate from the actual layout mechanics itself, which internally in implementation by user agents can still re-use parts of the existing mechanics for grids, including subgrids. For cases where masonry is nested inside grid, or grid inside masonry, the relationship between the two can be made explicit.
@jgotten, commenting on GitHub Issue #9041
Rachel again, this time speaking on behalf of the Chrome team:
There are two related reasons why we feel that masonry is better defined outside of grid layout—the potential of layout performance issues, and the fact that both masonry and grid have features that make sense in one layout method but not the other.
The case for keeping masonry separate from grid
One of the key benefits of integrating masonry into the grid layout (as in CASE 2) is the ability to leverage existing grid features, such as subgrids. Subgrids allow for cohesive designs among child elements within a grid, something highly desirable in many masonry layouts as well. Additionally, I believe that future enhancements to the grid layout will also be beneficial for masonry, making their integration even more valuable. By treating masonry as an extension of the grid layout, developers would be able to start using it immediately, without needing to learn a completely new system.
Kokomi, commenting on GitHub Issue #9041
It really would be a shame if keeping masonry separate from grid prevents masonry from being as powerful as it could be with access to grid’s feature set:
I think the arguments for a separate
display: masonryfocus too much on the potential simplicity at the expense of functionality. Excluding Grid’s powerful features would hinder developers who want or need more than basic layouts. Plus, introducing another display type could lead to confusion and fragmentation in the layout ecosystem.Angel Ponce, commenting on GitHub Issue #9041
Rachel counters that, though.
I want express my strong support for adding masonry to display:grid. The fact that it gracefully degrades to a traditional grid is a huge benefit IMO. But also, masonry layout is already possible (with some constraints) in Grid layout today!
Naman Goel, Angel Ponce, commenting on GitHub Issue #9041
Chris mildly voiced interest in merging the two in 2020 before the debate got larger and more heated. Not exactly a ringing endorsement, but rather an acknowledgment that it could make sense:
I like the
grid-template-rows: masonry;syntax because I think it clearly communicates: “You aren’t setting these rows. In fact, there aren’t even really rows at all anymore, we’ll take care of that.” Which I guess means there are no rows to inherit in subgrid, which also makes sense.
Where we at?Collecting feedback. Rachel, Ian, and Tab published a joint call for folks like you and me to add our thoughts to the bag. That was eight days ago as of this writing. Not only is it a call to action, but it’s also an excellent overview of the two competing ideas and considerations for each one. You’ll want to add your feedback to GitHub Issue #9041.
CSS Masonry & CSS Grid originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
The creator of CSS has said he originally envisaged CSS as the main web technology to control behavior on web pages, with scripting as a fallback when things weren’t possible declaratively in CSS. The rationale for a CSS-first approach was that “scripting is programming and programming is hard.” Since introducing the :hover pseudo-class, CSS has been standardizing patterns developers create in JavaScript and “harvesting” them into CSS standards. When you think about it like that, it’s almost as if JavaScript is the hack and CSS is the official way.
We can, therefore, feel less dirty implementing script-like behavior with CSS, and we shouldn’t be surprised that something like the new scroll-timeline feature has appeared with pretty good browser support. Too many developers implemented clever parallax scrolling websites, which has summoned the CSS feature genie we cannot put back in its bottle. If you don’t want janky main-thread animations for your next parallax-scrolling website, you must now come to the dark side of hacking CSS. Just kidding, there is also a new JavaScript API for scroll-linked animations if imperative programming better fits your use case.
Migrating a JavaScript sample to CSSIt was satisfyingly simple to fork Chris Coyier’s pre-scroll-timeline example of a scroll-linked animation by replacing the CSS Chris was using to control the animations with just one line of CSS and completely deleting the JavaScript!
body, .progress, .cube { animation-timeline: scroll();}
CodePen Embed FallbackUsing the scroll() function without parameters sets up an “anonymous scroll progress timeline” meaning the browser will base the animation on the nearest ancestor that can scroll vertically if our writing mode is English. Unfortunately, it seems we can only choose to animate based on scrolling along the x or y-axis of a particular element but not both, which would be useful. Being a function, we can pass parameters to scroll(), which provides more control over how we want scrolling to run our animation.
Experimenting with multiple dimensionsEven better is the scroll-scope property. Applying that to a container element means we can animate properties on any chosen ancestor element based on any scrollable element that has the same assigned scope. That got me thinking… Since CSS Houdini lets us register animation-friendly, inheritable properties in CSS, we can combine animations on the same element based on multiple scrollable areas on the page. That opens the door for interesting instructional design possibilities such as my experiment below.
CodePen Embed FallbackScrolling the horizontal narrative on the light green card rotates the 3D NES console horizontally and scrolling the vertical narrative on the dark green card rotates the NES console vertically. In my previous article, I noted that my past CSS hacks have always boiled down to hiding and showing finite possibilities using CSS. What interests me about this scroll-based experiment is the combinatorial explosion of combined vertical and horizontal rotations. Animation timelines provide an interactivity in pure CSS that hasn’t been possible in the past.
The implementation details are less important than the timeline-scope usage and the custom properties. We register two custom angle properties:
@property --my-y-angle { syntax: "<angle>"; inherits: true; initial-value: 0deg;}@property --my-x-angle { syntax: "<angle>"; inherits: true; initial-value: -35deg;}
Then, we “borrow” the NES 3D model from the samples in Julian Garner’s amazing CSS 3D modeling app. We update the .scene class for the 3D to base the rotation on our new variables like this:
.scene { transform: rotateY(var(--my-y-angle)) rotateX(var(--my-x-angle));}
Next, we give the <body> element a timeline-scope with two custom-named scopes.
body { timeline-scope: --myScroller,--myScroller2; }
I haven’t seen anything officially documented about passing in multiple scopes, but it does work in Google Chrome and Edge. If it’s not a formally supported feature, I hope it will become part of the standard because it is ridiculously handy.
Next, we define the named timelines for the two scrollable cards and the axes we want to trigger our animations.
.card:first-child { scroll-timeline-axis: x; scroll-timeline-name: --myScroller;}.card:nth-child(2) { scroll-timeline-axis: y; scroll-timeline-name: --myScroller2;}
And add the animations to the scene:
.scene { animation: rotateHorizontal,rotateVertical; animation-timeline: --myScroller,--myScroller2;}@keyframes rotateHorizontal { to { --my-y-angle: 360deg; }}@keyframes rotateVertical { to { --my-x-angle: 360deg; }}
Since the 3D model inherits the x and y angles from the document body, scrolling the cards now rotates the model in combinations of vertical and horizontal angle changes.
User-controlled animations beyond scrollbarsWhen you think about it, this behavior isn’t just useful for scroll-driven animations. In the above experiment, we are using the scrollable areas more like sliders that control the properties of our 3D model. After getting it working, I went for a walk and was daydreaming about how cool it would be if actual range inputs could control animation timelines. Then I found out they can! At least in Chrome. Pure CSS CMS anyone?
While we’re commandeering 3D models from Julian Garner, let’s see if we can use range inputs to control his X-wing model.
CodePen Embed FallbackIt’s mind-boggling that we can achieve this with just CSS, and we could do it with an arbitrary number of properties. It doesn’t go far enough for me. I would love to see other input controls that can manipulate animation timelines. Imagine text fields progressing animations as you fill them out, or buttons able to play or reverse animations. The latter can be somewhat achieved by combining the :active pseudo-class with the animation-play-state property. But in my experience when you try to use that to animate multiple custom properties, the browser can get confused. By contrast, animation timelines have been implemented with this use case in mind and therefore work smoothly and exactly as I expected.
I’m not the only one who has noticed the potential for hacking this emergent CSS feature. Someone has already implemented this clever Doom clone by combining scroll-timeline with checkbox hacks. The problem I have is it still doesn’t go far enough. We have enough in Chrome to implement avatar builders using scrollbars and range inputs as game controls. I am excited to experiment with unpredictable, sophisticated experiences that are unprecedented in the era before the scroll-timeline feature. After all, if you had to explain the definition of a video game to an alien, wouldn’t you say it is just a hyper-interactive animation?
Slide Through Unlimited Dimensions With CSS Scroll Timelines originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Miriam Suzanne’s in the middle of a redesign of her personal website. It began in August 2022. She’s made an entire series out of the work that’s worth your time, but I wanted to call out the fifth and latest installment because she presents a problem that I think we can all relate to:
But the walls got in my way. Instead of minimal renovation, I got just far enough to live with it and then started a brand new Eleventy repo.
The plan was to prototype […] and bring back well-formed solutions. To echo Dave Rupert, prototyping is useful. It’s easier to play with new ideas when you’re not carrying a decade of content and old code along with you.
But prototyping evolved into what I would call tinkering (complimentary). Maybe I mean procrastinating (also complimentary), but it’s a wandering process that also helps me better understand what I want from a website. I might not make visible progress over two years, but I start to form a point of view […]. Keeping things easy is always where things get complicated. And it brings me back to where my redesign started – a desire to clarify the information architecture. Not only for visitors, but for myself.
Don’t even tell me you’ve never been there! Jim Neilsen blogged along similar lines. You get a stroke of inspiration that’s the kernel of some idea that motivates you to start, you know, working on it. There’s no real plan, perhaps. The idea and inspiration are more than enough to get you going… that is until you hit a snag. And what I appreciate about Miriam’s post is that she’s calling out content as the snag. Well, not so much a snag as a return to the founding principle for the redesign: a refined content architecture.
- Sometimes I do events where I speak, or teach a workshop, or perform. Events happen at a time and place.
- Sometimes I create artifacts like a book or an album, a website, or specification. Artifacts often have a home URL. They might have a launch date, but they are not date-specific.
- Some of my projects are other channels with their own feeds, their own events and artifacts.
- Those channels are often maintained by an organization that I work with long-term. A band, a web agency, a performance company, etc.
These boundaries aren’t always clean. A post that remains relevant could be considered an artifact. Events can generate artifacts, and vice versa. An entire organization might exist to curate a single channel.
So, Miriam’s done poking at visual prototypes and ready to pour the filling into the pie crust. I relate with this having recently futzed with the content architecure of this site. I find it tough to start with a solidified design before I know what content is going into it. But I also find it tough to work with no shape at all. In my case, CSS-Tricks has a well-established design that’s evolved, mostly outside of me. I love the design but it’s an inherited one and I’m integrating content around it. Design is the constraint. If I had the luxury of stripping the building to the studs, I might take a different approach because then I could “paint” around it. Content would be the constraint.
It’s yet another version of the Chicken-Egg dilemma. I still think of the (capital-W) Web as a content medium at least in a UA style sense in that it’s the default. It’s more than that, of course. I’m a content designer at heart (and trade) but I’m hesitant to cry “content is king” which reminded me of something I wrote for an end-of-year series we did here answering the question: What is one thing people can do to make their website better? My answer: Read your website.
We start to see the power of content when we open up our understanding of what it is, what it does, and where it’s used. That might make content one of the most extensible problem-solving tools in your metaphorical shed—it makes sites more accessible, extracts Google-juicing superpowers, converts sales, and creates pathways for users to accomplish what they need to do.
And as far as prioritizing content or design, or…?
The two work hand-in-hand. I’d even go so far as to say that a lot of design is about enhancing what is communicated on a page. There is no upstaging one or the other. Think of content and design as supporting one another, where the sum of both creates a compelling call-to-action, long-form post, hero banner, and so on. We often think of patterns in a design system as a collection of components that are stitched together to create something new. Pairing content and design works much the same way.
I’d forgotten those words, so I appreciate Miriam giving me a reason to revisit them. We all need to be recalibrated every so often — swap out air filters, top off the fluids, and rotate the ol’ tires. And an old dog like me needs it a little more often. I spent a few more minutes in that end-of-year series and found a few other choice quotes about the content-design continuum that may serve as inspiration for you, me, or maybe even Miriam as she continues the process of aggragating her distributed self.
This sounds serious, but don’t worry — the site’s purpose is key. If you’re building a personal portfolio, go wild! However, if someone’s trying to file a tax return, whimsical loading animations aren’t likely to be well-received. On the other hand, an animated progress bar could be a nice touch while providing visual feedback on the user’s action.
Cassie Evans, “Empathetic Animation”
Remember, the web is an interactive platform — take advantage of that, where appropriate (less is more, accessibility is integral, and you need to know your audience). Whether that’s scrollytelling, captioned video, and heck, maybe for your audience, now’s the time to start looking into AR/VR! Who knows. Sometimes you just need to try stuff out and see what sticks. Just be careful. Experimentation is great, but we need to make sure we’re bringing everyone along for the ride.
Mel Choyce, “Show, Don’t Tell”
Your personal site is a statement of who you are and what you want to do. If you showcase your favorite type of work, you’ll get more requests for similar projects or jobs — feeding back into a virtuous cycle of doing more of what you love.
Amelia Wattenberger, “Exactly What You Want”
And one of my favorites:
But the prime reason to have a personal website is in the name: it is your personalhome on the web. Since its early days, the web has been about sharing information and freedom of expression. Personal websites still deliver on that promise. Nowhere else do you have that much freedom to create and share your work and to tell your personal story. It is your chance to show what you stand for, to be different, and to be specific. Your site lets you be uniquely you and it can be whatever you imagine it to be.
So if you have a personal site, make sure to put in the work and attention to make it truly yours. Make it personal. Fine-tune the typography, add a theme switcher, or incorporate other quirky little details that add personality. As Sarah Drasner writes, you can feel it if a site is done with care and excitement. Those are the sites that are a joy to visit and will be remembered.
Matthias Ott, “Make it Personal”
That last one has the added perk of reminding me how incredibly great Sarah Drasner is.
Aggregating my distributed self originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Many of you — perhaps most of you — have been sitting on the sidelines while WordPress and WP Engine trade legal attacks on one another. It’s been widely covered as we watch it unfold in the open; ironically, in a sense.
These things can take twists and turns and it doesn’t help that this just so happens to be an emotionally charged topic in certain circles. WordPress is still the leading CMS after all these years and by a long shot. Many developers make their living in the WordPress ecosystem. All of those developers need hosting. WP Engine is still the leading WordPress-flavored host after many years. Many developers host their agencies there and use it to administrate their clients’ sites.
And I haven’t even gotten to the drama. That’s not really the point. The point is that there’s a bunch of heated words flying around and it can be difficult to know where they’re coming from, who they are aimed at, and most importantly, why they’re being said in the first place. So, I’m going to round up a few key voices contributing to the discussion for the sake of context and to help catch up.
Editor’s Note: Even though CSS-Tricks has no involvement with either company, I think it’s mentioning that Automattic was a looooooong time sponsor. This site was also once hosted by Flywheel, a company acquired by WP Engine before we moved to Cloudways following the DigitalOcean acquisition. Me? My personal site runs on WP Engine, but I’m not precious about it having only been there one year.
Prelude to a tweetWe had fair warning that something was coming up when WordPress co-founder Matt Mullenweg sent this out over X:
I know private equity and investors can be brutal (read the book Barbarians at the Gate). Please let me know if any employee faces firing or retaliation for speaking up about their company's participation (or lack thereof) in WordPress. We'll make sure it's a big public deal and…
— Matt Mullenweg (@photomatt) September 19, 2024
There’s the ammo: Don’t let private equity bully you into speaking up against the company you work for when its contributions to WordPress are on the slim side of things.
Private equity. Lack of participation in the WordPress community. Making a big public deal of it. Remember these things because this is one day before…
WordCamp US 2024Matt spoke at WordCamp US (WCUS) 2024 in Portland, OR, last week. September 20 to be exact. Making big, bold statements at WCUS isn’t a new thing for Matt, as many of us still have “Learn JavaScript deeply” tattooed on the brain from 2016.
Matt’s statements this year were less inspirational (instructional?) as they took direct aim at WP Engine as part of a presentation on the merits of open-source collaboration. You can watch and listen to where the first jab was thrown roughly around the 10:05 marker of the recording.
Let’s break down the deal. Matt begins by comparing the open-source contributions to WordPress from his company, Automattic, to those of WP Engine. These things are tracked on WordPress.org as part of a campaign called “Five for the Future” that’s designed to give organizations an influential seat at the table to form the future of WordPress in exchange for open-source contributions back to the WordPress project. Automattic has a page totaling its contributions. So does WP Engine.
Before Matt reveals the numbers, he goes out of his way to call out the fact that both Automattic and WP Engine are large players in the neighborhood of $500 million dollars. That’s the setup for Matt to demonstrate how relatively little WP Engine contributes to WordPress against Matt’s own company. Granted, I have absolutely no clue what factors into contributions, nor how the pages are administrated or updated. But here’s what they show…
Quite the discrepancy! I’d imagine Automattic dwarfs every other company that’s pledged to the campaign. Maybe it would be better to compare the contributions of another non-Automattic pledge that has a fairly strong reputation for participating in WordPress community. 10up is one of the companies that comes straight to my mind and they are showing up for 191 hours per week, or roughly five times WP Engine’s reported time. I get conflicting info on 10up’s revenue, valuation, and size, so maybe the comparison isn’t fair. Or maybe it is fair because 10up is certainly smaller than WP Engine, and no estimate I saw was even close to the $500 million mark.
Whatever the case, bottom line: Matt calls out WP Engine for its lack of effort on a very public stage — maybe the largest — in WordPress Land. He doesn’t stop there, going on to namecheck Silver Lake, a ginormous private equity firm bankrolling the company. The insinuation is clear: there’s plenty of money and resources, so pony up.
That’s bad enough for attendees to raise eyebrows, but it doesn’t end there. Matt encourages users and developers alike to vote with money by not purchasing hosting from WP Engine (11:31) and seems to suggest (23:05) that he’ll provide financial support to any WP Engine employees who lose their jobs from speaking up against their employer.
I think I can get behind the general idea that some companies need a little prodding to pull their weight to something like the Five for the Future campaign. Encouraging developers to pull their projects from a company and employees to jeopardize their careers? Eek.
“WP Engine is not WordPress”This is when I believe things got noisy. It’s one thing to strong-arm a company (or its investors) into doing more for the community. But in a post on his personal blog the day after WCUS, Matt ups the ante alleging that “WP Engine isn’t WordPress.” You’d think this is part of the tough-guy stance he had from the stage, but his argument is much different in this post. Notice it’s about how WP Engine uses WordPress in its business rather than how much the company invests in it:
WordPress is a content management system, and the content is sacred. Every change you make to every page, every post, is tracked in a revision system, just like the Wikipedia. This means if you make a mistake, you can always undo it. It also means if you’re trying to figure out why something is on a page, you can see precisely the history and edits that led to it. These revisions are stored in our database. This is very important, it’s at the core of the user promise of protecting your data, and it’s why WordPress is architected and designed to never lose anything.
WP Engine turns this off. They disable revisions because it costs them more money to store the history of the changes in the database, and they don’t want to spend that to protect your content. It strikes to the very heart of what WordPress does, and they shatter it, the integrity of your content.
OK, gloves off. This is more personal. It’s no longer about community contributions but community trust and how WP Engine erodes trust by preventing WordPress users from accessing core WordPress features for their own profit.
Required readingThat’s where I’d like to end this, at least for now. Several days have elapsed since Matt’s blog post and there are many, many more words flying around from him, community members, other companies, and maybe even your Great Aunt. But if you’re looking for more signal than noise, I’ve rounded up a few choice selections that I feel contribute to the (heated) discussion.
Reddit: Matt Mullenweg needs to step down from WordPress.org leadership ASAPMatt responds to the requisite calls for him to step down, starting with:
To be very clear, I was 100% cordial and polite to everyone at the booth, my message was:
I know this isn’t about them, it’s happening several levels above, it’s even above their CEO, it’s coming from their owner, Silver Lake and particularly their board member Lee Wittlinger.
Several people inside WP Engine have been anonymously leaking information to me about their bad behavior, and I wanted to let them know if they were caught or faced retaliation that I would support them in every way possible, including covering their salaries until they found a new job.
That if we had to take down the WP Engine booth and ban WP Engine that evening, my colleague Chloé could print them all new personal badges if they still wanted to attend the conference personally, as they are community members, not just their company.
This was delivered calmly, and they said thank you, and their head of comms, Lauren Cox, who was there asked that they have time to regroup and discuss.
Automattic’s Actionable Misconduct Directed to WP EngineWP Engine issues a cease and desist letter designed to stop Matt from disparaging them publicly. But hold up, because there’s another juicy claim in there:
In the days leading up to Mr. Mullenweg’s September 20th keynote address at the WordCamp US Convention, Automattic suddenly began demanding that WP Engine pay Automattic large sums of money, and if it didn’t, Automattic would wage a war against WP Engine.
And yes, they did issue it from their own site’s /wp-content directory. That’s easy to lose, so I’ve downloaded it to link it for posterity.
Open Source, Trademarks, and WP EngineJust today, Matt published a cease and desist letter to the Auttomatic blog where he alleges that WP Engine’s commercial modifications to WordPress Core violate the WordPress trademark. Again, this has become about licensing, not contributions:
WP Engine’s business model is based on extensive and unauthorized use of these trademarks in ways that mislead consumers into believing that WP Engine is synonymous with WordPress. It’s not.
This is trademark abuse, not fair competition.
This is no longer WordPress vs. WP Engine. It’s more like Automattic vs. WP Engine. But with Matt’s name quite literally in the name Automattic, let’s be real and call this Matt Mullenweg vs. WP Engine.
WP Tavern coverageWP Tavern is still the closest thing we have to an official WordPress news outlet. Nevermind that it’s funded and hired by Automattic (among others). I respect it, though I honestly have been less attentive to it since the team turned over earlier this year. It’s still a great spot to catch up on the post-event coverage:
There’s another more recent WP Tavern article I want to call out because it’s a huge development in this saga…
WP Engine Banned from Using WordPress.org ResourcesDang. This is the point of no return. It not only affects WP Engine proper, but the Flywheel hosting it also owns.
WordPress.org has blocked WP Engine customers from updating and installing plugins and themes via WP Admin.
I was able to update plugins on my site as recently as yesterday, but let’s see as of this morning.
Aww, biscuits.Maybe I can still see details about my installed plugins…
Double biscuits!This is a bad, bad situation. I have thoughts about it and neither side looks good. Using real people with no dog in the fight to make a point is never gonna be a good look. Then again, both sides have valid points and I can see where they’re coming from. I just hate to see it come to a head like this.
Catching Up on the WordPress 🚫 WP Engine Sitch originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
A new tool from Eric Meyer, Brian Kardell, and Stephanie Stimac backed with Igalia’s support. Brian announced it on his blog, as did Eric, describing it like this:
What BCD Watch does is, it grabs releases of the Browser Compatibility Data (BCD) repository that underpins the support tables on MDN and services like caniuse.com. It then analyzes what’s changed since the previous release.
Every Monday, BCD Watch produces two reports. The Weekly Changes Report lists all the changes to BCD that happened in the previous week — what’s been added, removed, or renamed in the whole of BCD. It also tells you which of the Big Three browsers newly support (or dropped support for) each listed feature, along with a progress bar showing how close the feature is to attaining Baseline status.
Browser support data is at MDN. There’s also plenty at Caniuse.com. The two share data, in fact, though not all of it. We now have Baseline, which is also cited in MDN and Caniuse alike. It’s nice to see an effort at cracking a central spot for all this — organized by date, no less.
Oh, and hey, there’s a feed. Even better.
You can also poke at its repo. Thanks a bunch, Eric, Brian, Stephanie, and Igalia! This is super helpful and already part of my toolkit.
BCD Watch originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
The <select> element is a fairly straightforward concept: focus on it to reveal a set of <option>s that can be selected as the input’s value. That’s a great pattern and I’m not suggesting we change it. That said, I do enjoy poking at things and found an interesting way to turn a <select> into a dial of sorts — where options are selected by scrolling them into position, not totally unlike a combination lock or iOS date pickers. Anyone who’s expanded a <select> for selecting a country knows how painfully long lists can be and this could be one way to prevent that.
Here’s what I’m talking about:
CodePen Embed FallbackIt’s fairly common knowledge that styling <select> in CSS is not the easiest thing in the world. But here’s the trick: we’re not working with No, we’re not going to do anything like building our own
```
``` What we need is to style the list of selectable controls where we are capable of managing their sizes and spacing in CSS. I’ve gone with a group of labels with nested radio boxes as far as the markup goes. The exact styling is totally up to you, of course, but you can use these base styles I wrote up if you want a starting point.
.scroll-container { /* SIZING & LAYOUT */ --itemHeight: 60px; --itemGap: 10px; --containerHeight: calc((var(--itemHeight) * 7) + (var(--itemGap) * 6)); width: 400px; height: var(--containerHeight); align-items: center; row-gap: var(--itemGap); border-radius: 4px; /* PAINT */ --topBit: calc((var(--containerHeight) - var(--itemHeight))/2); --footBit: calc((var(--containerHeight) + var(--itemHeight))/2); background: linear-gradient( rgb(254 251 240), rgb(254 251 240) var(--topBit), rgb(229 50 34 / .5) var(--topBit), rgb(229 50 34 / .5) var(--footBit), rgb(254 251 240) var(--footBit)); box-shadow: 0 0 10px #eee;}
A couple of details on this:
--itemHeight is the height of each item in the list.--itemGap is meant to be the space between two items.--containerHeight variable is the .scroll-container’s height. It’s the sum of the item sizes and the gaps between them, ensuring that we display, at maximum, seven items at once. (An odd number of items gives us a nice balance where the selected item is directly in the vertical center of the list).--topBit and –-footBit variables are color stops that visually paint in the middle area (which is orange in the demo) to represent the currently selected item.I’ll arrange the controls in a vertical column with flexbox declared on the .scroll-container:
.scroll-container { display: flex; flex-direction: column; /* rest of styles */}
With layout work done, we can focus on the scrolling part of this. If you haven’t worked with CSS Scroll Snapping before, it’s a convenient way to direct a container’s scrolling behavior. For example, we can tell the .scroll-container that we want to enable scrolling in the vertical direction. That way, it’s possible to scroll to the rest of the items that are not in view.
.scroll-container { overflow-y: scroll; /* rest of styles */}
Next, we reach for the scroll-snap-style property that can be used to tell the .scroll-container that we want scrolling to stop on an item — not near an item, but directly on it.
.scroll-container { overflow-y: scroll; scroll-snap-type: y mandatory; /* rest of styles */}
Now items “snap” onto an item instead of allowing a scroll to end wherever it wants. One more little detail I like to include is overscroll-behavior, specifically along the y-axis as far as this demo goes:
.scroll-container { overflow-y: scroll; scroll-snap-type: y mandatory; overscroll-behavior-y: none; /* rest of styles */}
overscroll-behavior-y: none isn’t required to make this work, but when someone scrolls through the .scroll-container (along the y-axis), scrolling stops once the boundary is reached, and any further continued scrolling action will not trigger scrolling in any nearby scroll containers. Just a form of defensive CSS.
Time to move to the items inside the scroll container. But before we go there, here are some base styles for the items themselves that you can use as a starting point:
.scroll-item { /* SIZING & LAYOUT */ width: 90%; box-sizing: border-box; padding-inline: 20px; border-radius: inherit; /* PAINT & FONT */ background: linear-gradient(to right, rgb(242 194 66), rgb(235 122 51)); box-shadow: 0 0 4px rgb(235 122 51); font: 16pt/var(--itemHeight) system-ui; color: #fff; input { appearance: none; } abbr { float: right; } /* The airport code */}
As I mentioned earlier, the --itemHeight variable is setting as the size of each item and we’re declaring it on the flex property — flex: 0 0 var(--itemHeight). Margin is added before and after the first and last items, respectively, so that every item can reach the middle of the container through scrolling.
The scroll-snap-align property is there to give the .scroll-container a snap point for the items. A center alignment, for instance, snaps an item’s center (vertical center, in this case) with the .scroll-container‘s center (vertical center as well). Since the items are meant to be selected through scrolling alone pointer-events: none is added to prevent selection from clicks.
One last little styling detail is to set a new background on an item when it is in a :checked state:
.scroll-item { /* Same styles as before */ /* If input="radio" is :checked */ &:has(:checked) { background: rgb(229 50 34); }}
But wait! You’re probably wondering how in the world an item can be :checked when we’re removing pointer-events. Good question! We’re all finished with styling, so let’s move on to figuring some way to “select” an item purely through scrolling. In other words, whatever item scrolls into view and “snaps” into the container’s vertical center needs to behave like a typical form control selection. Yes, we’ll need JavaScript for that.
let observer = new IntersectionObserver(entries => { entries.forEach(entry => { with(entry) if(isIntersecting) target.children[1].checked = true; });}, { root: document.querySelector(`.scroll-container`), rootMargin: `-51% 0px -49% 0px`});document.querySelectorAll(`.scroll-item`).forEach(item => observer.observe(item));
The IntersectionObserver object is used to monitor (or “observe”) if and when an element (called a target) crosses through (or “intersects”) another element. That other element could be the viewport itself, but in this case, we’re observing the .scroll-container for when a .scroll-item intersects it. We’ve established the observed boundary with rootMargin:"-51% 0px -49% 0px".
A callback function is executed when that happens, and we can use that to apply changes to the target element, which is the currently selected .scroll-item. In our case, we want to select a .scroll-item that is at the halfway mark in the .scroll-container: target.children[1].checked = true.
That completes the code. Now, as we scroll through the items, whichever one snaps into the center position is the selected item. Here’s a look at the final demo again:
CodePen Embed FallbackLet’s say that, instead of selecting an item that snaps into the .scroll-container‘s vertical center, the selection point we need to watch is the top of the container. No worries! All we do is update the scroll-snap-align property value from center to start in the CSS and remove the :first-of-type‘s top margin. From there, it’s only a matter of updating the scroll container’s background gradient so that the color stops highlight the top instead of the center. Like this:
CodePen Embed FallbackAnd if one of the items has to be pre-selected when the page loads, we can get its position in JavaScript (getBoundingClientRect()) and use the scrollTo() method to scroll the container to where that specific item’s position is at the point of selection (which we’ll say is the center in keeping with our original demo). We’ll append a .selected class on that .scroll-item.
```
``
Let’s select the.selected` class, get its dimensions, and automatically scroll to it on page load:
let selected_item = (document.querySelector(".selected")).getBoundingClientRect();let scroll_container = document.querySelector(".scroll-container");scroll_container.scrollTo(0, selected_item.top - scroll_container.offsetHeight - selected_item.height);
It’s a little tough to demo this in a typical CodePen embed, so here’s a live demo in a GitHub Page (source code). I’ll drop a video in as well:
That’s it! You can build up this control or use it as a starting point to experiment with different layouts, styles, animations, and such. It’s important the UX clearly conveys to the users how the selection is done and which item is currently selected. And if I was doing this in a production environment, I’d want to make sure there’s a good fallback experience for when JavaScript might be unavailable and that my markup performs well on a screen reader.
References and further reading* A Few Functional Uses for Intersection Observer to Know When an Element is in View (Preethi Sam) * An Explanation of How the Intersection Observer Watches (Travis Almand) * Practical CSS Scroll Snapping (Max Kohler) * The Current State of Styling Selects in 2019 (Chris Coyier) * CSS Flexbox Layout Guide (CSS-Tricks) * CSS flex property (CSS-Tricks) * CSS Scroll Snap Properties (MDN) * scrollTo() (MDN)
How to Make a “Scroll to Select” Form Control originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Mixing colors in CSS is pretty much a solved deal, thanks to the more recent color-mix() function as it gains support. Pass in two color values — any two color values at all — and optionally set the proportions.
background-color: color-mix(#000 30%, #fff 70%);
We also have the relative color syntax that can manipulate colors from one color space to another and modify them from there. The preeminent use case being a way to add opacity to color values that don’t support it, such as named colors.
background-color: hsl(from black h s l); /* hsl(0 0% 0%) */background-color: hsl(from black h s l / 50%); /* hsl(0 0% 0% / 50%) */
We can get hacky and overlay one opaque element with another, I suppose.
CodePen Embed FallbackSame general idea maybe, but with mix-blend-mode?
CodePen Embed FallbackAnother roundabout way of getting there is something I saw this morning when looking over the updates that Ryan added to the animation property in the Almanac. Now, we all know that animation is shorthand for about a gajillion other properties (the order of which always eludes me). One of those is animation-composition and it’s used to… well, Ryan nails the explanation:
Defining a property in CSS also sets what is considered the underlying value of the property. By default, keyframe animations will ignore the underlying value, as they only consider the effect values defined within the animation. Keyframes create a stack of effect values, which determines the order in which the animation renders to the browser. Composite operations are how CSS handles the underlying effect combined with the keyframe effect value.
Manuel Matuzović and Robin Rendle also have excellent ways of explaining the property, the former of which sparked us to update the Almanac.
OK! We have three values supported by animation-composition to replace the underlying property value in favor of the effect value defined in keyframes, add to them, or accumulate for combining multiple values. The add value is what’s interesting to us because… oh gosh, let’s just let Ryan take it:
[I]nstead of replacing an underlying
background-colorproperty value with the keyframe’s effect value, the color type values are combined, creating new colors.
A-ha! The example goes like this:
CodePen Embed FallbackSee that? The add value blends the two colors as one transitions to the other. Notice, too, how much smoother that transition is than the replace value, although we wind up with a completely new color at the 100% mark rather than the color we declared in the keyframes. What if we pause the animation at some arbitrary point? Can we extract a new color value from it?
Ryan made this so that hovering on the elements pauses the animation. If we crack open DevTools and force the :hover pseudo on the element, maybe we can head over to the Computed tab to get the new color value.
Interestingly, we get some RGB conversions in there. Probably because updating color channels is easier than converting one hex to another? Browsers do smart stuff.
Now I want to go update my old color interpolation demo…
CodePen Embed FallbackHmm, not any different to my untrained eye. Maybe that’s only because we’re changing the HSL’s hue channel and it’s super subtle. Whatever the case, animation-composition can produce new computed color values. What you need those for and what you’d do with them? I dunno, but go wild.
Color Mixing With Animation Composition originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
I was looking over an older article Patrick Brosset penned for us introducing <selectmenu>, a new proposal at the time for a more style-able cousin to <select>. From there, I clicked the linked-up <selectmenu> explainer and got… this:
OK, link rot is a thing and happens all the time. Perhaps the site needs a little URL designing? But no, it’s not that at all. I searched a bit and found Jared White’s post saying that <selectmenu> is no more, which came by way of Una’s post over at the Chrome Developer Blog seeking feedback on a “customizable select”. And Adam Argyle’s got a wonderful demo dedicated to it, no surprise there.
I’m only sharing the links for now but plan to spend some time with it and jot down notes on Open UI’s new page for the Customizable <select>. I enjoyed looking at the boilerplate from Adam’s demo as a first glance:
select { &, &::picker(select) { appearance: base-select; } &::picker(select) { transition: display allow-discrete 1s, opacity 1s, overlay 1s allow-discrete ; } &:not(:open)::picker(select) { opacity: 0; } &:open::picker(select) { opacity: 1; @starting-style { opacity: 0; } }}
I see the ::picker(select) there that’s driving all of it. If I sneak a peek at Una’s post, I see that there are more ways to select different <select> parts, including:
<selectedoption> (the current selection)<option> (which now accepts HTML in between the tags!)option::beforeoption:checked (a little confusion here with the selected option)<button> (the little chevron arrow marker thingy)So, perhaps Chrome is more of a fan of extending the native <select> with additional CSS features for selecting the existing parts rather than moving forward with a completely new element. That’s cool, as one of Una’s demos shows how we still get the default <select> behavior even if a browser does not support the new selectors.
CodePen Embed Fallback
The selectmenu Element is No More…Long Live select! originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
For the past two months, all my livelihood has gone towards reading, researching, understanding, writing, and editing about Anchor Positioning, and with many Almanac entries published and a full Guide guide on the way, I thought I was ready to tie a bow on it all and call it done. I know that Anchor Positioning is still new and settling in. The speed at which it’s moved, though, is amazing. And there’s more and more coming from the CSSWG!
That all said, I was perusing the last CSSWG minutes telecon and knew I was in for more Anchor Positioning when I came to the following resolution:
Whenever you are comparing names, and at least one is tree scoped, then both are tree scoped, and the scoping has to be exact (not subtree) (Issue #10526: When does
anchor-scope“match” a name?)
Resolutions aren’t part of the specification or anything, but the strongest of indications about where they’re headed. So, I thought this was a good opportunity not only to take a peek at what we might get in anchor-scope and touch on other interesting bits from the telecon.
Remember that you can subscribe and read the full minutes on W3C.org. :)
Full TranscriptionWhat’s anchor-scope?To register an anchor, we can give it a distinctive anchor-name and then absolutely positioned elements with a matching position-anchor are attached to it. Even though it may look like it, anchor-name doesn’t have to be unique — we may reuse an anchor element inside a component with the same anchor-name.
```
``` However, if we try to connect them with CSS,
.anchor { anchor-name: --my-anchor;}.target { position: absolute; position-anchor: --my-anchor; position-area: top right;}
We get an unpleasant surprise where instead of each .anchor having their .target positioned at its top-right edge, they all pile up on the last .anchor instance. We can see it better by rotating each target a little. You’ll want to check out the next demo in Chrome 125+ to see the behavior:
CodePen Embed FallbackThe anchor-scope property should make an anchor element only discoverable by targets in their individual subtree. So, the prior example would be fixed in the future like this:
.anchor { anchor-name: --my-anchor; anchor-scope: --my-anchor;}
This is fairly straightforward — anchor-scope makes the anchor element available only in that specific subtree. But then we have to ask another question: What should the anchor-scope own scope be? We can’t have an anchor-scope-scope property and then an anchor-scope-scope-scope and so on… so which behavior should it be?
This is what started the conversation, initially from a GitHub issue:
When an
anchor-scopeis specified with a<dashed-ident>, it scopes the name to that subtree when the anchor name is “matching”. The problem is that this matching can be interpreted in at least three ways: (Assuming thatanchor-scopeis a tree-scoped reference, which is also not clear in the spec):
- It matches by the ident part of the name only, ignoring any tree-scope that would be associated with the name, or
- It matches by exact match of the ident part and the associated tree-scope, or
- It matches by some mechanism similar to dereferencing of tree-scoped references, where it’s a match when the tree-scope of the anchor-scope-name is an inclusive ancestor of the tree-scope of the anchor query.
And then onto the CSSWG Minutes:
TabAtkins: In anchor positioning, anchor names and references are tree scoped. The
anchor-scopeproperty that scopes, does not say whether the names are tree scoped or not. Question to decide: should they be?TabAtkins: I think the answer should be yes. If you have an anchor in a shadow tree with a part involved, then problems result if anchor scopes are not tree scoped. This is bad, so I think it should be tree scoped
sounds pretty reasonable
makes sense to me as far as I can understand it :)
This solution of the scope of scoping properties expanded towards View Transitions, which also rely on tree scoping to work:
khush: Thinking about this in the context of view transitions: in that API you give names and the tree scope has to be the same for them to match. There is another view transitions feature where I’m not sure if the spec says it’s tree scoped
khush: Want to make sure that feature is covered by the more general resolution
TabAtkins: Proposed more general resolution: whenever you are comparing names, and at least one is tree scoped, then both are tree scoped, and the scoping has to be exact (not subtree)
So the scope of anchor-scope is tree-scoped. Say that five times fast!
RESOLVED: whenever you are comparing names, and at least one is tree scoped, then both are tree scoped, and the scoping has to be exact (not subtree)
The next resolution was pretty straightforward. Besides allowing a <dashed-ident> that says that specific anchor is three-scoped, the anchor-scope property can take an all keyword, which means that all anchors are tree-scoped. So, the question was if all is also a tree-scoped value.
TabAtkins:
anchor-scope, in addition to idents, can take the keyword ‘all‘, which scopes all names. Should this be a tree-scoped ‘all‘? (i.e. only applies to the current tree scope)TabAtkins: Proposed resolution: the ‘
all‘ keyword is also tree-scoped in the same way sgtm +1, again same pattern withview-transition-groupRESOLVED: the ‘
all‘ keyword is tree-scoped
The conversation switched gears toward new properties coming in the CSS Scroll Snap Module Level 2 draft, which is all about changing the user’s initial scroll with CSS. Taking an example directly from the spec, say we have an image carousel:
```

``
We could set the initial scroll to show another image by setting it’sscroll-start-targettoauto`:
.carousel { overflow-inline: auto;}.carousel .origin { scroll-start-target: auto;}
As of right now, the only way to achieve this is using JavaScript to scroll an element into view:
document.querySelector(".origin").scrollIntoView({ behavior: "auto", block: "center", inline: "center"});
The last example is probably a carousel that is only scrollable in the inline direction. Still, there are doubts as far when the container is scrollable in both the inline and block directions. As seen in the initial GitHub issue:
The scroll snap 2 spec says that when there are multiple elements that could be
scroll-start-targetsfor a scroll container “user-agents should select the one which comes first in tree order“.Selecting the first element in tree-order seems like a natural way to resolve competition between multiple targets which would be scrolled to in one particular axis but is perhaps not as flexible as might be needed for the 2d case where an author wants to scroll to one item in one axis and another item in the other axis.
And back to the CSSWG minutes:
DavidA: We have a property we’re adding called
scroll-start-targetthat indicates if an element within a scroll container, then the scroll should start with that element onscreen. Question is what happens if there are multiple targets?
DavidA: Propose to do it in reverse-DOM order, this would result in the first one applied last and then be on screen. Also, should only change the scroll position if you have to.
After discussing why we have to define scroll-start-target when we have scroll-snap-align, the discussion went on the reverse-DOM order:
fantasai: There was a bunch of discussion about regular vs reverse-DOM order. Where did we end up and why?
flackr: Currently, we expect that it scrolls to the first item in DOM order. We probably want that to still happen. That is why the proposal is to scroll to each item in sequence in reverse-DOM order.
So we are coming in reverse to scroll the element, but only as required so the following elements are showing as much as possible:
flackr: There is also the issue of nearest…
fantasai: Can you explain nearest?
flackr: Same as scroll into view
fantasai: ?
flackr: This is needed with you scroll multiple things into view and want to find a good position (?)
fantasai: You scroll in reverse-DOM order…when you add the spec can you make it really clear that this is the end result of the algorithm?
flackr: Yes absolutely
fantasai: Otherwise it seems to make sense
And so it was resolved:
Proposed resolution 2: When
scroll-start-targettargets multiple elements, scroll to each in reverse DOM order with text to specify priority is the first item
Lastly, there was the debate about the text-underline-position, that when set to auto says, “The user agent may use any algorithm to determine the underline’s position; however it must be placed at or under the alphabetic baseline.” The discussion was about whether the auto value should automatically adjust the underlined position to match specific language rules, for example, at the right of the text for vertical writing modes, like Japanese and Mongolian.
fantasai: The initial value of
text-underline-positionisauto, which is defined as “find a good place to put the underline”.
Three options there: (1) under alphabetical baseline, (2) fully below text (good for lots-of-descenders cases), (3) for vertical text on the RHS
fantasai: auto value is defined in the spec about ‘how far down below the text’, but doesn’t say things about flipping. The current spec says “at or below”. In order to handle language-specific aspects, there is a default UA style sheet that for Chinese and Japanese and Korean there are differences for those languages. A couple of implementations do this
fantasai: Should we change the spec to mention these things?
fantasai: Or should we stick with the UA stylesheet approach?
The thing is that Chrome and Firefox already place the underline on the right in vertical Japanese when text-underline-position is auto.
CodePen Embed FallbackThe group was left with three options:
A) Keep spec as-is, update Gecko + Blink to match (using UA stylesheet for language switch)
B) Introduce auto to text-emphasis-position and use it in bothtext-emphasis-positionandtext-underline-positionto effect language switches
C) Adopt inconsistent behavior: text-underline-positionuses ‘auto‘ andtext-emphasis-positionuses UA stylesheetMany CSSWG members like Emilio Cobos, TabAtkins, Miriam Suzanne, Rachel Andrew and fantasai casted their votes, resulting in the following resolution:
RESOLVED: add auto value for text-emphasis-position, and change the meaning of text-underline-position: auto to care about left vs right in vertical text
I definitely encourage you to read at the full minutes! Or if you don’t have the time, you can there’s a list just of resolutions.
CSSWG Minutes Telecon (2024-09-18) originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Getting right to it: the CSS-Tricks Almanac got a big refresh this week!
I’m guessing you’re already familiar with this giant ol’ section of CSS-Tricks called the Almanac. This is where we publish references for CSS selectors and properties. That’s actually all we’ve published in there since the beginning of time… or at least since 2009 when most of the original work on it took place. That might as well be the beginning of time in web years. We might even call it Year 1 BR, or one year before responsive.
You don’t need me telling you how different writing CSS is today in the Year 14 AR. Quite simply, the Almanac hasn’t kept pace with CSS which is much, much more than properties and selectors. The truth is that we never really wanted to touch the Almanac because of how it’s configured in the back end and I’m pretty sure I spotted a ghost or two in there when I poked at it.
Visiting the Alamanc now, you’ll find a wider range of CSS information, including dedicated sections for pseudo class selectors, functions, and at-rules in addition to the existing properties and selectors sections. We’ve still got plenty of work to do filling those in (you should help!) but the architecture is there and there’s room to scale things up a little more if needed.
The work was non-trivial and as scary as I thought it would be. Let me walk you around some of what I did.
The situationWe’re proudly running WordPress and have since day one. There’s a lot of benefit to that, especially as templating goes. It may not be everyone’s favorite jam, but I’m more than cool with it and jumped in — damn the torpedoes!
If you’re familiar with WordPress, then you know that content is largely sliced up into two types: pages and posts. The difference between the two is fairly minimal — and nearly indistinguishable as they both employ the same editing interface. There are nuances, of course, but pages are largely different in that they are hierarchal, meaning they’re best for establishing parent-child page relationships for a nicely structured sitemap. Posts, meanwhile, are more meta-driven in the sense that we get to organize things by slapping tags on them or dropping them into a category group or whatever custom taxonomy we might have in reach.
The Almanac is built on pages, not posts. Pages are boss at hierarchy, and the Alamanc is a highly structured area that has a typical sitemap-like flow, and it just so happens to follow alphabetical order. For example, an entry for a CSS property, let’s say aspect-ratio, goes: Almanac → Properties → A → Aspect Ratio.
That doesn’t sound like a bad thing, right? It’s not, but pages are tougher to query in a template than posts, which have a lot more meta we can use for filtering and whatnot. Pages, on the other hand, not so much. (Well, not obviously so much.) They’re usually returned as structured objects because, you know, hierarchy. But it also means we have to manually create all of those pages, unlike tags and categories that automatically generqte archives. It feels so dang silly creating an empty page for the letter “A” that’s a child of the “Properties” page — which is a child of the Almanac itself — just so there’s a logical place to insert properties that begin with the letter A. And that has to happen for both properties and selectors.
The real problem is that the Almanac simply tapped out. We want to publish other CSS-y things in there, like functions and at-rules, but the Almanac was only ever built to show two groups. That’s why we never published anything else. It’s also why general selectors and pseudo-selectors were in the same bucket.
Expanding the place to hold more content was the scope I worked with, knowing that I’d have some chances to style things along the way.
One template to rule them allThat’s how things were done. The original deal was a single template used for the Almanac index and the alphabetical pages that list selectors and properties. It was neat, really. The page first checked if the current page is the Almamac page that sits atop the page hierarchy. If it is that page, then the template spits out the results for selectors and properties on the same page, in two different columns.
The query for that is quite impressive.
```
'page', 'post_status' => 'publish', 'post_parent' => $selectorID, 'posts_per_page' => -1, 'orderby' => 'title', 'order' => "ASC" )); $html = ''; $html .= get_the_title(); $html .= ''; $html .= get_post_meta(get_the_id(), 'almanac_example_code', true); $html .= ''; $html .= 'Continue Reading'; $html .= '``` And if we’re not currently on the index page? The template spits out just the alphabetical list of child pages for that particular section, e.g. properties. One template is enough for all that.
Querying child pagesI could have altered the letterOutput() function to take more page IDs to show the letter pages for other sections. But honestly, I just didn’t want to go there. I chose instead to reduce the function to one page ID argument instead of two, then split the template up: one for the main index and one for the “sub-sections” if you will. Yes, that means I wound up with more templates in my WordPress theme directory, but this is mostly for me and I don’t mind. I can check which sub-page I’m on (whether it’s a property index, selector index, at-rules index, etc.) and get just the child pages for those individually.
The other trouble with the function? All the generated markup is sandwiched inside a while()statement. Even if I wanted to parse the query by section to preserve a single template architecture, it’s not like I can drop an if() statement anywhere I want in there without causing a PHP fatal error or notice. Again, I had no interest in re-jiggering the function wholesale.
Letter archivesPublishing all those empty subpages for the letters of each section and then attaching them to the correct parent page is a lot of manual work. I know because I did it. There’s certainly a better, even programmatic, way but converting things from pages to posts and working from that angle didn’t appeal to me and I was working on the clock. We don’t always get to figure out an “ideal” way of doing things.
It’s a misnomer calling any of these letter pages “archives” according to WordPress parlance, but that’s how I’m looking at the child pages for the different sections — and that’s how it would have been if things were structured as posts instead of pages. If I have a section for Pseudo-Selectors, then I’m going to need individual pages for letters A through Z that, in turn, act as the parent pages for the individual pseudos. Three new sections with 26 letters each means I made 78 new pages. Yay.
You get to a letter page either through the breadcrumbs of an Almanac page (like this one for the aspect-ratio property) or by clicking the large letter in any of the sections (like this one for properties).
We’ve never taken those pages seriously. They’re there for structure, but it’s not like many folks ever land on them. They’re essentially placeholders. Helpful, yes, but placeholders nonetheless. We have been so unserious about these pages that we never formally styled them. It’s a model of CSS inheritance, tell you what.
Yup, you can stop gushing now. 😍This is where I took an opportunity to touch things up visually. I’ve been working with big, beefy things in the design since coming back to this job a few months ago. Things like the oversized headings and thick-as-heck shadows you see.
It’s not my natural aesthetic, but I think it works well with CSS-Tricks… and maybe, just maybe, there’s a tear of joy running down Chris Coyier’s face because of it. Maybe.
NavigatingAnother enhancement was added to the navigation displayed on the main index page. I replaced the alphabetical navigation at the top with a nav that takes you to each section and now we can edit the page directly in WordPress without having to dev around.
Before (top) and after (bottom)The only thing that bothers me is that I hardcoded the dang thing instead of making it a proper WordPress menu that I can manage from the admin. [Adds a TODO to his list.]
Since I freed up the Alamanc index from displaying the selector and property lists in full, I could truly use it as an index for the larger number of sections we’re adding.
There may be a time when we’ll want to make the main page content less redundant with the navigation but I’ll take this as a good start that we can build up from. Plus, it’s now more consistent with the rest of the “top-level” pages linked up in the site’s main menu as far as headers go and that can’t be bad.
Oh yeah, and while we’re talking about navigating around, the new sections have been added to the existing left sidebar on individual Almanac pages to help jump to other entries in any section without having to return to the index.
Yes, that’s really how little content we have in there right now!Quickly reference thingsThe last enhancement I’ll call out is minor but I think it makes a positive difference. If you head over to any subpage of the index — i.e., Selectors, Properties, Pseudos, Functions, At-Rules — a snippet and high-level definition is available for each item at the ready without having to jump to the full page.
We’ve always been big on “get to the examples quickly” and I think this helps that cause quite a bit.
“You could’ve also done [x]…”Yeah, lots more opportunities to tighten things up. The only goal I had in mind was to change things up just enough for the Almanac to cover more than selectors and properties, and maybe take some styling liberties here and there. There’s plenty more I wanna do and maybe we’ll get there, albeit incrementally.
What sort of things? Well, that hardcoded index navigation for one. But more than that, I’d like to keep pushing on the main page. It was serving a great purpose before and I pretty much wiped that out. It’d be good to find a way to list all of the entries — for all sections — the way we did when it was only twe sections. That’s something I plan to poke at.
And, yes, we want to cover even more CSS-y items in there, like general terminology, media and user preference queries, possibly specifications… you get the idea. The Almanac is a resource for us here on the team as much as it is for you, and we refer to it on the daily. We want it flush with useful information.
That’s all.You can stop reading now and just head on over to the Almanac for a virtual stroll.
Re-Working the CSS Almanac originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
I’m working on a refresh of my personal website, what I’m calling the HD remaster. Well, I wouldn’t call it a “full” redesign. I’m just cleaning things up, and Polypane is coming in clutch. I wrote about how much I enjoy developing with Polypane on my personal blog back in March 2023. In there, I say that I discover new things every time I open the browser up and I’m here to say that is still happening as of August 2024.
Polypane, in case you’re unfamiliar with it, is a web browser specifically created to help developers in all sorts of different ways. The most obvious feature is the multiple panes displaying your project in various viewport sizes:
I’m not about to try to list every feature available in Polypane; I’ll leave that to friend and creator, Kilian Valkhof. Instead, I want to talk about a neat feature that I discovered recently.
Outline tabInside Polypane’s sidebar, you will find various tabs that provide different bits of information about your site. For example, if you are wondering how your social media previews will look for your latest blog post, Polypane has you covered in the Meta tab.
The tab I want to focus on though, is the Outline tab. On the surface, it seems rather straightforward, Polypane scans the page and provides you outlines for headings, landmarks, links, images, focus order, and even the full page accessibility tree.
Seeing your page this way helps you spot some pretty obvious mistakes, but Polypane doesn’t stop there. Checking the Show issues option will point out some of the not-so-obvious problems.
In the Landmarks view, there is an option to Show potentials as well, which displays elements that could potentially be page landmarks.
In these outline views, you also can show an overlay on the page and highlight where things are located.
Now, the reason I even stumbled upon these features within the Outline tab is due to a bug I was tracking down, one specifically related to focus order. So, I swapped over to the “Focus order” outline to inspect things further.
That’s when I noticed the option to see an overlay for the focus order.
This provides a literal map of the focus order of your page. I found this to be incredibly useful while troubleshooting the bug, as well as a great way to visualize how someone might navigate your website using a keyboard.
These types of seemingly small, but useful features are abundant throughout Polypane.
Amazing toolWhen I reached out to Kilian, mentioning my discovery, his response was “Everything’s there when you need it!”
I can vouch for that.
Clever Polypane Debugging Features I’m Loving originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Only Chris, right? You’ll want to view this in a Chromium browser:
CodePen Embed FallbackThis is exactly the sort of thing I love, not for its practicality (cuz it ain’t), but for how it illustrates a concept. Generally, tutorials and demos try to follow the “rules” — whatever those may be — yet breaking them helps you understand how a certain thing works. This is one of those.
The concept is pretty straightforward: one target element can be attached to multiple anchors on the page.
```
``
We’ve gotta register the anchors and attach the.target` to them:
.anchor-1 { anchor-name: --anchor-1;}.anchor-2 { anchor-name: --anchor-2;}.target { }
Wait, wait! I didn’t attach the .target to the anchors. That’s because we have two ways to do it. One is using the position-anchor property.
.target { position-anchor: --anchor-1;}
That establishes a target-anchor relationship between the two elements. But it only accepts a single anchor value. Hmm. We need more than that. That’s what the anchor() function can do. Well, it doesn’t take multiple values, but we can declare it multiple times on different inset properties, each referencing a different anchor.
.target { top: anchor(--anchor-1, bottom);}
The second piece of anchor()‘s function is the anchor edge we’re positioned to and it’s gotta be some sort of physical or logical inset — top, bottom, start, end, inside, outside, etc. — or percentage. We’re bascially saying, “Take that .target and slap it’s top edge against --anchor-1‘s bottom edge.
That also works for other inset properties:
.target { top: anchor(--anchor-1 bottom); left: anchor(--anchor-1 right); bottom: anchor(--anchor-2 top); right: anchor(--anchor-2 left);}
Notice how both anchors are declared on different properties by way of anchor(). That’s rad. But we aren’t actually anchored yet because the .target is just like any other element that participates in the normal document flow. We have to yank it out with absolute positioning for the inset properties to take hold.
.target { position: absolute; top: anchor(--anchor-1 bottom); left: anchor(--anchor-1 right); bottom: anchor(--anchor-2 top); right: anchor(--anchor-2 left);}
In his demo, Chris cleverly attaches the .target to two <textarea> elements. What makes it clever is that <textarea> allows you to click and drag it to change its dimensions. The two of them are absolutely positioned, one pinned to the viewport’s top-left edge and one pinned to the bottom-right.
If we attach the .target's top and left edges to --anchor-1‘s bottom and right edges, then attach the target's bottom and right edges to --anchor-2‘s top and left edges, we’re effectively anchored to the two <textarea> elements. This is what allows the .target element to stretch with the <textarea> elements when they are resized.
But there’s a small catch: a <textarea> is resized from its bottom-right corner. The second <textarea> is positioned in a way where the resizer isn’t directly attached to the .target. If we rotate(180deg), though, it’s all good.
CodePen Embed FallbackAgain, you’ll want to view that in a Chromium browser at the time I’m writing this. Here’s a clip instead if you prefer.
That’s just a background-color on the .target element. We can put a little character in there instead as a background-image like Chris did to polish this off.
CodePen Embed FallbackFun, right?! It still blows my mind this is all happening in CSS. It wasn’t many days ago that something like this would’ve been a job for JavaScript.
Multiple Anchors originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
I’m a big Lynn Fisher fan. You probably are, too, if you’re reading this. Or maybe you’re reading her name for the first time, in which case you’re in for a treat.
That’s because I had a chance to sit down with Lynn for a whopping hour to do nothing more than gab, gab, and gab some more. I love these little Smashing Hours because they’re informal like that and I feel like I really get to know the person I’m talking with. And this was my very first time talking with Lynn, we had a ton to talk about — her CSS art, her annual site refreshes, where she finds inspiration for her work, when the web started to “click” for her… and so much more.
Don’t miss the bit where Lynn discusses her current site design (~24 min.) because it’s a masterclass in animation and creativity.
Smashing Hour With Lynn Fisher originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
The **text-box-trim** and **text-box-edge** properties in CSS enable developers to trim specifiable amounts of the whitespace that appear above the first formatted line of text and below the last formatted line of text in a text box, making the text box vertically larger than the content within.
This whitespace is called leading, and it appears above and below (so it’s two half-leadings, actually) all lines of text to make the text more readable. However, we only want it to appear in between lines of text, right? We don’t want it to appear along the over or under edges of our text boxes, because then it interferes with our margins, paddings, gaps, and other spacings.
As an example, if we implement a 50px margin but then the leading adds another 37px, we’d end up with a grand total of 87px of space. Then we’d need to adjust the margin to 13px in order to make the space 50px in practice.
As a design systems person, I try to maintain as much consistency as possible and use very little markup whenever possible, which enables me to use the adjacent-sibling combinator (+) to create blanket rules like this:
/* Whenever <element> is followed by <h1> */<element> + h1 { margin-bottom: 13px; /* instead of margin-bottom: 50px; */}
This approach is still a headache since you still have to do the math (albeit less of it). But with the text-box-trim and text-box-edge properties, 50px as defined by CSS will mean 50px visually:
Disclaimer: text-box-trim and text-box-edge are only accessible via a feature flag in Chrome 128+ and Safari 16.4+, as well as Safari Technology Preview without a feature flag. See Caniuse for the latest browser support.
Start with text-box-trimtext-box-trim is the CSS property that basically activates text box trimming. It doesn’t really have a use beyond that, but it does provide us with the option to trim from just the start, just the end, both the start and end, or none:
text-box-trim: trim-start;text-box-trim: trim-end;text-box-trim: trim-both;text-box-trim: none;
Note: In older web browsers, you might need to use the older start/end/both values in place of the newer trim-start/trim-end/trim-both values, respectively. In even older web browsers, you might need to use top/bottom/both. There’s no reference for this, unfortunately, so you’ll just have to see what works.
Now, where do you want to trim from?You’re probably wondering what I mean by that. Well, consider that a typographic letter has multiple peaks.
There’s the x-height, which marks the top of the letter “x” and other lowercase characters (not including ascenders or overshoots), the cap height, which marks the top of uppercase characters (again, not including ascenders or overshoots), and the alphabetic baseline, which marks the bottom of most letters (not including descenders or overshoots). Then of course there’s the ascender height and descender height too.
You can trim the whitespace between the x-height, cap height, or ascender height and the “over” edge of the text box (this is where overlines begin), and also the white space between the alphabetic baseline or descender height and the “under” edge (where underlines begin if text-underline-position is set to under).
Don’t trim anythingtext-box-edge: leading means to include all of the leading; simply don’t trim anything. This has the same effect as text-box-trim: none or forgoing text-box-trim and text-box-edge entirely. You could also restrict under-edge trimming with text-box-trim: trim-start or over edge trimming with text-box-trim: trim-end. Yep, there are quite a few ways to not even do this thing at all!
Newer web browsers have deviated from the CSSWG specification working drafts by removing the leading value and replacing it with auto, despite the “Do not ship (yet)” warning (shrug).
Naturally, text-box-edge accepts two values (an instruction regarding the over edge, then an instruction regarding the under edge). However, auto must be used solo.
text-box-edge: auto; /* Works */text-box-edge: ex auto; /* Doesn't work */text-box-edge: auto alphabetic; /* Doesn't work */
I could explain all the scenarios in which auto would work, but none of them are useful. I think all we want from auto is to be able to set the over or under edge to auto and the other edge to something else, but this is the only thing that it doesn’t do. This is a problem, but we’ll dive into that shortly.
Trim above the ascenders and/or below the descendersThe text value will trim above the ascenders if used as the first value and below the descenders if used as the second value and is also the default value if you fail to declare the second value. (I think you’d want it to be auto, but it won’t be.)
text-box-edge: ex text; /* Valid */text-box-edge: ex; /* Computed as `text-box-edge: ex text;` */text-box-edge: text alphabetic; /* Valid */text-box-edge: text text; /* Valid */text-box-edge: text; /* Computed as `text-box-edge: text text;` */
It’s worth noting that ascender and descender height metrics come from the fonts themselves (or not!), so text can be quite finicky. For example, with the Arial font, the ascender height includes diacritics and the descender height includes descenders, whereas with the Fraunces font, the descender height includes diacritics and I don’t know what the ascender height includes. For this reason, there’s talk about renaming text to from-font.
Trim above the cap height onlyTo trim above the cap height:
text-box-edge: cap; /* Computed as text-box-edge: cap text; */
Remember, undeclared values default to text, not auto (as demonstrated above). Therefore, to opt out of trimming the under edge, you’d need to use trim-start instead of trim-both:
text-box-trim: trim-start; /* Not text-box-trim: trim-both; */text-box-edge: cap; /* Not computed as text-box-edge: cap text; */
Trim above the cap height and below the alphabetic baselineTo trim above the cap height and below the alphabetic baseline:
text-box-trim: trim-both;text-box-edge: cap alphabetic;
By the way, the “Cap height to baseline” option of Figma’s “Vertical trim” setting does exactly this. However, its Dev Mode produces CSS code with outdated property names (leading-trim and text-edge) and outdated values (top and bottom).
Trim above the x-height onlyTo trim above the x-height only:
text-box-trim: trim-start;text-box-edge: ex;
Trim above the x-height and below the alphabetic baselineTo trim above the x-height and below the alphabetic baseline:
text-box-trim: trim-both;text-box-edge: ex alphabetic;
Trim below the alphabetic baseline onlyTo trim below the alphabetic baseline only, the following won’t work (things were going so well for a moment, weren’t they?):
text-box-trim: trim-end;text-box-edge: alphabetic;
This is because the first value is always the mandatory over-edge value whereas the second value is an optional under-edge value. This means that alphabetic isn’t a valid over-edge value, even though the inclusion of trim-end suggests that we won’t be providing one. Complaints about verbosity aside, the correct syntax would have you declare any over-edge value even though you’d effectively cancel it out with trim-end:
text-box-trim: trim-end;text-box-edge: [any over edge value] alphabetic;
What about ideographic glyphs?It’s difficult to know how web browsers will trim ideographic glyphs until they do, but you can read all about it in the spec. In theory, you’d want to use the ideographic-ink value for trimming and the ideographic value for no trimming, both of which aren’t unsupported yet:
text-box-edge: ideographic; /* No trim */text-box-edge: ideographic-ink; /* Trim */text-box-edge: ideographic-ink ideographic; /* Top trim */text-box-edge: ideographic ideographic-ink; /* Bottom trim */
text-box, the shorthand propertyIf you’re not keen on the verbosity of text box trimming, there’s a shorthand text-box property that makes it somewhat inconsequential. All the same rules apply.
/* Syntax */text-box: [text-box-trim] [text-box-edge (over)] [text-box-edge (under)]?/* Example */text-box: trim-both cap alphabetic;
Final thoughtsAt first glance, text-box-trim and text-box-edge might not seem all that interesting, but they do make spacing elements a heck of a lot simpler.
Is the current proposal the best way to handle text box trimming though? Personally, I don’t think so. I think text-box-trim-start and text-box-trim-end would make a lot more sense, with text-box-trim being used as the shorthand property and text-box-edge not being used at all, but I’d settle for some simplification and/or consistent practices. What do you think?
There are some other concerns too. For example, should there be an option to include underlines, overlines, hanging punctuation marks, or diacritics? I’m going to say yes, especially if you’re using text-underline-position: under or a particularly thick text-decoration-thickness, as they can make the spacing between elements appear smaller.
Two CSS Properties for Trimming Text Box Whitespace originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
I collect a bunch of links in a bookmarks folder. These are things I fully intend to read, and I do — eventually. It’s a good thing bookmarks are digital, otherwise, I’d need a bigger coffee table to separate them from the ever-growing pile of magazines.
The benefit of accumulating links is that the virtual pile starts revealing recurring themes. Two seemingly unrelated posts published a couple months apart may congeal and become more of a dialogue around a common topic.
I spent time pouring through a pile of links I’d accumulated over the past few weeks and noticed a couple of trending topics. No, that’s not me you’re smelling — there’s an aroma of nostalgia in the air., namely a newfound focus on learning web fundamentals and some love for manual deployments.
Web Developers, AI, and Development FundamentalsAlvaro Montero:
Ultimately, it is not about AI replacing developers, but about developers adapting and evolving with the tools. The ability to learn, understand, and apply the fundamentals is essential because tools will only take you so far without the proper foundation.
ShopTalk 629: The Great Divide, Global Design + Web Components, and Job TitlesChris and Dave sound off on The Great Divide in this episode and the rising value of shifting back towards fundamentals:
Dave: But I think what is maybe missing from that is there was a very big feeling of disenfranchisement from people who are good and awesome at CSS and JavaScript and HTML. But then were being… The market was shifting hard to these all-in JavaScript frameworks. And a lot of people were like, “I don’t… This is not what I signed up for.”
[…]
Dave: Yeah. I’m sure you can be like, “Eat shit. That’s how it is, kid.” But that’s also devaluing somebody’s skillset. And I think what the market is proving now is if you know JavaScript or know HTML, CSS, and regular JavaScript (non-framework JavaScript), you are once again more valuable because you understand how a line of CSS can replace 10,000 lines of JavaScript – or whatever it is.
Chris: Yeah. Maybe it’s coming back just a smidge–
Dave: A smidge.
Chris: –that kind of respecting the fundamental stuff because there’s been churn since then, since five years ago. Now it’s like these exclusively React developers we hired, how useful are they anymore? Were they a little too limited and fundamental people are knowing more? I don’t know. It’s hard to say that the job industry is back when it doesn’t quite feel that way to me.
Dave: Yeah, yeah. Yeah, who knows. I just think the value in knowing CSS and HTML, good HTML, are up more than they maybe were five years ago.
Just a Spec: HTML Finally Gets the Respect It DeservesJared and Ayush riffin’ on the first ever State of HTML survey, why we need it, and whether “State of…” surveys are representative of people who work with HTML.
[…] once you’ve learned about divs and H’s 1 through 6, what else is there to know? Quite a lot, as it turns out. Once again, we drafted Lea Verou to put her in-depth knowledge of the web platform to work and help us craft a survey that ended up reaching far beyond pure HTML to cover accessibility, web components, and much more.
[…]
You know, it’s perfectly fine to be an expert at HTML and CSS and know very little JavaScript. So, yeah, I think it’s important to note that as we talk about the survey, because the survey is a snapshot of just the people who know about the survey and answer the questions, right? It’s not necessarily representative of the broad swath of people around the world who have used HTML at all.
[…]
So yeah, a lot of interest in HTML. I’m talking about HTML. And yeah, in the conclusion, Lea Verou talks about we really do have this big need for more extensibility of HTML.
In a more recent episode:
I’m not surprised. I mean, when someone who’s only ever used React can see what HTML does, I think it’s usually a huge revelation to them.
[…]
It just blows their minds. And it’s kind of like you just don’t know what you’re missing out on up to a point. And there is a better world out there that a lot of folks just don’t know about.
[…]
I remember a while back seeing a post come through on social media somewhere, somebody’s saying, oh, I just tried working with HTML forms, just standard HTML forms the first time and getting it to submit stuff. And wait, it’s that easy?
Yeah, last year when I was mentoring a junior developer with the Railsworld conference website, she had come through Bootcamp and only ever done React, and I was showing her what a web component does, and she’s like, oh, man, this is so cool. Yeah, it’s the web platform.
Reckoning: Part 4 — The Way OutAlex Russell in the last installment of an epic four-part series well worth your time to fully grasp the timeline, impact, and costs of modern JavsaScript frameworks to today’s development practices:
Never, ever hire for JavaScript framework skills. Instead, interview and hire only for fundamentals like web standards, accessibility, modern CSS, semantic HTML, and Web Components. This is doubly important if your system uses a framework.
Semi-Annual Reminder to Learn and Hire for Web StandardsAdrian Roselli:
This is a common cycle. Web developers tire of a particular technology — often considered the HTML killer when released — and come out of it calling for a focus on the native web platform. Then they decide to reinvent it yet again, but poorly.
There are many reasons companies won’t make deep HTML / CSS / ARIA / SVG knowledge core requirements. The simplest is the commoditization of the skills, partly because framework and library developers have looked down on the basics.
The anchor elementHeydon Pickering in a series dedicated to HTML elements, starting alphabetically with the good ol’ anchor <a>:
Sometimes, the
<a>is referred to as a hyperlink, or simply a link. But it is not one of these and people who say it is one are technically wrong (the worst kind of wrong).[…]
Web developers and content editors, the world over, make the mistake of not making text that describes a link actually go inside that link. This is collosally unfortunate, given it’s the main thing to get right when writing hypertext.
AI Myth: It lets me write code I can’t on my ownChris Ferndandi:
At the risk of being old and out-of-touch: if you don’t know how to write some code, you probably shouldn’t use code that Chat GPT et al write for you.
[…]
It’s not bulletproof, but StackOverflow provides opportunities to learn and understand the code in a way that AI-generated code does not.
What Skills Should You Focus on as Junior Web Developer in 2024?Frontend Masters:
Let’s not be
old-man-shakes-fist-at-kids.gifabout this, but learning the fundamentals of tech is demonstrateably useful. It’s true in basketball, it’s true for the piano, and it’s true in making websites. If you’re aiming at a long career in websites, the fundamentals are what powers it.[…]
The point of the fundamentals is how long-lasting and transferrable the knowledge is. It will serve you well no matter what other technologies a job might have you using, or when the abstractions over them change, as they are want to do.
As long as we’re talking about learning the fundamentals…
The BasicsOh yeah, and of course there’s this little online course I released this summer for learning HTML and CSS fundamentals that I describe like this:
The Basics is more for your clients who do not know how to update the website they paid you to make. Or the friend who’s learning but still keeps bugging you with questions about the things they’re reading. Or your mom, who still has no idea what it is you do for a living. It’s for those whom the entry points are vanishing. It’s for those who could simply sign up for a Squarespace account but want to understand the code it spits out so they have more control to make a site that uniquely reflects them.
Not all this nostalgia is reserved only for HTML and CSS, but for deploying code, too. A few recent posts riff on what it might look like to ship code with “buildless” or near “buildless” workflows.
Raw-Dogging WebsitesBrad Frost:
It is extraordinarily liberating. Yes, there are some ergonomic inefficiencies, but at the end of the day it comes out in the wash. You might have to copy-and-paste some HTML, but in my experience I’d spend that much time or more debugging a broken build or dependency hell.
Going BuildlessMax Böck in a follow-up to Brad:
So, can we all ditch our build tools soon?
Probably not. I’d say for production-grade development, we’re not quite there yet. Performance tradeoffs are a big part of it, but there are lots of other small problems that you’d likely run into pretty soon once you hit a certain level of complexity.
For smaller sites or side projects though, I can imagine going the buildless route – just to see how far I can take it.
Manual ’till it hurtsJeremy Keith in a follow-up to Max:
If you’re thinking that your next project couldn’t possibly be made without a build step, let me tell you about a phrase I first heard in the indie web community: “Manual ‘till it hurts”. It’s basically a two-step process:
- Start doing what you need to do by hand.
- When that becomes unworkable, introduce some kind of automation.
It’s remarkable how often you never reach step two.
I’m not saying premature optimisation is the root of all evil. I’m just saying it’s premature.
That’s it for this pile of links and good gosh my laptop feels lighter for it. Have you read other recent posts that tread similar ground? Share ’em in the comments.
What’s Old is New originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
HTML forms come with built-in ways to validate form inputs and other controls against predefined rules such as making an input required, setting min and max constraints on range sliders, or establishing a pattern on an email input to check for proper formatting. Native HTML and browsers give us a lot of “free” features that don’t require fancy scripts to validate form submissions.
And if something doesn’t properly validate? We get “free” error messaging to display to the person using the form.
These are usually good enough to get the job done, but we may need to override these messages if we need more specific error content — especially if we need to handle translated content across browsers. Here’s how that works.
The Constraints APIThe Constraints API is used to override the default HTML form validation messages and allows us to define our own error messages. Chris Ferdinandi even covered it here on CSS-Tricks in great detail.
In short, the Constraints API is designed to provide control over input elements. The API can be called at individual input elements or directly from the form element.
For example, let’s say this simple form input is what we’re working with:
```
``
We can set our own error message by grabbing theelement and calling thesetCustomValidity()` method on it before passing it a custom message:
const fullNameInput = document.getElementById("fullName");fullNameInput.setCustomValidity("This is a custom error message");
When the submit button is clicked, the specified message will show up in place of the default one.
Translating custom form validation messagesOne major use case for customizing error messages is to better handle internationalization. There are two main ways we can approach this. There are other ways to accomplish this, but what I’m covering here is what I believe to be the most straightforward of the bunch.
Method 1: Leverage the browser’s language settingThe first method is using the browser language setting. We can get the language setting from the browser and then check whether or not we support that language. If we support the language, then we can return the translated message. And if we do not support that specific language, we provide a fallback response.
Continuing with the HTML from before, we’ll create a translation object to hold your preferred languages (within the script tags). In this case, the object supports English, Swahili, and Arabic.
const translations = { en: { required: "Please fill this", email: "Please enter a valid email address", }, sw: { required: "Sehemu hii inahitajika", email: "Tafadhali ingiza anwani sahihi ya barua pepe", }, ar: { required: "هذه الخانة مطلوبه", email: "يرجى إدخال عنوان بريد إلكتروني صالح", }};
Next, we need to extract the object’s labels and match them against the browser’s language.
// the translations objectconst supportedLangs = Object.keys(translations);const getUserLang = () => { // split to get the first part, browser is usually en-US const browserLang = navigator.language.split('-')[0]; return supportedLangs.includes(browserLang) ? browserLang :'en';};// translated error messagesconst errorMsgs = translations[getUserLang()];// form elementconst form = document.getElementById("myForm");// button elementconst btn = document.getElementById("btn");// name inputconst fullNameInput = document.getElementById("fullName");// wrapper for error messagingconst errorSpan = document.getElementById("error-span");// when the button is clicked…btn.addEventListener("click", function (event) { // if the name input is not there… if (!fullNameInput.value) { // …throw an error fullNameInput.setCustomValidity(errorMsgs.required); // set an .error class on the input for styling fullNameInput.classList.add("error"); }});
Here the getUserLang() function does the comparison and returns the supported browser language or a fallback in English. Run the example and the custom error message should display when the button is clicked.
Method 2: Setting a preferred language in local storageA second way to go about this is with user-defined language settings in localStorage. In other words, we ask the person to first select their preferred language from a <select> element containing selectable <option> tags. Once a selection is made, we save their preference to localStorage so we can reference it.
<label for="languageSelect">Choose Language:</label><select id="languageSelect"> <option value="en">English</option> <option value="sw">Swahili</option> <option value="ar">Arabic</option></select><form id="myForm"> <label for="fullName">Full Name</label> <input type="text" id="fullName" name="fullName" placeholder="Enter your full name" required> <span id="error-span"></span> <button id="btn" type="submit">Submit</button></form>
With the <select> in place, we can create a script that checks localStorage and uses the saved preference to return a translated custom validation message:
// the <select> elementconst languageSelect = document.getElementById("languageSelect");// the <form> elementconst form = document.getElementById("myForm");// the button elementconst btn = document.getElementById("btn");// the name inputconst fullNameInput = document.getElementById("fullName");const errorSpan = document.getElementById("error-span");// translated custom messagesconst translations = { en: { required: "Please fill this", email: "Please enter a valid email address", }, sw: { required: "Sehemu hii inahitajika", email: "Tafadhali ingiza anwani sahihi ya barua pepe", }, ar: { required: "هذه الخانة مطلوبه", email: "يرجى إدخال عنوان بريد إلكتروني صالح", }};// the supported translations objectconst supportedLangs = Object.keys(translations);// get the language preferences from localStorageconst getUserLang = () => { const savedLang = localStorage.getItem("preferredLanguage"); if (savedLang) return savedLang; // provide a fallback message const browserLang = navigator.language.split('-')[0]; return supportedLangs.includes(browserLang) ? browserLang : 'en';};// set initial languagelanguageSelect.value = getUserLang();// update local storage when user selects a new languagelanguageSelect.addEventListener("change", () => { localStorage.setItem("preferredLanguage", languageSelect.value);});// on button clickbtn.addEventListener("click", function (event) { // take the translations const errorMsgs = translations[languageSelect.value]; // ...and if there is no value in the name input if (!fullNameInput.value) { // ...trigger the translated custom validation message fullNameInput.setCustomValidity(errorMsgs.required); // set an .error class on the input for styling fullNameInput.classList.add("error"); }});
The script sets the initial value to the currently selected option, saves that value to localStorage, and then retrieves it from localStorage as needed. Meanwhile, the script updates the selected option on every change event fired by the <select> element, all the while maintaining the original fallback to ensure a good user experience.
If we open up DevTools, we’ll see that the person’s preferred value is available in localStorage when a language preference is selected.
Wrapping upAnd with that, we’re done! I hope this quick little tip helps out. I know I wish I had it a while back when I was figuring out how to use the Constraints API. It’s one of those things on the web you know is possible, but exactly how can be tough to find.
References* Form Validation series (Chris Ferdinandi) * Meet the Pseudo Class Selectors (Chris Coyier) * Constraint validation (MDN) * Client-side form validation (MDN)
Two Ways to Create Custom Translated Messaging for HTML Forms originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
I am thrilled to say, that from this week onwards, the CSS-tricks Almanac has an entry for each property, function, and at-rule related to the new Anchor Positioning API! For the last month, I have tried to fully understand this new module and explain it to the best of my ability. However, anchor positioning is still a new feature that brings even newer dynamics on how to position absolute elements, so it’s bound to have some weird quirks and maybe even a few bugs lurking around.
To celebrate the coverage, I wanted to discuss those head-scratchers I found while diving into this stuff and break them down so that hopefully, you won’t have to bang your head against the wall like I did at first.
The inset-modified containing blockA static element containing block is a fairly straightforward concept: it’s that element’s parent element’s content area. But things get tricky when talking about absolutely positioned elements. By default, an absolutely positioned element’s containing block is the viewport or the element’s closest ancestor with a position other than static, or certain values in properties like contain or filter.
All in all, the rules around an absolute element’s containing block aren’t so hard to remember. While anchor positioning and the containing block have their quirks (for example, the anchor element must be painted before the positioned element), I wanted to focus on the inset-modified containing block (which I’ll abbreviate as IMCB from here on out).
There isn’t a lot of information regarding the inset-modified containing block, and what information exists comes directly from the anchor positioning specification module. This tells me that, while it isn’t something new in CSS, it’s definitely something that has gained relevance thanks to anchor positioning.
The best explanation I could find comes directly from the spec:
For an absolutely positioned box, the inset properties effectively reduce the containing block into which it is sized and positioned by the specified amounts. The resulting rectangle is called the inset-modified containing block.
So if we inset an absolutely positioned element’s (with top, left, bottom, right, etc.), its containing block shrinks by the values on each property.
.absolute { position: absolute; top: 80px; right: 120px; bottom: 180px; left: 90px;}
For this example, the element’s containing block is the full viewport, while its inset modified containing block is 80px away from the top, 120px away from the right, 180px away from the bottom, and 90px away from the left.
Knowing how the IMCB works isn’t a top priority for learning CSS, but if you want to understand anchor positioning to its fullest, it’s a must-know concept. For instance, the position-area and position-try-order heavily rely on this concept.
In the case of the position-area property, a target containing block can be broken down into a grid divided by four imaginary lines:
anchor(start).anchor(end).The position-area property uses this 3×3 imaginary grid surrounding the target to position itself inside the grid. So, if we have two elements…
```
``` …attached with anchor positioning:
.anchor { anchor-name: --my-anchor; height: 50px; width: 50px;}.target { position: absolute; position-anchor: --my-anchor; height: 50px; width: 50px;}
…we can position the .target element using the position-area property:
.target { position: absolute; position-anchor: --my-anchor; position-area: top left; height: 50px; width: 50px;}
The IMCB is shrunk to fit inside the region of the grid we selected, in this case, the top-left region.
You can see it by setting both target’s dimensions to 100%:
CodePen Embed FallbackThe position-try-order also uses the IMCB dimensions to decide how to order the fallbacks declared in the position-try-fallbacks property. It checks which one of the fallbacks provides the IMCB with the largest available height or width, depending on whether you set the property with either the most-height or most-width values.
I had a hard time understanding this concept, but I think it’s perfectly shown in a visual tool by Una Kravets on https://chrome.dev/anchor-tool/.
Specification vs. implementationThe spec was my best friend while I researched anchor positioning. However, theory can only take you so far, and playing with a new feature is the fun part of understanding how it works. In the case of anchor positioning, some things were written in the spec but didn’t actually work in browsers (Chromium-based browsers at the time). After staring mindlessly at my screen, I found the issue was due to something so simple I didn’t even consider it: the browser and the spec didn’t match.
Anchor positioning is different from many other features in how fast it shipped to browsers. The first draft was published on June 2023 and, just a year later, it was released on Chrome 125. To put itinto perspective, the first draft for custom properties was published in 2012 and we waited four years to see it implemented (although, Firefox shipped it years before other browsers).
I am excited to see browsers shipping new CSS features at a fast pace. While it’s awesome to get new stuff faster, it leaves less space between browsers and the CSSWG to remake features and polish existing drafts. Remember, once something is available in browsers, it’s hard to change or remove it. In the case of anchor positioning, browsers shipped certain properties and functions early on that were ultimately changed before the spec had fully settled into a Candidate Recommendation.
It’s a bit confusing, but as of Chrome 129+, this is the stuff that Chrome shipped that required changes:
position-areaThe inset-area property was renamed to position-area (#10209), but it will be supported until Chrome 131.
.target { /* from */ inset-area: top right; /* to */ position-area: top right;}
position-try-fallbacksThe position-try-options was renamed to position-try-fallbacks (#10395).
.target { /* from */ position-try-options: flip-block, --smaller-target; /* to */ position-try-fallbacks: flip-block, --smaller-target;}
inset-area()The inset-area() wrapper function doesn’t exist anymore for the position-try-fallbacks (#10320), you can just write the values without the wrapper.
.target { /* from */ position-try-options: inset-area(top left); /* to */ position-try-fallbacks: top left;}
anchor(center)In the beginning, if we wanted to center a target from the center, we would have to write this convoluted syntax:
.target { --center: anchor(--x 50%); --half-distance: min(abs(0% - var(--center)), abs(100% - var(--center))); left: calc(var(--center) - var(--half-distance)); right: calc(var(--center) - var(--half-distance));}
The CWSSG working group resolved (#8979) to add the anchor(center) argument for much-needed brevity.
.target { left: anchor(center);}
Bugs!Some bugs snuck into browser implementations of anchor positioning. For example, the spec says that if an element doesn’t have a default anchor element, then the position-area property does nothing. This is a known issue (#10500) but it’s still possible to replicate, so please, just don’t do it.
The following code…
.container { position: relative;}.element { position: absolute; position-area: center; margin: auto;}
…centers the .element inside its container as we can see in this demo from Temani Afif:
CodePen Embed FallbackAnother example comes from the position-visibility property. If your anchor element is off-screen, you typically want its target to be hidden as well. The spec says the default is anchors-visible, but browsers go with always instead.
Chrome currently isn’t reflecting the spec. It indeed is using
alwaysas the initial value. But the spec’s text is intentional — if your anchor is off-screen or otherwise scrolled off, you usually want it to hide (#10425).
Anchor positioning accessibilityWhile anchor positioning’s most straightforward use case is for stuff like tooltips, infoboxes, and popovers, it can be used for a lot of other stuff as well. Check this example by Silvestar Bistrović, for example, where he connects elements with lines. He’s tethered elements together for decorative purposes, so anchor positioning doesn’t mean there is a semantic relationship between the elements. As a consequence, non-visual agents, like screen readers, are left in the dark about how to interpret two seemingly unrelated elements.
If we’re aiming to link a tooltip to another element, we need to set up a relationship in the DOM and let anchor positioning handle the visuals. Happily, there are APIs (like the Popover API) that do this for us, even establishing an anchor relationship that we can take advantage of to create more compelling visuals.
In a general way, the spec describes an approach to create this relationship using ARIA attributes such as the aria-details or aria-describedby, along the role attribute on the target element.
So, while we could attach the following two elements…
```
``` …using anchor positioning:
.anchor { anchor-name: --my-anchor;}.toolip { position: absolute; position-anchor: --my-anchor; position-area: top;}
…but screen readers only see two elements next to one another without any remarked relationship. That’s a bummer for accessibility, but we can easily fix it using the corresponding ARIA attribute:
```
``` And now they are both visually and semantically linked together! It would just be better if could pull it off without ARIA.
ConclusionBeing confused by a new feature just to finally understand it is one of the most satisfying experiences anyone in programming can feel. While there are still some things about anchor positioning that can be (and are) confusing, I’m pleased to say the CSS-Tricks Almanac now has a deluge of information to help clarify things.
The most exciting thing is that anchor positioning is still in an early stage. That means there are many more confusing things coming for us to discover and learn!
Anchor Positioning Quirks originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Inclusive Design 24 is in 8 short days — and it’s FREE, no sign-up required!
Quick Hit #14 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Seems like we’re always talking about clipping text around here. All it takes is a little browsing to spot a bunch of things we’ve already explored.
**Article** on Sep 19, 2012 Multi-line Text Overflow Ellipsis accessibility content truncation Chris Coyier **Article** on Mar 16, 2021 Better Line Breaks for Long URLs accessibility content truncation Reuben Lillie **Article** on Feb 8, 2017 Flexbox and Truncated Text accessibility content truncation Chris Coyier **Article** on Jun 20, 2017 Handling Long and Unexpected Content in CSS accessibility content truncation Ahmad Shadeed **Article** on Oct 1, 2021 Line Clampin’ (Truncating Multiple Line Text) accessibility content truncation Chris Coyier **Article** on Oct 23, 2021 Multi-Line Truncation with Pure CSS accessibility content truncation Chris Coyier **Article** on Jun 30, 2022 Text-overflow: ellipsis considered harmful accessibility content truncation Geoff Graham **Article** on Jul 21, 2020 Using Flexbox and text ellipsis together accessibility content truncation Chris Coyier It’s harder than it looks! And there’s oodles of consideration that go into it! Last time I visited this, I recreated a cool-looking implementation on MDN.
CodePen Embed FallbackIn there, I noted that VoiceOver respects the full text and announces it.
I started wondering: What if I or someone else wanted to read the full text but didn’t have the assistive tech for it? It’s almost a selfish-sounding thing because of long-term muscle memory telling me I’m not the user. But I think that’s a valid request for a more inclusive experience. All folks should be able to get the full content, visually or announced.
I didn’t want to make the same demo, so I opted for something a little different that poses similar challenges, perhaps even more so. This is it:
CodePen Embed FallbackI know, not the loveliest thing ever. But it’s an interesting case study because:
<button> in there to trigger the auto-height transition.This setup does what my MDN experiment doesn’t: give users without assistive tech a path to the full content. Right on, ship it! But wait…
Now VoiceOver (I’m sorry that’s all I’ve tested in) announces the button. I don’t want that. I don’t even need that because a screen reader already announces the full text. It’s not like someone who hears the text needs to expand the panel or anything. They should be able to skip it!
But should we really “hide” the button? So much conventional wisdom out there tells us that it’s terrible to hide and disable buttons. Any control that’s there for sighted readers should be present for hearing listeners as well.
If we were to simply drop disabled="true" on the button, that prevents the screen reader from pressing the button to activate something needlessly. But now we’ve created a situation where we’ve disabled something without so much as an explanation why. If I’m hearing that there’s a button on the page and it’s disabled (or dimmed), I want to know why because it sounds like I might be missing out on something even if I’m not. Plus, I don’t want to disable the button by default, especially for those who need it.
This is where “real world” Geoff would likely stop and question the pattern altogether. If something is getting this complicated, then there’s probably a straighter path I’m missing. But we’re all learners here, so I gave that other Geoff a shiny object and how he’s distracted for hours.
Let’s say we really do want to pursue this pattern and make it where the button remains in place but also gives assistive tech-ers some context. I know that the first rule of ARIA is “don’t use ARIA” but we’ve crossed that metaphorical line by deciding to use a <button>. We’re not jamming functionality into a <div> but are using a semantic element. Seems like the “right” place to reach for ARIA.
We could “hide” the button this way:
```
``
Buuuut, slappingaria-hidden="true"on a focusable element is not considered a best practice. There’s anaria-approach that’s equivalent to thedisabledattribute, only it doesn’t actuallydisable` the button when we’re not using a screen reader.
<button aria-disabled="true">Show Full Text</button>
Cool, now it’s hidden! Ship it. Oh, wait again. Yes, we’ve aria-disabled the button as far as assistive tech is concerned, but it still doesn’t say why.
Still some work to do. I recently learned a bunch about ARIA after watching Sara Soueidan’s “The Other C in CSS” presentation. I’m not saying I “get” it all now, but I wanted to practice and saw this demo as a good learning exercise. I learned a couple different ways we can “describe” the button accessibly:
aria-label: If our element is interactive (which it is), we can use this to compose a custom description for the button, assuming the button’s accessible name is not enough (which it’s not).aria-labelledby: It’s like aria-label but allows us to reference another element on the page that’s used for the button’s description.Let’s focus on that second one for a moment. That might look something like this:
<button aria-disabled="true" aria-labelledby="notice">Show Full Text</button><span id="notice">This button is disabled since assistive tech already announces the article content.</span>
The element we’re referencing has to have an id. And since an ID can only be used once a page we’ve gotta make sure this is the only instance of it, so make it unique — more unique than my lazy example. Once it’s there, though, we want to hide it because sighted folks don’t need the additional context — it’s for certain people. We can use some sort of .visually-hidden utility class to hide the text inclusively so that screen readers still see and announce it:
.visually-hidden:not(:focus):not(:active) { width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); /* for IE only */ clip-path: inset(50%); position: absolute; white-space: nowrap;}
Let’s make sure we’re referencing that in the CSS:
<button aria-disabled="true" aria-labelledby="notice">Show Full Text</button><span id="notice" class="visually-hidden">This button is disabled since assistive tech already announces the article content.</span>
This certainly does the trick! VoiceOver recognizes the button, calls it out, and reads the .visually-hidden notice as the button’s description.
I’m pretty sure I would ship this. That said, the markup feels heavy with hidden span. We had to intentionally create that span purely to describe the button. It’s not like that element was already on the page and we decided to recycle it. We can get away with aria-label instead without the extra baggage:
```
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quidem asperiores reprehenderit, dicta illum culpa facere qui ab dolorem suscipit praesentium nostrum delectus repellendus quas unde error numquam maxime cupiditate quaerat? ``` VoiceOver seemed to read things a little smoother this time around. For example, it read the `aria-label` content right away and announced the button and its state only once. Left to my own devices, I’d call this “baked” and, yes, finally *ship it*. But not left to my own devices, this probably isn’t up to snuff. I inspected the button again in DevTools to see how the accessibility API translates it. This looks *off* to me. I know that the button’s accessible name should come from the `aria-label` if one is available, but I also know two other ARIA attributes are designed specifically for describing elements: * **`aria-description`:** This is likely what we want! MDN describes it as “a string value that describes or annotates the current element.” Perfect for our purposes. But! MDN notes that this is still in the Editor’s Draft of the ARIA 1.3 specification. It shows up widely supported in Caniuse at the same time. It’s probably safe to use but I’m strangely conservative with features that haven’t been formally recommended and would be hesitant to ship this. I’m sure an actual accessibility practitioner would have a more informed opinion based on testing. * **`aria-describedby`:** We can leverage another element’s accessible description to describe the button just like we did with `aria-labelledby`. Cool for sure, but not what we need right here since I wouldn’t want to introduce another element only to hide it for its description. But don’t just take it from me! I’m feeling my way through this — and only partially as far as testing. This also might be a completely dumb use case — hiding text is usually a bad thing, including little excerpts like this. I’m expecting we’ll get some tips from smarter folks in the comments. Here’s that final demo once again. It’s editable, so feel free to poke around. CodePen Embed Fallback --- Another Stab at Truncated Text originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
View Transitions are one of the most awesome features CSS has shipped in recent times. Its title is self-explanatory: transitions between views are possible with just CSS, even across pages of the same origin! What’s more interesting is its subtext, since there is no need to create complex SPA with routing just to get those eye-catching transitions between pages.
What also makes View Transitions amazing is how quickly it has gone from its first public draft back in October 2022 to shipping in browsers and even in some production contexts like Airbnb — something that doesn’t happen to every feature coming to CSS, so it shows how rightfully hyped it is.
That said, the API is still new, so it’s bound to have some edge cases or bugs being solved as they come. An interesting way to keep up with the latest developments about CSS features like View Transitions is directly from the CSS Telecom Minutes (you can subscribe to them at W3C.org).
Full Transcription
View Transitions were the primary focus at the August 21 meeting, which had a long agenda to address. It started with a light bug in Chrome regarding the navigation descriptor, used in every cross-document view transition to opt-in to a view transition.
@view-transition { navigation: auto | none;}
Currently, the specs define navigation as an enum type (a set of predefined types), but Blink takes it as a CSSOMString (any string). While this initially was passed as a bug, it’s interesting to see the conversation it sparked on the GitHub Issue:
Actually I think this is debatable, we don’t currently have at rules that use enums in that way, and usually CSSOM doesn’t try to be fully type-safe in this way. e.g. if we add new navigation types and some browsers don’t support them, this would interpret them as invalid rules rather than rules with empty navigation.
The last statement may not look exciting, but it opens the possibility of new navigation types beyond auto and none, so think about what a different type of view transition could do.
And then onto the CSSWG Minutes:
emilio: Is it useful to differentiate between missing auto or none?
noamr: Yes, very important for forward compat. If one browser adds another type that others don’t have yet, then we want to see that there’s a difference between none or invalid
emilio: But then you get auto behavior?
noamr: No, the unknown value is not read for purpose of nav. It’s a vt role without navigation descriptor and no initial value Similar to having invalid rule
So in future implementations, an invalid navigation descriptor will be ignored, but exactly how is still under debate:
ntim: How is it different from navigation none?
noamr: Auto vs invalid and then auto vs none. None would supersede auto; it has a meaning to not do a nav while invalid is a no-op.
ntim: So none cancels the nav from the prev doc?
noamr: Yes
The none has the intent to cancel any view transitions from a previous document, while an invalid or empty string will be ignored. In the end, it resolved to return an empty string if it’s missing or invalid.
RESOLVED: navigation is a CSSOMString, it returns an empty string when navigation descriptor is missing or invalid
Onto the next item on the agenda. The discussion went into the view-transition-group property and whether it should have an order of precedence. Not to confuse with the pseudo-element of the same name (::view-transition-group) the view-transition-group property was resolved to be added somewhere in the future. As of right now, the tree of pseudo-elements created by view transitions is flattened:
::view-transition├─ ::view-transition-group(name-1)│ └─ ::view-transition-image-pair(name-1)│ ├─ ::view-transition-old(name-1)│ └─ ::view-transition-new(name-1)├─ ::view-transition-group(name-2)│ └─ ::view-transition-image-pair(name-2)│ ├─ ::view-transition-old(name-2)│ └─ ::view-transition-new(name-2)│ /* and so one... */
However, we may want to nest transition groups into each other for more complex transitions, resulting in a tree with ::view-transition-group inside others ::view-transition-group, like the following:
::view-transition├─ ::view-transition-group(container-a)│ ├─ ::view-transition-group(name-1)│ └─ ::view-transition-group(name-2)└─ ::view-transition-group(container-b) ├─ ::view-transition-group(name-1) └─ ::view-transition-group(name-2)
So the view-transition-group property was born, or to be precise, it will be at some point in timer. It might look something close to the following syntax if I’m following along correctly:
view-transition-group: normal | <ident> | nearest | contain;
* **normal** is contained by the root ::view-transition (current behavior).
* **<ident>** will be contained by an element with a matching view-transition-name
* **nearest** will be contained by its nearest ancestor with view-transition-name.
* **contain** will contain all its descendants without changing the element’s position in the tree
The values seem simple, but they can conflict with each other. Imagine the following nested structure:
A /* view-transition-name: foo */└─ B /* view-transition-group: contain */ └─ C /* view-transition-group: foo */
Here, B wants to contain C, but C explicitly says it wants to be contained by A. So, which wins?
vmpstr: Regarding nesting with view-transition-group, it takes keywords or ident. Contain says that all of the view-transition descendants are nested. Ident says same thing but also element itself will nest on the thing with that ident. Question is what happens if an element has a view-transition-group with a custom ident and also has an ancestor set to contain – where do we nest this? the contain one or the one with the ident? noam and I agree that ident should probably win, seems more specific.
: +1
The conversations continued if there should be a contain keyword that wins over <ident>
emilio: Agree that this seems desirable. Is there any use case for actually enforcing the containment? Do we need a strong contain? I don’t think so?
astearns: Somewhere along the line of adding a new keyword such as contain-idents?
: “contain-all”emilio: Yeah, like sth to contain everything but needs a use case
But for now, it was set for <ident> to have more specificity than contain
PROPOSED RESOLUTION: idents take precedence over contain in view-transition-group
astearns: objections or concerns or questions?
: just as they do for<ident>values. (which also apply containment, but only to ‘normal’ elements)RESOLVED: idents take precedence over contain in view-transition-group
Lastly, the main course of the discussion: whether or not some properties should be captured as styles instead of as a snapshot. Right now, view transitions work by taking a snapshot of the “old” view and transitioning to the “new” page. However, not everything is baked into the snapshot; some relevant properties are saved so they can be animated more carefully.
From the spec:
However, properties like
mix-blend-modewhich define how the element draws when it is embedded can’t be applied to its image. Such properties are applied to the element’s corresponding ::view-transition-group() pseudo-element, which is meant to generate a box equivalent to the element.
In short, some properties that depend on the element’s container are applied to the ::view-transition-group rather than ::view-transition-image-pair(). Since, in the future, we could nest groups inside groups, how we capture those properties has a lot more nuance.
noamr: Biggest issue we want to discuss today, how we capture and display nested components but also applies to non-nested view transition elements derived from the nested conversation. When we nest groups, some CSS properties that were previously not that important to capture are now very important because otherwise it looks broken. Two groups: tree effects like opacity, mask, clip-path, filters, perspective, these apply to entire tree; borders and border-radius because once you have a hierarchy of groups, and you have overflow then the overflow affects the origin where you draw the borders and shadows these also paint after backgrounds
noamr: We see three options.
- Change everything by default and don’t just capture snapshot but add more things that get captured as ?? instead of a flat snapshot (opacity, filter, transform, bg borders). Will change things because these styles are part of the group but have changed things before (but this is different as it changes observable computed style)
- Add new property
view-transition-styleorview-transition-capture-mode. Fan of the first as it reminds me oftransform-style.- To have this new property but give it auto value. If group contains other groups when you get the new mode so users using nesting get the new mode but can have a property to change the behavior If people want the old crossfade behavior they can always do so by regular DOM nesting
Regarding the first option about changing how all view transitions capture properties by default:
bramus: Yes, this would be breaking, but it would break in a good way. Regarding the name of the property, one of the values proposed is cross-fade, which is a value I wouldn’t recommend because authors can change the animation, e.g. to scale-up/ scale-down, etc. I would suggest a different name for the property,
view-transition-capture-mode: flat | layered
Of course, changing how view transitions work is a dilemma to really think about:
noamr: There is some sentiment to 1 but I feel people need to think about this more?
astearns: Could resolve on option 1 and have blink try it out to see how much breakage there is and if its manageable then we’re good and come back to this. Would be resolving one 1 unless it’s not possible. I’d rather not define a new capture mode without a switch
…so the best course of action was to gather more data and decide:
khush: When we prototype we’ll find edge cases. We will take those back to the WG in that case. Want to get this right
noamr: It involves a lot of CSS props. Some of them are captured and not painted, while others are painted. The ones specifically would all be specified
After some more discussion, it was resolved to come back with compat data from browsers, you can read the full minutes at W3C.org. I bet there are a lot of interesting things I missed, so I encourage you to read it.
RESOLVED: Change the capture mode for all view-transitions and specify how each property is affected by this capture mode change
RESOLVED: Describe categorization of properties in the Module Interactions sections of each spec
RESOLVED: Blink will experiment and come back with changes needed if there are compat concerns
CSSWG Minutes Telecon (2024-08-21) originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
I sure do love little reminders about HTML semantics, particularly semantics that are tougher to commit to memory. Scott has a great one, beginning with this markup:
``` I am a paragraph.
I am also a paragraph.You might hate it, but I'm a paragraph too.* Even I am a paragraph. * Though I'm a list item as well.
I might trick you
Guess who? A paragraph! ```
You may look at that markup and say “Hey! You can’t fool me, only the
elements are “real” paragraphs!
You might even call out such elements as
divs orspans being used as “paragraphs” a WCAG failure.But, if you’re thinking those sorts of things, then maybe you’re not aware that those are actually all “paragraphs”.
It’s easy to forget this since many of those non-paragraph elements are not allowed in between paragraph tags and it usually gets all sorted out anyway when HTML is parsed.
The accessibility bits are what I always come to Scott’s writing for:
Those examples I provided at the start of this post? macOS VoiceOver, NVDA and JAWS treat them all as paragraphs ([asterisks] for NVDA, read on…). […] The point being that screen readers are in step with HTML, and understand that “paragraphs” are more than just the
pelement.
Paragraphs originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Didya see that Tumblr is getting a WordPress makeover? And it’s not a trivial move:
This won’t be easy. Tumblr hosts over half a billion blogs. We’re talking about one of the largest technical migrations in internet history. Some people think it’s impossible. But we say, “challenge accepted.”
Half a billion blogs. Considering that WordPress already powers somewhere around 40% of all websites (which is much, much higher than 500m) this’ll certainly push that figure even further.
I’m sure there’s at least one suspicious nose out there catching whiffs of marketing smoke though I’m amicable to the possibility that this is a genuine move to enhance a beloved platform that’s largely seen as a past relic of the Flickr era. I loved Tumblr back then. It really embraced the whole idea that a blog can help facilitate better writing with a variety of post formats. (Post formats, fwiw, are something I always wished would be a WordPress first-class citizen but they never made it out of being an opt-in theme feature). Tumblr was the first time I was able to see blogging as more than a linear chain of content organized in reverse chronological order. Blog posts are more about what you write and how you write it than they are when they’re written.
Anyway, I know jobs are a scarce commodity in tech these days and Auttomatic is looking for folks to help with the migration.
I was about to say this “could” be a neat opportunity, but nay, it’s a super interesting and exciting opportunity, one where your work is touching two of the most influential blogging platforms on the planet. I remember interviewing Alex Hollender and Jon Robson after they shipped a design update to Wikipedia and thinking how much fun and learning would come out of a project like that. This has that same vibe to me. Buuuut, make no illusions about it: it’ll be tough.
Shipping Tumblr and WordPress originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
I think it’s worth listening to anything Sara Soueidan has to say. That’s especially true if she’s speaking at an event for the first time in four years, which was the case when she took the stage at CSS Day 2024 in Amsterdam. What I enjoy most about Sara is how she not only explains the why behind everything she presents but offers it in a way that makes me go “a-ha!” instead of “oh crap, I’m doing everything wrong.”
(Oh, and you should take her course on Practical Accessibility.)
Sara’s presentation, “The Other ‘C’ in CSS”, was published on YouTube just last week. It’s roughly 55 minutes of must-see points on the various ways CSS can, and does, impact accessibility. I began watching the presentation casually but quickly fired up a place where I could take thorough notes once I found myself ooo-ing and ahhh-ing along.
So, these are the things I took away from Sara’s presentation. Let me know if you’ve also taken notes so we can compare! Here we go, there’s a lot to take in.
Here’s the videoYes, CSS affects accessibilityCSS changes more than the visual appearance of elements, whether we like it or not. More than that, its effects cascade down to HTML and the accessibility tree (accTree). And when we’re talking about the accTree, we’re referring to a list of objects that describes and defines accessible information about elements.
There are typically four main bits of info about an accTree object:
The browser provides interactive features — like checking a checkbox that updates and exposes the element’s information — so the user knows what happens following an interaction.
Accessibility tree objects may also contain properties and relationships, such as whether it is part of a group or labeled by another element.
Example: List semanticsCSS can affect an object’s accessible role, name, description, or even whether it is exposed in the accTree at all. As such, it can directly impact the screen reader announcement. We shared a while back how removing list-style affects list semantics, particularly in the case of Safari, and Sara explains its nuances.
/* Removes list role semantics in Safari *//* Need to add aria-role=list */ul { list-style: none;}/* Does not remove role semantics in Safari */nav ul { list-style: none:}/* Removed unless specifically re-added in the markup */ul:where([role="list"]) { list-style: none;}/* Preserves list semantics */ul { list-style: "";}
display: contentsCSS can completely remove the presence of an element from the accessibility tree. I took a screenshot from one of Sara’s slides but it’s just so darn helpful that I figured putting the info in a table would be more useful:
| Exposed to a11y APIs? | Keyboard accessible? | Visually accessible (rendered)? | Children exposed to a11y APIs? |
| --- | --- | --- | --- |
| display: none | ❌ | ❌ | ❌ | ❌ |
| visibility: hidden | ❌ | ❌ | ❌ | ❌ |
| opactity: 0 and filter: opacity(0) | ✅ | ✅ | ❌ | ✅ |
| clip-path: inset(100%) | ✅ | ✅ | ❌ | ✅ |
| position(off-canvas) | ✅ | ✅ | ❌ | ✅ |
| .visually-hidden | ✅ | ✅ | ❌ | ✅ |
| display: contents | ✅ | ✅ | ❌ | ✅ |
The display: contents method does more than it’s supposed to. In short, we know that display controls the type of box an element generates. A value of none, for example, generates no box.
The contents value is sort of like none in that not box is generated. The difference is that it has no impact on the element’s children. In other words, declaring contents does not remove the element or its child elements from the accTree. More than that, there’s a current bug report saying that declaring contents in Firefox breaks the anchoring effect of an ID attribute attached to an element.
Eric Bailey says that using display: contents is considered harmful. If using it, the recommendation is to set it on a generic <div> instead of a semantically meaningful element. If we were to use it on a meaningful interactive element, it would be removed from the accTree, and its children would be bumped up to the next level in the DOM.
Visually hiding stuffMany, many of us use some sort of .visibility-hidden class as a utility for hiding elements while allowing screenreaders to pick them up and announce the contents. TPGi has a great breakdown of the technique.
.visually-hidden:not(:focus):not(:active) { width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); /* for IE only */ clip-path: inset(50%); position: absolute; white-space: nowrap;}
This is super close to what I personally use in my work, but the two :not() statements were new to me and threw me for a loop. What they do is make sure that the selector only applies when the element is neither focused nor activated.
It’s easy to slap this class on things we want to hide and call it a day. But we have to be careful and use it intentionally when the situation allows for us to hide but still announce an element. For example, we would not want to use this on interactive elements because those should be displayed at all times. If you’re interacting with something, we have to be able to see it. But for generic text stuff, all good. Skip to content links, too.
There’s an exception! We may want an animated checkbox and have to hide the native control’s appearance so that it remains hidden, even though CSS is styling it in a way that it is visible. We still have to account for the form control’s different states and how it is announced to assistive tech. For example, if we hide the native checkbox for a custom one by positioning it way off the screen, the assistive tech will not announce it on focus or activation. Better to absolutely position the checkbox over the custom one to get the interactive accessibility benefits.
Bottom line: Ask yourself whether an interactive element will become visible when it receives focus when deciding whether or not to use a .visually-hidden utility.
CSS and accessible namesThe browser follows a specific process when it determines an element’s accessible name (accName):
aria-labelledby. If present, and if the ID in the attribute is a valid reference to an element on the page, it uses the reference’s element’s computed text as the element’s accessible name.aria-label.role="presentation" or role="none" (i.e., the element does not accept an accName anymore), the browser checks if the element can get its own name, which could happen in a few ways, including:alt or title (which is best on an <iframe>; otherwise, avoid),<label> or <legend>, orAt this point, Sara went into a brief (but wonderful) tangent on <button> semantics. Buttons are labelable elements and can get their accName by using an aria-label attribute, an aria-labelledby attribute, its contents, or even a <label> element.
ARIA takes precedence over HTML which is why we want to avoid it only where we have to. We can see the priorities and overrides for accessible names in DevTools under the Accessibility tab when inspecting elements.
But note: the order of priority defined in the accName computation algorithm does not define the order of priority that you should follow when providing an accName to elements. The steps should like be reversed if anything. Prioritize native HTML!
CSS generated contentAvoid using CSS to create meaningful content. Here’s why:
<a href="#" class="info">CSS generated content</a>
.info::before { content: "ⓘ" / "Info: "; /* or */ content: url('path-to-icon.svg') / "Info: ";}/* Contents: : Info: CSS generated content. */
But it’s more nuanced than that. For one, we’re unable to translate content generated by CSS into different languages, at least via automated tools. Another one: that content is gone if CSS is unavailable for whatever reason. I didn’t think this would ever be too big a concern until Sara reminded me that some contexts completely strip out CSS, like Safari’s Reader Mode (something I rely on practically every day, but wish I didn’t have to).
There are also edge cases where CSS generated content might be inaccessible, including in Forced Colors environments (read: color conflicts), or if a broken image is passed to the url() function (read: alt text of the image is not shown in place of the broken image, at least in most browsers, yet it still contributes to the accName, violating SC 2.5.3 Label in Name). Adrian Roselli’s article on the topic includes comprehensive test results of the new feature, showing different results.
Inline SVG is probably better! But we can also do this to help with icons that are meant to be decorative to not repeat redundant information. But it is inconsistent as far as browser implementation (but Sara says Safari gets it right).
/* like: <img src="icon.svg" alt=""> */.icon { content: url('path/to/icon.svg') / "";}
So, what can we do to help prevent awkward and inaccessible situations that use CSS generated content?
alt text (when support is there and behavior is consistent).CSS can completely strip an element of its accName……if the source of the name is hidden in a way that removes it from the accessibility tree.
For example, an <input> can get its accName from a <label>, but that label is hidden by CSS in a way that doesn’t expose it to a11y APIs. In other words, the <label> is no longer rendered and neither are its contents, so the input winds up with no accName.
BUT! Per spec:
By default assistive technologies do not relay hidden information, but an author can explicitly override that and include hidden text as part of the accessible name or accessible description by using
aria-labelledbyoraria-describedby.
So, in this case, we can reuse the label even if it is hidden by tacking on aria-labelledby. We could use the .visually-hidden utility, but the label is still accessible and will continue to be announced.
CSS does not affect the state of an element in the accTreeIf we use a <button> to show/hide another element, for example, the <button> element state needs to expose that state. Content on hover or focus violates SC 1.4.13 which requires a way to dismiss the content. And users must be able to move their cursor away from the text and have it persist.
CSS-only modals using the checkbox hack are terrible because they don’t trap focus, don’t make the page content inert, and don’t manage keyboard focus (without JavaScript).
Popovers created with the Popover API are always non-modal. If you want to create a modal popover, a <dialog> is the right way to go. I’m enamored with Jhey Tompkins’s demo using the popover for a flyout navigation component, so much so that I used it in another article. But, using popover for modal-type stuff — including for something like a flyout nav — we still need to update the accessible states.
There’s much more to consider, from focus traps to inert content. But we can also consider removing the popover’s ::backdrop for fewer restrictions, like making background content inert or trapping focus. Then again, something like a popover-based flyout navigation violates SC 2.4.12 Focus Not Obscured if it covers or obscures another element with focus. So, yes, visibility is important for usability but we should shoot for better usability that goes beyond WCAG conformance. (Sara elaborates on this in a comment down below.)
So… close the popover when focus leaves it. Sara mentioned an article that Amit Sheen wrote for Smashing Magazine where it’d be wise to pay close attention to how a change is communicated to the user when a <select> menu <option> is selected to update colors on the page. That poses issues about SC 3.2.2 where something changes on input. When the user interacts with it, the user should know what’s going to happen.
Final thoughtsYeah, let all that sink in. It feels good, right? Again, what I love most about Sara’s presentation (or any of them, for that matter) is that she isn’t pointing any condemning fingers at anyone. I care about oodles accessible experiences but know just how much I don’t know, and it’s practical stuff like this where I see clear connections to my work that can make me better.
I took one more note from Sara’s talk and didn’t quite know where to put it, but I think the conclusion makes sense because it’s a solid reminder that HTML, CSS, and, yes JavaScript, all have seats at the table and can each contribute positively to accessible experience:
The “Other” C in CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
I created a little library at work to make those “skeleton screens” that I’m not sure anyone likes. […] We named it
skellyCSSbecause… skeletons and CSS, I guess. We still aren’t even really using it very much, but it was fun to do and it was the first node package I made myself (for the most part).
Regardless of whether or not anyone “likes” skeleton screens, they do come up and have their use cases. And they’re probably not something you want to rebuild time and again. Great use for a web component, I’d say! Maybe Ryan can get Uncle Dave to add it to his Awesome Standalones list. 😉
The other reason I’m sharing this link is that Ryan draws attention to the Web Components De-Mystified course that Scott Jehl recently published, something worth checking out of course, but that I needed a reminder for myself.
Introducing
AVIF (AV1 Image File Format) is a modern image file format specification for storing images that offer a much more significant file reduction when compared to other formats like JPG, JPEG, PNG, and WebP. Version 1.0.0 of the AVIF specification was finalized in February 2019 and released by Alliance for Open Media to the public.
You save 50% of your file size when compared to JPG and 20% compared to WebP while still maintaining the image quality.
In this article, you will learn about some browser-based tools and command-line tools for creating AVIF images.
Why use AVIF over JPGs, PNGS, WebP, and GIF?* Lossless compression and lossy compression * JPEG suffers from awful banding * WebP is much better, but there’s still noticeable blockiness compared to the AVIF * Multiple color space * 8, 10, 12-bit color depth
CaveatsJake Archibald, wrote an article a few years back on this new image format and also helped us to identify some disadvantages to compressing images, normally you should look out for these two when compressing to AVIF:
See also: Addy Osmani at Smashing Magazine goes in-depth on using AVIF and WebP.
Browser SolutionsSquooshScreenshot of Squoosh.Squoosh is a popular image compression web app that allows you to convert images in numerous formats to other widely used compressed formats, including AVIF.
Features* File-size limit: 4MB * Image optimization settings (located on the right side) * Download controls – this includes seeing the size of the resulting file and the percentage reduction from the original image * Free to use
CloudinaryCloudinary’s free image-to-AVIF converter is another image tool that doesn’t require any form of code. All you need to do is upload your selected images (PNG, JPG, GIF, etc.) and it returns compressed versions of them. Its API even has more features besides creating AVIF images like its image enhancement and artificially generating filling for images.
I’m pretty sure you’re here because you’re looking for a free and fast converter. So, the browser solution should do.
Features
You can find answers to common questions in the Cloudinary AVIF converter FAQ.
Command Line Solutionsavif-cli``avif-cli by lovell lets you take your images (PNG, JPEG, etc.) stored in a folder and converts them to AVIF images of your specified reduction size.
Here are the requirements and what you need to do:
Install the package:
npm install avif
Run the command in your terminal:
npx avif --input="./imgs/*" --output="./output/" --verbose
* ./imgs/* – represents the location of all your image files
* ./output/ – represents the location of your output folder
CodePen Embed FallbackFeatures* Free to use * Speed of conversion can be set
You can find out about more commands via the avif-cli GitHub page.
sharpsharp is another useful tool for converting large images in common formats to smaller, web-friendly AVIF images.
Here are the requirements and what you need to do:
Install the package:
npm install sharp
Create a JavaScript file named sharp-example.js and copy this code:
const sharp = require('sharp')const convertToAVIF = () => { sharp('path_to_image') .toFormat('avif', {palette: true}) .toFile(__dirname + 'path_to_output_image')}convertToAVIF()
Where path_to_image represents the path to your image with its name and extension, i.e.:
./imgs/example.jpg
And path_to_output_image represents the path you want your image to be stored with its name and new extension, i.e.:
/sharp-compressed/compressed-example.avif
Run the command in your terminal:
node sharp-example.js
And there! You should have a compressed AVIF file in your output location!
Features* Free to use
* Images can be rotated, blurred, resized, cropped, scaled, and more using sharp
See also: Stanley Ulili’s article on How To Process Images in Node.js With Sharp.
ConclusionAVIF is a technology that front-end developers should consider for their projects. These tools allow you to convert your existing JPEG and PNG images to AVIF format. But as with adopting any new tool in your workflow, the benefits and downsides will need to be properly evaluated in accordance with your particular use case.
I hope you enjoyed reading this article as much as I enjoyed writing it. Thank you so much for your time and I hope you have a great day ahead!
Useful Tools for Creating AVIF Images originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Developers suffer in the great multitudes whom their sacred block-based websites cannot reach.
Johannes Gutenberg (probably)
Long time WordPresser, first time Gutenberger here. I’m a fan even though I’m still anchored to a classic/block hybrid setup. I believe Johanes himself would be, too, trading feather pens for blocks. He was a forward-thinking 15th-century inventor, after all.
My enthusiasm for Gutenberg-ness is curbed at the theming level. I’ll sling blocks all day long in the Block Editor, but please, oh please, let me keep my classic PHP templates and the Template Hierarchy that comes with it. The separation between theming and editing is one I cherish. It’s not that the Site Editor and its full-site editing capabilities scare me. It’s more that I fail to see the architectural connection between the Site and Block Editors. There’s a connection for sure, so the failure of not understanding it is more on me than WordPress.
The WP Minute published a guide that clearly — and succinctly — describes the relationships between WordPress blocks, patterns, and templates. There are plenty of other places that do the same, but this guide is organized nicely in that it starts with the blocks as the lowest-level common denominator, then builds on top of it to show how patterns are comprised of blocks used for content layout, synced patterns are the same but are one of many that are edited together, and templates are full page layouts cobbled from different patterns and a sprinkle of other “theme blocks” that are the equivalent of global components in a design system, say a main nav or a post loop.
The guide outlines it much better, of course:
That “overrides” enhancement to the synced patterns feature is new to me. I’m familiar with synced patterns (with a giant nod to Ganesh Dahal) but must’ve missed that in the WordPress 6.6 release earlier this summer.
I’m not sure when or if I’ll ever go with a truly modern WordPress full-site editing setup wholesale, out-of-the-box. I don’t feel pressured to, and I believe WordPress doesn’t care one way or another. WordPress’s ultimate selling point has always been its flexibility (driven, of course, by the massive and supportive open-source community behind it). It’s still the “right” tool for many types of projects and likely will remain so as long as it maintains its support for classic, block, and hybrid architectures.
Understanding Gutenberg Blocks, Patterns, and Templates originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Happy birthday, Chris Coyier — and thank you for CSS-Tricks as well as everything you do at CodePen, ShopTalk, Boost, and even your personal blog!
Quick Hit #13 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Giant kudos to Scott Jehl on releasing his new Web Components De-Mystified online course! Eight full hours of training from one of the best in the business.
Quick Hit #12 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Eric gifting us with his research on all the various things that anchors (not links) do when they are in :focus.
Turns out, there’s a lot!
That’s an understatement! This is an incredible amount of work, even if Eric calls it “dry as a toast sandwich.” Boring ain’t always a bad thing. Let me simply drop in a pen that Dave put together pulling all of Eric’s findings into a table organized to compare the different behaviors between operating systems — and additional tables for each specific platform — because I think it helps frame Eric’s points.
CodePen Embed FallbackThat really is a lot! But why on Earth go through the trouble of documenting all of this?
All of the previously documented behavior needs to be built in JavaScript, since we need to go the synthetic link route. It also means that it is code we need to set aside time and resources to maintain.
That also assumes that is even possible to recreate every expected feature in JavaScript, which is not true. It also leaves out the mental gymnastics required to make a business case for prioritizing engineering efforts to re-make each feature.
There’s the rub! These are the behaviors you’re gonna need to mimic and maintain if veering away from semantic, native web elements. So what Eric is generously providing is perhaps an ultimate argument against adopting frameworks — or rolling some custom system — that purposely abstract the accessible parts of the web, often in favor of DX.
As with anything, there’s more than meets the eye to all this. Eric’s got an exhaustive list at the end there that calls out all the various limitations of his research. Most of those notes sound to me like there are many, many other platforms, edge cases, user agent variations, assistive technologies, and considerations that could also be taken into account, meaning we could be responsible for a much longer list of behaviors than what’s already there.
And yes, this sweatshirt is incredible. Indeed.
Basic keyboard shortcut support for focused links originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
A gem from Chris Ferdinandi that details how to use custom events to hook into Web Components. More importantly, Chris dutifully explains why custom events are a better fit than, say, callback functions.
With a typical JavaScript library, you pass callbacks in as part of the instantiate process. […] Because Web Components self-instantiate, though, there’s no easy way to do that.
There’s a way to use callback functions, just not an “easy” way to go about it.
JavaScript provides developers with a way to emit custom events that developers can listen for with the
Element.addEventListener()method.We can use custom events to let developers hook into the code that we write and run more code in response to when things happen. They provide a really flexible way to extend the functionality of a library or code base.
Don’t miss the nugget about canceling custom events!
Callbacks on Web Components? originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Hey look at that, the State of CSS Survey for 2024 is open and taking submissions.
Quick Hit #11 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
You ever find yourself in bumper-to-bumper traffic? I did this morning on the way to work (read: whatever cafe I fancy). There’s a pattern to it, right? Stop, go, stop, go, stop… it’s almost rhythmic and harmonious in the most annoying of ways. Everyone in line follows the dance, led by some car upfront, each subsequent vehicle pressed right up to the rear of the next for the luxury of moving a few feet further before the next step.
Photo by Jakob JinHave you tried breaking the pattern? Instead of playing shadow to the car in front of me this morning, I allowed space between us. I’d gradually raise my right foot off the brake pedal and depress the gas pedal only once my neighboring car gained a little momentum. At that point, my car begins to crawl. And continue crawling. I rarely had to tap the brakes at all once I got going. In effect, I had sacrificed proximity for a smoother ride. I may not be traveling the “fastest” in line, but I was certainly gliding along with a lot less friction.
I find that many things in life are like that. Getting closest to anything comes with a cost, be it financial or consequence. Want the VIP ticket to a concert you’re stoked as heck about? Pony up some extra cash. Want the full story rather than a headline? Just enter your email address. Want up-to-the-second information in your stock ticker? Hand over some account information. Want access to all of today’s televised baseball games? Pick up an ESPN+ subscription.
Proximity and speed are the commodities, the products so to speak. Closer and faster are what’s being sold.
You may have run into the “law of diminishing returns” in some intro-level economics class you took in high school or college. It’s the basis for a large swath of economic theory but in essence, is the “too much of a good thing” principle. It’s what AMPM commercials have been preaching this whole time.
I’m embedding the clip instead of linking it up because it clearly illustrates the “problem” of having too many of what you want (or need). Dude resorted to asking two teens to reach into his front pocket for his wallet because his hands were full, creeper. But buy on, the commercial says, because the implication is that there’s never too much of a good thing, even if it ends in a not-so-great situation chockfull of friction.
The only and only thing I took away from physics in college — besides gravity force being 9.8 m/s2 — is that there’s no way to have bigger, cheaper, and faster at the same time. You can take two, but all three cannot play together. For example, you can have a spaceship that’s faster and cheaper, but chances are that it ain’t gonna be bigger than a typical spaceship. If you were to aim for bigger, it’d be a lot less cheap, not only for the extra size but also to make the dang heavy thing go as fast as possible. It’s a good rule in life. I don’t have proof of it, but I’d wager Mick Jagger lives by it, or at least did at one time.
Speed. Proximity. Faster and slower. Closer and further. I’m not going to draw any parallels to web development, UX design, or any other front-end thing. They’re already there.
The Intersection of Speed and Proximity originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
A client asked if we could mimic the “rubber band” scrolling behavior on many mobile devices. I’m sure you know what I’m talking about. It’s a behavior that already exists and happens automatically in most browsers. In iOS Safari, for example, you’re allowed to scroll beyond the top or bottom edge of the viewport by a few hundred pixels, and letting go snaps the page back in place.
I had heard of some instances where someone might want to prevent the bounce from happening but no one had asked me to implement it, especially in a way that supports devices without a touch interface. I was actually a bit surprised there isn’t an existing CSS property for this. There’s the non-standard -webkit-overflow-scrolling property but that’s for a different type of “momentum” scrolling. Nor would I want to rely on a non-standard property that’s not on track to become part of the specifications.
OK, so what if we want to force this sort of rubber banding in our work? For starters, we’d need some sort of element acting as a container for content that requires scrolling. From there, we could reach for JavaScript, of course, but that involves adding scroll listeners or a combination of pointerDown, pointerUp, and pointerMove events, not to mention keeping track of positions, inertial movement, etc.
A CSS-only solution would be much more ideal.
Here is a container with a few child elements:
```
``` Let’s get some baseline styles in place, specifically to create a situation where we’re guaranteed to overflow a parent container.
/* Parent container with fixed dimensions for overflow */.carousel { width: 200px; height: 400px; overflow-x: hidden; overflow-y: auto;}/* Wrapper for slides, stacked in a column */.slides { display: flex; flex-direction: column; flex-wrap: wrap; width: 100%; height: fit-content;}/* Each slide is the full width of the carousel */.slide { width: 100%; aspect-ratio: 1;}
Let’s start by adding some vertical margins. If your container has only one long item, add it to the top and bottom of the child element. If the container has multiple children, you’ll want to add margin to the top of the first child element and the bottom of the last child element.
.carousel > .slides > .slide:first-child { margin-top: 100px;}.carousel > .slides > .slide:last-child { margin-bottom: 100px;}
Great! We can now scroll past the edges, but we need something to snap it back after the user lifts their finger or pointer. For this, we’ll need the scroll-snap-type and scroll-snap-align properties
.carousel { scroll-snap-type: y mandatory;}.carousel > .slides > .slide { scroll-snap-align: start;}.carousel > .slides > .slide:first-child { margin-top: 100px;}.carousel > .slides > .slide:last-child { scroll-snap-align: end; margin-bottom: 100px;}
Note that the same applies to a horizontally scrollingelement. For that, you’d change things up so that margin is applied to the element’s left and right edges instead of its top and bottom edges. You’ll also want to change the scroll-snap-type property’s value from y mandatory to x mandatory while you’re at it.
That’s really it! Here’s the final demo:
CodePen Embed FallbackI know, I know. This isn’t some Earth-shattering or mind-blowing effect, but it does solve a very specific situation. And if you find yourself in that situation, now you have something in your back pocket to use.
Additional resources
Elastic Overflow Scrolling originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
A couple of weeks ago I was super excited about publishing my first CSS-Tricks post: “Letter Spacing is Broken. Forget about that though, what’s important is the post’s topic: letter spacing is broken and doesn’t work as the CSS Specification says it should. In a nutshell, instead of spacing the characters evenly, it leaves an unpleasant space at the end of the element.
While this inconsistency between the web and the spec is just a quirk for a Spanish/English speaker like me, for speakers of right-to-left (RTL) languages like Arabic or Hebrew, an annoying space is left at the start or end of a word. Firefox (Gecko) kinda fixes it and rearranges the unnecessary space at the end (in the reading order), but Google and Safari (Blink and Webkit) leave it at the start.
Of course, I wanted to demo this major pain point, but styling RTL content was beyond my CSS power. That’s when I found this life-saver guide by Ahmad Shadeed that covers every major aspect of styling RTL content on the web and best practices to easily internationalize an LTR webpage. A resource that, I think, is a must-read if you are interested in i18n and accessibility in the web.
I may have discovered warm water since this guide goes back to 2018, but I hope those like me who didn’t know about it have fun learning something new!
RTL Styling 101 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
I can’t say I would have ever expected to see Jeremy Keith performing the Yeah Yeah Yeahs song “Maps”, but then again, I don’t know what I expected to happen at Frostapalooza.
The EventBrad Frost, web designer, author of Atomic Design, and an absolute maniac on the bass, celebrated his birthday by putting together a one-night-only benefit concert featuring musical performances by himself and his talented family and friends.
Frostapalooza, held at Mr. Smalls Theatre in Pittsburgh, PA, was an all-ages event where 100% of the proceeds are headed towards two great causes:
PerformancesThe variation of musical performances sprawled across the night, covering tracks by Fleetwood Mac, Radiohead, David Bowie and so much more, check out this setlist of all 31 tracks on Spotify.
I loved the performance of Pink Floyd’s classic song, “Money.” As a Floyd fan who will never get to see them live, this was easily the best rendition I could ask for, which included the full lineup of instrumental sections.
Brad was joined on stage by none other than CSS-Tricks founder, Chris Coyier. Chris picked banjo on a few songs, such as Johnny Cash’s “Folsom Prison Blues” and The Band’s “The Weight,” both fantastic.
The stage background prominently displayed visuals out of CodePen demos made by CodePen community members during the set. Check out the Frostapalooza tag on CodePen to see everything that was projected.
Another favorite moment was Brad’s version of “Wake Up” by Arcade Fire, which felt like a perfectly matched song for the evening.
MusiciansIf you haven’t caught on yet, many of the folks lending their musical talents to Frostapalooza also happen to be web designers and developers Brad has met and worked with during his career. At times it felt like the Wu-Tang Clan of CSS on stage.
Brad’s family and musicians from his other bands pitched in, such as Elby Brass. Ridiculously impressive! I had never seen a tuba-playing lead vocalist until this night.
You can see the full lineup on the event’s website. But I’ll drop a screenshot in here just for posterity.
Photos! Videos!Mike Aparicio captured a great video of a group jam on Queen’s “Bohemian Rhapsody” that you’ve got to watch on YouTube. Brian Kardell nabbed this gem of Chris pickin’ on “The Weight”:
The endPlain and simple, this was a super fun night celebrating music and friends. Happy birthday, Brad, and thanks for putting on an awesome show!
On the Ground at Frostapalooza originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Every programming language has loops. Loops perform an operation (i.e., a chunk of work) a number of times, usually once for every item in an array or list, or to simply repeat an operation until a certain condition is met.
JavaScript in particular has quite a few different types of loops. I haven’t even used all of them, so for my own curiosity, I thought I’d do a high-level overview of them. And as it turns out, there are pretty good reasons I haven’t used at least a couple of the different types.
So, for now let’s spend a while exploring the different types of loops, what we can do with each of one, and why you might use one over another. (You’ll think that little play on words is absolutely hilarious by the end.)
The while and do...while loopsFirst up is the while loop. It’s the most basic type of loop and has the potential to be the easiest to read and the fastest in many cases. It’s usually used for doing something until a certain condition is met. It’s also the easiest way to make an infinite loop or a loop that never stops. There is also the do...while statement. Really, the only difference is that the condition is checked at the end versus the beginning of each iteration.
// remove the first item from an array and log it until the array is emptylet queue1 = ["a", "b", "c"];while (queue1.length) { let item = queue1.shift(); console.log(item);}// same as above but also log when the array is emptylet queue2 = [];do { let item = queue2.shift() ?? "empty"; console.log(item);} while (queue2.length);
The for loopNext is the for loop. It should be the go to way to do something a certain number of times. If you need to repeat an operation, say, 10 times, then use a for loop instead. This particular loop may be intimidating to those new to programming, but rewriting the same loop in the while-style loop can help illustrate the syntax make it easier to stick in your mind.
// log the numbers 1 to 5for (let i = 1; i <= 5; i++) { console.log(i);}// same thing but as a while looplet i = 1; // the first part of a for loop// the secondwhile (i <= 5) { console.log(i); i++; // the third}("end");
The for...of and for await...of loopsA for...of loop is the easiest way to loop through an array.
let myList = ["a", "b", "c"];for (let item of myList) { console.log(item);}
They aren’t limited to arrays though. Technically they can iterate through anything that implements what is called an iterable protocol. There are a few built-in types that implement the protocol: arrays, maps, set, and string, to mention the most common ones, but you can implement the protocol in your own code. What you’d do is add a [Symbol.iterator] method to any object and that method should return an iterator. It’s a bit confusing, but the gist is that iterables are things with a special method that returns iterators; a factory method for iterators if you will. A special type of function called a generator is a function that returns both a iterable and iterator.
let myList = { *[Symbol.iterator]() { yield "a"; yield "b"; yield "c"; },};for (let item of myList) { console.log(item);}
There is the async version of all the things I just mentioned: async iterables, async iterators, and async generators. You’d use an async iterable with for await...of.
async function delay(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); });}// this time we're not making an iterable, but a generatorasync function* aNumberAMinute() { let i = 0; while (true) { // an infinite loop yield i++; // pause a minute await delay(60_000); }}// it's a generator, so we need to call it ourselvesfor await (let i of aNumberAMinute()) { console.log(i); // stop after one hour if (i >= 59) { break; }}
One unobvious thing about for await...of statement is that you can use it with non-async iterables and it will work just fine. The reverse, however, is not true; you can’t use async iterables with the for...of statement.
The forEach and map loopsWhile these are not technically loops per se, you can use them to iterate over a list.
Here is the thing about the forEach method. Historically it was much slower than using a for loop. I think in some cases that may not be true anymore, but if performance is a concern, then I would avoid using it. And now that we have for...of I’m not sure there is much reason to use it. I guess the only reason that it still may come up is if you have a function ready to use as the callback, but you could easily just call that same function from inside the body of for...of.
forEach also receives the index for each item though, so that may be a thing you need too. Ultimately, the decision to use it will probably come down to whether any other code you’re working with uses it, but I personally would avoid using it if I’m writing something new.
let myList = ["a", "b", "c"];for (let item of myList) {console.log(item);}// but maybe if I need the index use forEach["a", "b", "c"].forEach((item, index) => { console.log(`${index}: ${item}`);});
Meanwhile, map essentially converts one array into another. It still has the same performance impact that forEach has, but it is a bit nicer to read than the alternative. It’s certainly subjective though, and just like with forEach you’ll want to do what the rest of your other code is doing. You see it a ton in React and React-inspired libraries as the primary way to loop through an array and output a list of items within JSX.
function MyList({items}) { return ( <ul> {items.map((item) => { return <li>{item}</li>; })} </ul> );}
The for...in loopThis list of loops in JavaScript wouldn’t be complete without mentioning the for...in statement because it can loop through the fields of an object. It visits fields that are inherited through the object’s prototype chain too, though, and I’ve honestly always avoided it for that reason.
That said, if you have an object literal, then for...in might be a viable way to iterate through the keys of that object. Also it’s worth noting that if you’ve been programming JavaScript for a long time, you may remember that the order of keys use to be inconsistent between browsers, but now the order is consistent. Any key that could be an array index (i.e., positive integers) will be first in ascending order, and then everything else in the order as authored.
let myObject = { a: 1, b: 2, c: 3,};for (let k in myObject) { console.log(myObject[k]);}
Wrapping upLoops are something that many programmers use every day, though we may take them for granted and not think about them too much.
But when you step back and look at all of the ways we have to loop through things in JavaScript, it turns out there are several ways to do it. Not only that, but there are significant — if not nuanced — differences between them that can and will influence your approach to scripts.
All About JavaScript Loops originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
I was just going over the latest CSSWG minutes (you can subscribe to them at W3C.org) and came across a few interesting nuggets I wanted to jot down for another time. The group discussed the CSS Values, CSS Easing, and Selectors modules, but what really caught my eye was adding triggered delays to CSS for things like hover, long taps, and focus states.
The idea stems from an OpenUI proposal, the same group we can thank for raising things like the Popover API and customizable select element. The concept, if I understand it right, is that anytime someone hovers, taps, or focuses on, say, a <button> for a certain amount of time, we can invoke some sort of thing. A tooltip is the perfect illustration. Hovering over the trigger element, the reasoning goes, is an expression of interest and as web authors, we can do something with that interest, like displaying a tooltip.
Whoa, right?! There’s long been chatter about CSS encroaching on JavaScript territory (isn’t it ironic, don’t you think?). Firing events in response to interaction is quite literally the only thing I use JavaScript for. There’s no mistake about that in the CSSWG, as documented in the minutes:
So. Does this belong in CSS? Or should it be elsewhere? Does the approach make sense? Are there better ideas? Most interested in the last.
[…]
Other question; does this belong in CSS or HTML… maybe this is just a javascript feature? In JS you can determine MQ state and change things so it wouldn’t necessarily be in CSS.
And shortly later:
As you were talking; one thing that I kept thinking of; should developers be customizing the delay at all? Original use case for delay is that hover shouldn’t be instant. But if we don’t allow for customizing we can align to platform delay lengths.
But there’s an excellent point to be made about the way many of us are already doing this with CSS animations (animation-delay) and transitions (transition-delay). Sometimes even applying those globally with the Universal Selector or a prefers-* query.
Things get even hairier when considering how values are defined for this. Are they explicit delays (800ms), generic keywords (none/short/medium/long), a custom property, a pseudo-class… something else? I’m glad there’re incredibly smart folks noodling on this stuff.
I think here it would be good to go with time values. CSS is a good place to put it. We have all the ergonomics. The right declarative place to put it.
Whatever the eventual case may be:
I think this sounds reasonable and I’d like to explore it. Unsure if this is the exact shape, but this space seems useful to me.
CSSWG Minutes Telecon (2024-08-14) originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Yes, yes. Functionally, they are different. But heck if I didn’t know about the wacky thresholds until Jens Oliver Meiert tooted a pair of quick polls.
According to the HTML Standard:
- If the current cell has a
colspanattribute, then parse that attribute’s value, and let colspan be the result.
If parsing that value failed, or returned zero, or if the attribute is absent, then let colspan be 1, instead.
If colspan is greater than 1000, let it be 1000 instead.- If the current cell has a
rowspanattribute, then parse that attribute’s value, and let rowspan be the result.
If parsing that value failed or if the attribute is absent, then let rowspan be 1, instead.
If rowspan is greater than 65534, let it be 65534 instead.
I saw the answers in advance and know I’d have flubbed rowspan. Apparently, 1000 table columns are plenty of columns to span at once, while 65534 is the magic number for clamping how many rows we can span at a time. Why is the sweet spot for rowspan 6,4543 spans greater than colspan? There are usually good reasons for these things.
What that reason is, darned if I know, but now I have a little nugget for cocktail chatter in my back pocket.
How are the colspan and rowspan attributes different? originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Free e-book from Jens Oliver Meiert that’ll bore you to death in the best way: Rote Learning HTML & CSS
Quick Hit #11 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Killed by Google is called a “graveyard” but I also see it as a resume in experimentation.
Quick Hit #10 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Modern CSS keeps giving us a lot of new, easier ways to solve old problems, but often the new features we’re getting don’t only solve old problems, they open up new possibilities as well.
Container queries are one of those things that open up new possibilities, but because they look a lot like the old way of doing things with media queries, our first instinct is to use them in the same way, or at least a very similar way.
When we do that, though, we aren’t taking advantage of how “smart” container queries are when compared to media queries!
Because of how important media queries were for ushering in the era of responsive web design I don’t want to say anything mean about them… but media queries are dumb. Not dumb in terms of the concept, but dumb in that they don’t know very much. In fact, most people assume that they know more than they do.
Let’s use this simple example to illustrate what I mean:
html { font-size: 32px;}body { background: lightsalmon;}@media (min-width: 35rem) { body { background: lightseagreen; }}
What would the viewport size be for the background color to change? If you said 1120px wide — which is the product of multiplying 35 by 32 for those who didn’t bother doing the math — you aren’t alone in that guess, but you’d also be wrong.
Remember when I said that media queries don’t know very much? There are only two things they do know:
And when I say the browser’s font size, I don’t mean the root font size in your document, which is why 1120px in the above example was wrong.
The font size they look at is the initial font size coming from the browser before any values, including the user agent styles, are applied. By default, that’s 16px, though users can change that in their browser settings.
And yes, this is on purpose. The media query specification says:
Relative length units in media queries are based on the initial value, which means that units are never based on results of declarations.
This might seem like a strange decision, but if it didn’t work that way, what would happen if we did this:
html { font-size: 16px;}@media (min-width: 30rem) { html { font-size: 32px; }}
If the media query looked at the root font-size (like most assume it does), you’d run into a loop when the viewport would get to 480px wide, where the font-size would go up in size, then back down over and over again.
Container queries are a lot smarterWhile media queries have this limitation, and for good reason, container queries don’t have to worry about this type of problem and that opens up a lot of interesting possibilities!
For example, let’s say we have a grid that should be stacked at smaller sizes, but three columns at larger sizes. With media queries, we sort of have to magic number our way to the exact point where this should happen. Using a container query, we can determine the minimum size we want a column to be, and it’ll always work because we’re looking at the container size.
That means we don’t need a magic number for the breakpoint. If I want three columns with a minimum size of 300px, I know I can have three columns when the container is 900px wide. If I did that with a media query, it wouldn’t work, because when my viewport is 900px wide, my container is, more often than not, smaller than that.
But even better, we can use any unit we want as well, because container queries, unlike media queries, can look at the font size of the container itself.
To me, ch is perfect for this sort of thing. Using ch I can say “when I have enough room for each column to be a minimum of 30 characters wide, I want three columns.”
We can do the math ourselves here like this:
.grid-parent { container-type: inline-size; }.grid { display: grid; gap: 1rem; @container (width > 90ch) { grid-template-columns: repeat(3, 1fr); }}
And this does work pretty well, as you can see in this example.
CodePen Embed FallbackAs another bonus, thanks to Miriam Suzanne, I recently learned that you can include calc() inside media and container queries, so instead of doing the math yourself, you can include it like this: @container (width > calc(30ch * 3)) as you can see in this example:
CodePen Embed FallbackA more practical use caseOne of the annoying things about using container queries is having to have a defined container. A container cannot query itself, so we need an extra wrapper above the element we want to select with a container query. You can see in the examples above that I needed a container on the outside of my grid for this to work.
Even more annoying is when you want grid or flex children to change their layout depending on how much space they have, only to realize that this doesn’t really work if the parent is the container. Instead of having that grid or flex container be the defined container, we end up having to wrap each grid or flex item in a container like this:
```
You might recall that Alvaro suggests bumping up font-size to 1.25rem from the default user agent size of 16px. Sebastian Laube pokes at that:
I wouldn’t adopt Alvaro’s suggestion without further ado, as I would waste so much space on a smartphone, for example, and many users would probably be irritated by the large font.
I set a font size of
1.2remfrom a certain viewport size. But this also has to be done carefully, because then grey areas arise in which media queries suddenly fall back into another area…
I personally agree with Alvaro that the default 16px size is too small. That’s just how I feel as someone who is uncomfortably close to wearing the bottoms of actual Coke bottles to see anything clearly on a screen.
On the flip side, I professionally agree with Sebastian, not that many users would probably be irritated by the large font, but to openly question an approach rather than adopting someone else’s approach wholesale based on a single blog post. It may very well be that a font-size bump is the right approach. Everything is relative, after all, and we ought to be listening to the people who use the thing we’re making for decisions like this.
The much bigger question is the one Sebastian poses right at the end there:
Should browsers perhaps use a larger font size on large screens from the outset if the user does not specify otherwise? Or do we need an information campaign to make them aware that they should check their system settings or set a different default font size in the browser?
Fantastic, right?! I’m honestly unsure where I’d draw the viewport breakpoint for 16px being either too large or small and where to start making adjustments. Is 16px the right default at any viewport size? Or perhaps user agents ought to consider a fluid type implementation that defines a default font scale and range of sizes instead? It’s great food for thought.
font-size Limbo originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Mental health is always tough to talk about, especially in an industry that, to me, often rewards ego over vulnerability. I still find it tough even after having written about my own chronic depression and exploring UX case studies about it.
But that’s exactly the sort of discussions that Schalk Venter and Schalk Neethling host on their Mental Health in Tech podcast. They invited me on the show and we got deep into what it’s like to do your best work when you’re not feeling your best. We got so deep into it that we didn’t realize two hours blew right by, and the interview was split into two parts, the second of which published today.
Vulnerability and Breaking the Facade as a Balancing Act – Geoff – Part 1 by Schalk Neethling
Read on SubstackThe Emotional Rollercoaster of Tech Layoffs, Reviving CSS-Tricks, and Recovery – Geoff – Part 2 by Schalk Neethling
Read on Substack
Mental Health in Tech Podcast Interview originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Heydon with a reminder that <address> isn’t for, you know, mailing addresses.
Quick Hit #9 originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
A new explainer for a new HTML attribute to handle handwritten inputs. Like this:
<input type="text" handwriting="true" ... ><input type="text" handwriting="false" ... ><textarea handwriting="" ... > <!-- evaluates to "true" --><div contenteditable handwriting="true">...</div> <!-- maybe? -->
The primary use case is for those using one of those writing devices for touch screens, like Apple Pencil. The explainer comes from the Microsoft Edge team, so maybe the Surface Slim Pen is the impetus behind it. I suppose there are other use cases since the same devices that support pen-like input devices also tend to support using your finger. There are also those text box signatures in apps like DocuSign that are impossibly tough to sign with a mouse cursor, though it’s possible.
We’ll let the author, Adam Ettenberger, articulate it better than I can:
The developer authoring the drawing widget may not be aware that it may be on top of or near an input element, and it seems bad if they need to find such elements and disable handwriting on them.
⚠️Auto-playing mediaWhy an HTML attribute? The explainer outlines a few alternatives that were considered, including:
pointer-events property (e.g. pointer-events: handwriting)touch-action propertyinputmode attributeAnd why not a JavaScript API instead? Haha, we won’t go there.
Anyway, handwriting on the web… let’s see if we get a prototype!
A couple more pieces of context:
Direct Link →
HTML Attribute to Allow/Disallow Handwriting Input originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Remember these? Chris would write a post now and then to chronicle things happening around the ol’ CSS-Tricks site. It’s only been 969 days since the last one, give or take. Just think: back then we were poking at writing CSS in JavaScript and juuuuuuust starting to get excited about a set of proposed new color features that are mostly implemented today. We’re nesting CSS rules now. Container queries became an actual thing.
CSS was going gosh-darned hog wild. Probably not the “best” time for a site about CSS to take a break, eh?
That’s why I thought I’d dust off the chronicles. It’s been a hot minute and a lot is happening around CSS-Tricks today.
I’m (sorta) backWe may as well begin here! Yeah, I was “let go” last year. There was no #HotDrama. A bunch of really good folks — all in the DigitalOcean community team — were let go at the same time. It was a business decision, love it or not.
Things changed at DigitalOcean after that. A new leadership team is on board and, with it, a re-dedicated focus on re-establishing the community side of things. That, and Chris published a meaty post about the CSS-Tricks situation from his perspective. Coincidentally or not, a new job opened that looked a lot like my old gig. I had feelings about that, of course.
This little flurry of activity led to a phone call. And a few more. And now I’m back to help get the ol’ CSS-Tricks engine purring, hopefully making it the rich resource we’ve loved for so long. I’m on contract at the moment and feeling things out.
So far? Man, it feels great to be back.
What I did during the “lull”I jumped over to Smashing Magazine. Gosh, that team is incredible. It tickles me that we still have Smashing Magazine. And here’s a piece of trivia for your next front-end cocktail party: Smashing Magazine was launched in September 2006, a mere 11 months before Chris published the very first article here on CSS-Tricks.
I also spent my time teaching front-end development at a couple of colleges that are local to me where I live in Colorado. I had already been teaching but bumped up the load. But not too much because I decided this was as good a time as any to work on a master’s degree. So, I enrolled and split my days as a part-time editor, part-time educator, and part-time student.
The degree went quicker than expected, so I used the rest of my time finishing up an online course I had started a couple years earlier and finally got around to publishing it! It’s probably not the sort of course for someone reading this post, but for complete beginners who are likely writing their very first line of HTML or CSS. You ever get asked how to build a website but don’t have the energy (or time) to explain everything? Yeah, me too. That’s who this course is for. And my mom.
I call it The Basics — and I’d love it if you shared it with anyone you think might use it as a starting point into web development.
What I want for CSS-Tricks, going forwardThis site’s always been great, even long before I was brought on board. Historically, it’s been more of a personal blog turned multi-author blog with a steady stream of content. Nothing wrong with that at all.
What’s lacking, though, is structure. Most everything we publish is treated like a blog post: write it, smash the Publish button, and let it sit on top of the stream until the next blog post comes out. We’re talking about a time-based approach in which posts become a timeline of activity in reverse chronological order. Where do you find that one post you came across last month? It’s probably buried by this point and you’ve gotta either hit the post archives or try your hand searching for it by keyword. That might work for a blog with a few hundred posts, but there are more than 7,000 here and searching has become more like finding the metaphorical needle in the equally metaphorical haystack.
So, you may have noticed that I’m shuffling things around. Everything is still a “post” but we’re now using a Category taxonomy more effectively than we had been in the past. Each category is a “type” of post. And the type of post is determined by what exactly we’re trying to get out of it. Let’s actually break this out into its own section because it’s a sizeable change with some explanation around it.
The “types” of things we’re publishingOK, so everything used to be an article or an Almanac entry. We still have “articles” and “entries” but there are better ways to classify and distinguish them, most notably with articles.
This is how it shakes out:
This is what we’re looking at right now, but there are obviously other ways we can slice-n-dice content. For example, we have an archive of “snippets” that we’ve buried for many years but could be useful. Same with videos. And more, if you can believe it. So, there’s plenty of housekeeping to do to keep us busy! This is still very much early days. You’ll likely experience some turbulence during your flight. And I’m okay with that because this is a learning place, and the people working it are learning, too.
Yes, I did just say, “people” as in more than one person because I’d to…
Welcome a couple of new faces!The thing that excites me most — even more than the ice cream truck excites my daughters — is bringing new people along for the ride. Running CSS-Tricks is a huge job (no matter how easy I make it look 😝). So, I’ve brought on a couple of folks to help share the load!
Juan Diego Rodriguez
Ryan Trimble
I got to know Juan Diego while editing for Smashing Magazine. He had written a couple of articles for Smashing before I joined and his latest work, the first part of a series of articles discussing the “headaches” of working with Gatsby, landed on my desk. It’s really, really good — you should check it out. What you should know about Juan Diego that I’ve come know is that the dude cares a lot about the web platform. Not only that, but pays close attention to it. I’m pretty sure he reads CSSWG specifications for pleasure over tea. His love and curiosity for all-things-front-end is infectious and I’ve already learned a bunch from him. I know you will, too.
Ryan, on the other hand, is a total nerd for design systems that advocates for accessible interfaces. He actually reached out to me on Mastodon when he caught wind that I needed help. It was perfect timing and I couldn’t be more grateful that he poked me when he did. As I’ve gotten to know him, I’m realizing how versatile his skillset is. Working with “design systems” can mean lots of different things. For Ryan, it means consistent, predictable user interfaces based on modular and reusable web components — specifically web components that are native to the platform. In fact, he’s currently working on a design system called Platform UI. I’ve also become a fan of his personal blog, especially his weekly roundups of articles he finds interesting.
You’ll be seeing a lot of Juan Diego and Ryan around here! They’re both hard at work on bringing the trusty Almanac up-to-date but will be posting articles as well. No one’s full time here, me included, so it’s truly a team effort.
Please give ’em both a hearty welcome!
This is all an ongoing work in progress…and probably always will be! I love that CSS-Tricks is a place where everyone learns together. It might be directly about CSS. Maybe it’s not. Perhaps it’s only tangentially related to web development. It may even be a rough idea that isn’t fully baked, but we put it out there and learn new things together with an open mind to the fact that the web is a massive place where everyone has something to contribute and a unique perspective we all benefit from — whether it’s from a specialization in CSS, semantics, performance, accessibility, design, typography, marketing, or what have you.
Do you wanna write for CSS-Tricks?You can and you should! You get paid, readers learn something, and that gets people coming to the site. Everybody wins!
I know writing isn’t everyone’s top skill. But that’s exactly what the team is here for. You don’t have to be a superior writer, but only be willing to write something. We’ll help polish it off and make it something you’re super proud of.
More than 200 web developers, designers, and specialists just like you have written for this site. You should apply to write an article and join the club!
So, yes: CSS-Tricks is back!In its own weird way! In my perfect world, there would be no doubt whether CSS-Tricks is publishing content on any given day. But that’s not entirely up to me. It not only has to be of at least some value to people like you who depend on sites like CSS-Tricks but also to DigitalOcean. It’s a delicate dance but I think everyone’s on the same page with a shared interest of keeping this site around and healthy.
I’m stoked I get to be a part of it. And that Juan Diego and Ryan do, too. And you, as well.
We’re all in it together. 🧡
CSS Chronicles XLII originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Ever search for CSS info and run into some article — perhaps even one or a dozen on this site — that looks promising until you realize it was published when dinosaurs roamed the planet? The information is good, but maybe isn’t the best reflection of modern best practices?
I’ve wondered that a bunch. And so has Chris. But so has Brecht De Ruyte and he’s actually doing something about it, along with other folks who make up the W3C CSS-Next community group. I worked with him on this article for Smashing Magazine and was stoked to see how much discussion, thought, and intention have gone into “versioning” CSS.
The idea? We’d “skip” CSS4, so to speak, slapping a CSS4 label on a lot of what’s released since CSS3:
CSS3 (~2009-2012):
Level 3 CSS specs as defined by the CSSWG. (immutable)CSS4 (~2013-2018):
Essential features that were not part of CSS3 but are already a fundamental part of CSS..
From there?
CSS5 (~2019-2024):
Newer features whose adoption is steadily growing.CSS6 (~2025+):
Early-stage features that are planned for future CSS
The most important part of the article, though, is that you (yes, you) can help the CSS-Next community group.
We also want you to participate. Anyone is welcome to join the CSS-Next group and we could certainly use help brainstorming ideas. There’s even an incubation group that conducts a biweekly hour-long session that takes place on Mondays at 8:00 a.m. Pacific Time (2:00 p.m. GMT).
It’s Time To Talk About “CSS5” originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Christian Heilmann gave this talk at Typo3 Developer Days. I’m linking it up because it strikes an already stricken nerve in me. The increasing complexity of web development has an inverse relationship with the decreasing number of entry points for those getting into web development.
I love how Christian compares two hypothetical development stacks.
Thenindex.html
Now* Get the right editor with all the right extensions
* Set up your terminal with the right font and all the cool dotfiles
* Install framework flügelhorn.js with bundler wolperdinger.io
* Go to the terminal and run packagestuff –g install
* Look at all the fun warning messages and update dependencies
* Doesn’t work? Go SUDO, all the cool kids are …
* Don’t bother with the size of the modules folder
* Learn the abstraction windfarm.css – it does make you so much more effective
* Use the templating language funsocks – it is much smaller than HTML
* Check out the amazing hello world example an hour later…
He’s definitely a bit glib, but the point is solid. Things are more complex today than they were, say, ten years ago. I remember struggling with Grunt back then and thinking I’d never get it right. I did eventually, and my IDE was never the same after that.
It’s easy to get swept up in the complexity, even for those with experience in the field:
This world is unfortunately becoming lost or, at least, degraded — not because it is no longer possible to view the source of a webpage, but because that source is often inscrutable, even on simple webpages.
— Pixel Envy “A View Source Web”
Christian’s post reminds me that the essence of the web is not only still alive but getting better every day:
- Browsers are constantly updated.
- The web standardisation process is much faster than it used to be.
- We don’t all need to build the next killer app. Many a framework promises scaling to infinity and only a few of us will ever need that.
He goes on to suggest many ways to remove complexity and abstractions from a project. My biggest takeaway is captured by a single headline:
The web is built on resilient technologies – we just don’t use them
Which recalls what Molly White said earlier this year that there’s always an opportunity to swing the pendulum back:
The thing is: none of this is gone. Nothing about the web has changed that prevents us from going back. If anything, it’s become a lot easier. We can return. Better, yet: we can restore the things we loved about the old web while incorporating the wonderful things that have emerged since, developing even better things as we go forward, and leaving behind some things from the early web days we all too often forget when we put on our rose-colored glasses.
We can return. We can restore all the things. So, tell me: do you take the red pill or the blue one?
Let’s make a simpler, more accessible web originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
It was a few years ago during the 2020 Olympics in Tokyo 2020 that I made a demo of animated 3D Olympic rings. I like it, it looks great, and I love the effect of the rings crossing each other.
CodePen Embed FallbackBut the code itself is kind of old. I wrote it in SCSS, and crookedly at that. I know it could be better, at least by modern standards.
So, I decided to build the demo again from scratch in honor of this year’s Olympics. I’m writing vanilla CSS this time, leveraging modern features like trigonometric functions for fewer magic numbers and the relative color syntax for better color management. The kicker, turns out, is that the new demo winds up being more efficient with fewer lines of code than the old SCSS version I wrote in 2020!
Look at the CSS tab in that first demo again because we’ll wind up with something vastly different — and better — with the approach we’re going to use together. So, let’s begin!
The markupWe’ll use layers to create the 3D effect. These layers are positioned one after the other (on the z-axis) to get the depth of the 3D object which, in our case, is a ring. The combination of the shape, size, and color of each layer — plus the way they vary from layer to layer — is what creates the full 3D object.
In this case, I’m using 16 layers where each layer is a different shade (with the darker layers stacked at the back) to get a simple lighting effect, and using the size and thickness of each layer to establish a round, circular shape.
As far as HTML goes, we need five <div> elements, one for each ring, where each <div> contains 16 elements that act as the layers, which I’m wrapping in <i> tags. Those five rings we’ll put in a parent container to hold things together. We’ll give the parent container a .rings class and each ring, creatively, a .ring class.
This is an abbreviated version of the HTML showing how that comes together:
```
``
Note the--icustom property I’ve dropped on thestyleattribute of each` element:
<i style="--i: 1;"></i><i style="--i: 2;"></i><i style="--i: 3;"></i><!-- etc. -->
We’re going to use --i to calculate each layer’s position, size, and color. That’s why I’ve set their values as integers in ascending order — those will be multipliers for arranging and styling each layer individually.
Pro tip: You can avoid writing the HTML for each and every layer by hand if you’re working on an IDE that supports Emmet. But if not, no worries, because CodePen does! Enter the following into your HTML editor then press the Tab key on your keyboard to expand it into 16 layers: i*16[style="--i: $;"]
The (vanilla) CSSLet’s start with the parent .rings container for now will just get a relative position. Without relative positioning, the rings would be removed from the document flow and wind up off the page somewhere when setting absolute positioning on them.
.rings { position: relative;}.ring { position: absolute;}
Let’s do the same with the <i> elements, but use CSS nesting to keep the code compact. We’ll throw in border-radius while we’re at it to clip the boxy edges to form perfect circles.
.rings { position: relative;}.ring { position: absolute; i { position: absolute; border-radius: 50%; }}
The last piece of basic styling we’ll apply before moving on is a custom property for the --ringColor. This’ll make coloring the rings fairly straightforward because we can write it once, and then override it on a layer-by-layer basis. We’re declaring --ringColor on the border property because we only want coloration on the outer edges of each layer rather than filling them in completely with background-color:
.rings { position: relative;}.ring { position: absolute; --ringColor: #0085c7; i { position: absolute; inset: -100px; border: 16px var(--ringColor) solid; border-radius: 50%; }}
Did you notice I snuck something else in there? That’s right, the inset property is also there and set to a negative value of 100px. That might look a little strange, so let’s talk about that first as we continue styling our work.
Negative insettingSetting a negative value on the inset property means that the layer’s position falls outside the .ring element. So, we might think of it more like an “outset” instead. In our case, the .ring has no size as there are no content or CSS properties to give it dimensions. That means the layer’s inset (or rather “outset”) is 100px in each direction, resulting in a .ring that is 200×200 pixels.
Let’s check in with what we have so far:
CodePen Embed FallbackPositioning for depthWe’re using the layers to create the impression of depth. We do that by positioning each of the 16 layers along the z-axis, which stacks elements from front to back. We’ll space each one a mere 2px apart — that’s all the space we need to create a slight visual separation between each layer, giving us the depth we’re after.
Remember the --i custom property we used in the HTML?
<i style="--i: 1;"></i><i style="--i: 2;"></i><i style="--i: 3;"></i><!-- etc. -->
Again, those are multipliers to help us translate each layer along the z-axis. Let’s create a new custom property that defines the equation so we can apply it to each layer:
i { --translateZ: calc(var(--i) * 2px);}
What do we apply it to? We can use the CSS transform property. This way, we can rotate the layers vertically (i.e., rotateY()) while translating them along the z-axis:
i { --translateZ: calc(var(--i) * 2px); transform: rotateY(-45deg) translateZ(var(--translateZ));}
Color for shadingFor color shading, we’ll darken the layers according to their position so that the layers get darker as we move from the front of the z-axis to the back. There are a few ways to do it. One is dropping in another black layer with decreasing opacity. Another is modifying the “lightness” channel in a hsl() color function where the value is “lighter” up front and incrementally darker towards the back. A third option is playing with the layer’s opacity, but that gets messy.
Even though we have those three approaches, I think the modern CSS relative color syntax is the best way to go. We’ve already defined a default --ringColor custom property. We can put it through the relative color syntax to manipulate it into other colors for each ring <i> layer.
First, we need a new custom property we can use to calculate a “light” value:
.ring { --ringColor: #0085c7; i { --light: calc(var(--i) / 16); border: 16px var(--ringColor) solid; }}
We’ll use the calc()-ulated result in another custom property that puts our default --ringColor through the relative color syntax where the --light custom property helps modify the resulting color’s lightness.
.ring { --ringColor: #0085c7; i { --light: calc(var(--i) / 16); --layerColor: rgb(from var(--ringColor) calc(r * var(--light)) calc(g * var(--light)) calc(b * var(--light))); border: 16px var(--ringColor) solid; }}
That’s quite an equation! But it only looks complex because the relative color syntax needs arguments for each channel in the color (RGB) and we’re calculating each one.
rgb(from origin-color channelR channelG channelB)
As far as the calculations go, we multiply each RGB channel by the --light custom property, which is a number between 0 and 1 divided by the number of layers, 16.
Time for another check to see where we’re at:
CodePen Embed FallbackCreating the shapeTo get the circular ring shape, we’ll set the layer’s size (i.e., thickness) with the border property. This is where we can start using trigonometry in our work!
We want the thickness of each ring to be a value between 0deg to 180deg — since we’re only actually making half of a circle — so we will divide 180deg by the number of layers, 16, which comes out to 11.25deg. Using the sin() trigonometric function (which is equivalent to the opposite and hypotenuse sides of a right angle), we get this expression for the layer’s --size:
--size: calc(sin(var(--i) * 11.25deg) * 16px);
So, whatever --i is in the HTML, it acts as a multiplier for calculating the layer’s border thickness. We have been declaring the layer’s border like this:
i { border: 16px var(--ringColor) solid;)
Now we can replace the hard-coded 16px value with --size calculation:
i { --size: calc(sin(var(--i) * 11.25deg) * 16px); border: var(--size) var(--layerColor) solid;)
But! As you may have noticed, we aren’t changing the layer’s size when we change its border width. As a result, the round profile only appears on the layer’s inner side. The key thing here is understanding that setting the --size with the inset property which means it does not affect the element’s box-sizing. The result is a 3D ring for sure, but most of the shading is buried.
⚠️ Auto-playing mediaWe can bring the shading out by calculating a new inset for each layer. That’s kind of what I did in the 2020 version, but I think I’ve found an easier way: add an outline with the same border values to complete the arc on the outer side of the ring.
i { --size: calc(sin(var(--i) * 11.25deg) * 16px); border: var(--size) var(--layerColor) solid; outline: var(--size) var(--layerColor) solid;}
We have a more natural-looking ring now that we’ve established an outline:
CodePen Embed FallbackAnimating the ringsI had to animate the ring in that last demo to compare the ring’s shading before and after. We’ll use that same animation in the final demo, so let’s break down how I did that before we add the other four rings to the HTML
I’m not trying to do anything fancy; I’m just setting the rotation on the y-axis from -45deg to 45deg (the translateZ value remains constant).
@keyframes ring { from { transform: rotateY(-45deg) translateZ(var(--translateZ, 0)); } to { transform: rotateY(45deg) translateZ(var(--translateZ, 0)); }}
As for the animation property, I’ve given named it ring , and a hard-coded (at least for now) a duration of 3s, that loops infinitely. Setting the animation’s timing function with ease-in-out and alternate, respectively, gives us a smooth back-and-forth motion.
i { animation: ring 3s infinite ease-in-out alternate;}
That’s how the animation works!
Adding more ringsNow we can add the remaining four rings to the HTML. Remember, we have five rings total and each ring contains 16 <i> layers. It could look as simple as this:
```
``
There’s something elegant about the simplicity of this markup. And we could use the CSSnth-child()pseudo-selector to select them individually. I like being a bit more declarative than that and am going to give each.ring` and additional class we can use to explicitly select a given ring.
```
``` Our task now is to adjust each ring individually. Right now, everything looks like the first ring we made together. We’ll use the unique classes we just set in the HTML to give them their own color, position, and animation duration.
The good news? We’ve been using custom properties this entire time! All we have to do is update the values in each ring’s unique class.
.ring { &.ring__1 { --ringColor: #0081c8; --duration: 3.2s; --translate: -240px, -40px; } &.ring__2 { --ringColor: #fcb131; --duration: 2.6s; --translate: -120px, 40px; } &.ring__3 { --ringColor: #444444; --duration: 3.0s; --translate: 0, -40px; } &.ring__4 { --ringColor: #00a651; --duration: 3.4s; --translate: 120px, 40px; } &.ring__5 { --ringColor: #ee334e; --duration: 2.8s; --translate: 240px, -40px; }}
If you’re wondering where those --ringColor values came from, I based them on the International Olympic Committee’s documented colors. Each --duration is slightly offset from one another to stagger the movement between rings, and the rings are --translate‘d 120px apart and then staggered vertically by alternating their position 40px and -40px.
Let’s apply the translation stuff to the .ring elements:
.ring { transform: translate(var(--translate));}
Earlier, we set the animation’s duration to a hard-coded three seconds:
i { animation: ring 3s infinite ease-in-out alternate;}
This is the time to replace that with a custom property that calculates the duration for each ring separately.
i { animation: ring var(--duration) -10s infinite ease-in-out alternate;}
Whoa, whoa! What’s the -10s value doing in there? Even though each ring layer is set to animate for a different duration, the starting angle of the animations is all the same. Adding a constant negative delay on changing durations will make sure that each ring’s animation starts at a different angle.
Now we have something that is almost finished:
CodePen Embed FallbackSome final touchesWe’re at the final stretch! The animation looks pretty great as-is, but I want to add two more things. The first one is a small-10deg “tilt” on the x-axis of the parent .rings container. This will make it look like we’re viewing things from a higher perspective.
.rings { rotate: x -10deg;}
The second finishing touch has to do with shadows. We can really punctuate the 3D depth of our work and all it takes is selecting the .ring element’s ::after pseudo-element and styling it like a shadow.
First, we’ll set the width of the pseudos’ border and outline to a constant (24px) while setting the color to a semi-transparent black (#0003). Then we’ll translate them so they appear to be further away. We’ll also inset them so they line up with the actual rings. Basically, we’re shifting the pseudo-elements around relative to the actual element.
.ring { /* etc. */ &::after { content: ''; position: absolute; inset: -100px; border: 24px #0003 solid; outline: 24px #0003 solid; translate: 0 -100px -400px; }}
The pseudos don’t look very shadow-y at the moment. But they will if we blur() them a bit:
.ring { /* etc. */ &::after { content: ''; position: absolute; inset: -100px; border: 24px #0003 solid; outline: 24px #0003 solid; translate: 0 -100px -400px; filter: blur(12px); }}
The shadows are also pretty box-y. Let’s make sure they’re round like the rings:
.ring { /* etc. */ &::after { content: ''; position: absolute; inset: -100px; border: 24px #0003 solid; outline: 24px #0003 solid; translate: 0 -100px -400px; filter: blur(12px); border-radius: 50%; }}
Oh, and we ought to set the same animation on the pseudo so that the shadows move in harmony with the rings:
.ring { /* etc. */ &::after { content: ''; position: absolute; inset: -100px; border: 24px #0003 solid; outline: 24px #0003 solid; translate: 0 -100px -400px; filter: blur(12px); border-radius: 50%; animation: ring var(--duration) -10s infinite ease-in-out alternate; }}
Final demoLet’s stop and admire our completed work:
CodePen Embed FallbackAt the end of the day, I’m really happy with the 2024 version of the Olympic rings. The 2020 version got the job done and was probably the right approach for that time. But with all of the features we’re getting in modern CSS today, I had plenty of opportunities to improve the code so that it is not only more efficient but more reusable — for example, this could be used in another project and “themed” simply by updating the --ringColor custom property.
Ultimately, this exercise proved to me the power and flexibility of modern CSS. We took an existing idea with complexities and recreated it with simplicity and elegance.
CSS Olympic Rings originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Heydon on the virtues of hyperlinking hypertext in an anchor element:
Sometimes, the
<a>is referred to as a hyperlink, or simply a link. But it is not one of these and people who say it is one are technically wrong (the worst kind of wrong).[…]
An
<a>is an interactive element (well, it is if it has anhref). The text inside an interactive element is sometimes referred to as a label since it should tell you what the element does. Since anchors take you places on the web, the text should tell you where you would be going or what you can do there.[…]
Web developers and content editors, the world over, make the mistake of not making text that describes a link actually go inside that link. This is collosally [sic] unfortunate, given it’s the main thing to get right when writing hypertext.
As far as where that anchor hyperlinks to, Jim Nielsen back in 2003 discussed a bunch of considerations that go into designing URLs. More recently, he’s mused on the the potential of well-designed URLs to change — or more accurately, the potential of humans to change things:
If a slug is going to be human-friendly, i.e. human-readable, then it’s going to contain information that is subject to change because humans make errors.
Swapping the contents of a URL is a breaking change. If we were to start with a wonderful URL like, say:
<a href=“css-tricks.com/almanac”>
…but decide that we now like “Docs” instead of “Almanac” then we might do this:
<a href=“css-tricks.com/docs”>
Naturally, we’d drop some sorta redirect on the server so that anyone attempting to hit /almanac is automatically directed to /docs instead. But now we’ve got a form of technical debt to maintain that may not be any more dangerous than walking and chewing gum at the same time, but could become a mouthful much later. We’ve got a gazillion redirects on CSS-Tricks for a gazillion different reasons, most often for totally human reasons like typos. Remember the CSS-Tricks Chronicles we used to write? Botching the Roman numeral numbering system on those was standard fare. Look at the very last edition from 2001, titled “CSS-Tricks Choronicles XLI” and its URL:
https://css-tricks.com/css-tricks-chronicle-xxxxi/
🥸
I’ve been thinking about this a lot while attempting to organize the 7,000 some-odd articles on this site. For years, we’ve maintained a “flat” structure in the sense that the title of an article becomes the URL (after, perhaps, with some light editing):
<a href=“css-tricks.com/geoff-is-on-another-dumb-rant”>
But I’m starting to think about the content on this site in terms of type rather than title alone. For example, we’ve always had “articles” on this site with a smattering of “links” sprinkled in alongside Almanac “entries” and “guides” among other categories of content. We’ve just never reflected that in our URLs because, well, the design is flat. Adding another layer for the type of content borks the original URL!
<a href=“css-tricks.com/soapbox/geoff-is-on-another-dumb-rant”>
Jay Hoffman has been thinking about this, too.
A dead link may not seem like it means very much, even in the aggregate. But they are. One-way links, the way they exist on the web where anyone can link to anything, is what makes the web universal. In fact, the first name for URL’s was URI’s, or Universal Resource Identifier. It’s right there in the name. And as Berners-Lee once pointed out, “its universality is essential.”
[…]
Time and time again, when the web goes into crisis and part of it is lost, the Internet Archive and similar efforts come to the rescue. But even the Internet Archive is having a hard time protecting against a barrage of link rot we can’t seem to get away from.
All of this dovetails into recent reporting that Google has decided to sunset its URL shortener. All of those goo.gl URLs accumulated since the shortener was introduced in 2018?
Any developers using links built with the Google URL Shortener in the form https://goo.gl/* will be impacted, and these URLs will no longer return a response after August 25th, 2025. We recommend transitioning these links to another URL shortener provider.
There’s some minutiae of consolation for Google itself:
Note that goo.gl links generated via Google apps (such as Maps sharing) will continue to function.
To be clear, this move is less a form of link rot than it is a straight-up pruning to cut things off. If link rot is akin to allowing your hair to go gray, then deprecating Google’s URL shortener is a total head shave. Nick Heer believes there’s a good side to it, however:
In principle, I support this deprecation because it is confusing and dangerous for Google’s own shortened URLs to have the same domain as ones created by third-party users. But this is a Google-created problem because it designed its URLs poorly. It should have never been possible for anyone else to create links with the same URL shortener used by Google itself.
I tend to agree. The whole situation is a Rosemary’s Baby predicament presenting two terribly uncomfortable choices. The right uncomfortable decision was made, but we still have to deal with the repercussions of wiping out part of the web’s context.
Heydon’s post led me down this rabbit trail, so I’ll link it up here for you to take a hike with it.
(Hyper) Links About (Hyper) Links originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
I have to thank Jeremy Keith and his wonderfully insightful article from late last year that introduced me to the concept of HTML Web Components. This was the “a-ha!” moment for me:
When you wrap some existing markup in a custom element and then apply some new behaviour with JavaScript, technically you’re not doing anything you couldn’t have done before with some DOM traversal and event handling. But it’s less fragile to do it with a web component. It’s portable. It obeys the single responsibility principle. It only does one thing but it does it well.
Until then, I’d been under the false assumption that all web components rely solely on the presence of JavaScript in conjunction with the rather scary-sounding Shadow DOM. While it is indeed possible to author web components this way, there is yet another way. A better way, perhaps? Especially if you, like me, advocate for progressive enhancement. HTML Web Components are, after all, just HTML.
While it’s outside the exact scope of what we’re discussing here, Andy Bell has a recent write-up that offers his (excellent) take on what progressive enhancement means.
Let’s look at three specific examples that show off what I think are the key features of HTML Web Components — CSS style encapsulation and opportunities for progressive enhancement — without being forced to depend on JavaScript to work out of the box. We will most definitely use JavaScript, but the components ought to work without it.
The examples can all be found in my Web UI Boilerplate component library (built using Storybook), along with the associated source code in GitHub.
Example 1: Live demoI really like how Chris Ferdinandi teaches building a web component from scratch, using a disclosure (show/hide) pattern as an example. This first example extends his demo.
Let’s start with the first-class citizen, HTML. Web components allow us to establish custom elements with our own naming, which is the case in this example with a tag we’re using to hold a designed to show/hide a block of text and a that holds the of text we want to show and hide.
Show / Hide
Content to be shown/hidden. If JavaScript is disabled or doesn’t execute (for any number of possible reasons), the button is hidden by default — thanks to the hidden attribute on it— and the content inside of the div is simply displayed by default.
Nice. That’s a really simple example of progressive enhancement at work. A visitor can view the content with or without the .I mentioned that this example extends Chris Ferdinandi’s initial demo. The key difference is that you can close the element either by clicking the keyboard’s ESC key or clicking anywhere outside the element. That’s what the two [data-attribute]s on the tag are for.
We start by defining the custom element so that the browser knows what to do with our made-up tag name:
`customElements.define('webui-disclosure', WebUIDisclosure);`
Custom elements must be named with a dashed-ident, such as or whatever, but as Jim Neilsen notes, by way of Scott Jehl, that doesn’t exactly mean that the dash *has* to go between two words.
I typically prefer using TypeScript for writing JavaScript to help eliminate stupid errors and enforce some degree of “defensive” programming. But for the sake of simplicity, the structure of the web component’s ES Module looks like this in plain JavaScript:
`default class WebUIDisclosure extends HTMLElement { constructor() { super(); this.trigger = this.querySelector('[data-trigger]'); this.content = this.querySelector('[data-content]'); this.bindEscapeKey = this.hasAttribute('data-bind-escape-key'); this.bindClickOutside = this.hasAttribute('data-bind-click-outside'); if (!this.trigger || !this.content) return; this.setupA11y(); this.trigger?.addEventListener('click', this); } setupA11y() { // Add ARIA props/state to button. } // Handle constructor() event listeners. handleEvent(e) { // 1. Toggle visibility of content. // 2. Toggle ARIA expanded state on button. } // Handle event listeners which are not part of this Web Component. connectedCallback() { document.addEventListener('keyup', (e) => { // Handle ESC key. }); document.addEventListener('click', (e) => { // Handle clicking outside. }); } disconnectedCallback() { // Remove event listeners. }}`
Are you wondering about those event listeners? The first one is defined in theconstructor()function, while the rest are in theconnectedCallback()function. Hawk Ticehurst explains the rationale much more eloquently than I can.
This JavaScript isn’t required for the web component to “work” but it does sprinkle in some nice functionality, not to mention accessibility considerations, to help with the progressive enhancement that allows the
to show and hide the content. For example, JavaScript injects the appropriatearia-expandedandaria-controls` attributes enabling those who rely on screen readers to understand the button’s purpose.That’s the progressive enhancement piece to this example.
For simplicity, I have not written any additional CSS for this component. The styling you see is simply inherited from existing global scope or component styles (e.g., typography and button).
However, the next example does have some extra scoped CSS.
Example 2: That first example lays out the progressive enhancement benefits of HTML Web Components. Another benefit we get is that CSS styles are encapsulated, which is a fancy way of saying the CSS doesn’t leak out of the component. The styles are scoped purely to the web component and those styles will not conflict with other styles applied to the current page.
Let’s turn to a second example, this time demonstrating the style encapsulating powers of web components and how they support progressive enhancement in user experiences. We’ll be using a tabbed component for organizing content in “panels” that are revealed when a panel’s corresponding tab is clicked — the same sort of thing you’ll find in many component libraries.
Live demoStarting with the HTML structure:
``` Tab 1 Tab 2 Tab 3 1 - Lorem ipsum dolor sit amet consectetur.
2 - Lorem ipsum dolor sit amet consectetur.
3 - Lorem ipsum dolor sit amet consectetur.
``
You get the idea: three links styled as tabs that, when clicked, open a tab panel holding content. Note that each[data-tab]in the tab list targets an anchor link matching a tab panel ID, e.g.,#tab1,#tab2`, etc.
We’ll look at the style encapsulation stuff first since we didn’t go there in the last example. Let’s say the CSS is organized like this:
webui-tabs { [data-tablist] { /* Default styles without JavaScript */ } [data-tab] { /* Default styles without JavaScript */ } [role='tablist'] { /* Style role added by JavaScript */ } [role='tab'] { /* Style role added by JavaScript */ } [role='tabpanel'] { /* Style role added by JavaScript */ }}
See what’s happening here? We have two style rules — [data-tablist] and [data-tab] — that contain the web component’s default styles. In other words, these styles are there regardless of whether JavaScript loads or not. Meanwhile, the other three style rules are selectors that are injected into the component as long as JavaScript is enabled and supported. This way, the last three style rules are only applied if JavaScript plops the **role** attribute on those elements in the HTML. Right there, we’re already supplying a touch of progressive enhancement by setting styles only when JavasScript is needed.
All these styles are fully encapsulated, or scoped, to the web component. There is no “leakage” so to speak that would bleed into the styles of other web components, or even to anything else on the page within the global scope. We can even choose to forego classnames, complex selectors, and methodologies like BEM in favour of simple descendent selectors for the component’s children, allowing us to write styles more declaratively on semantic elements.
Quickly: “Light” DOM versus Shadow DOMFor most web projects, I generally prefer to bundle CSS (including the web component Sass partials) into a single CSS file so that the component’s default styles are available in the global scope, even if the JavaScript doesn’t execute.
However, it is possible to import a stylesheet via JavaScript that is only consumed by this web component if JavaScript is available:
import styles from './styles.css';class WebUITabs extends HTMLElement { constructor() { super(); this.adoptedStyleSheets = [styles]; }}customElements.define('webui-tabs', WebUITabs);
Alternatively, we could inject a // etc.; }} customElements.define('webui-tabs', WebUITabs);`
Whichever method you choose, these styles are scoped directly to the web component, preventing component styles from leaking out, but allowing global styles to be inherited.
Now consider this simple example. Everything we write in between the component’s opening and closing tags is considered part of the “Light” DOM.
``` Some content... styles are inherited from the global scope
----------- Shadow DOM Boundary ------------- | | --------------------------------------------- ``` Dave Rupert has an excellent write-up that makes it really easy to see how external styles are able to “pierce” the Shadow DOM and select an element in the Light DOM. Notice how the
element that is written in between the custom element’s tags receives the button selector’s styles in the global CSS, while the injected via JavaScript is left untouched.If we want to style the Shadow DOM
we’d have to do that with internal styles like the examples above for importing a stylesheet or injecting an inline`
Most days, I’m writing vanilla CSS. Thanks to CSS variables and nesting, I have fewer reasons to reach for Sass or any other preprocessor. The times I reach for Sass tend to be when I need a @mixin to loop through a list of items or help keep common styles DRY.
That could change for me in the not-so-distant future since a new CSS Functions and Mixins Module draft was published in late June after the CSSWG resolved to adopt the proposal back in February.
Notice the module’s name: Functions and Mixins. There’s a distinction between the two.
This is all new and incredibly unbaked at the moment with plenty of TODO notes in the draft and points to consider in future drafts. The draft spec doesn’t even have a definition for mixins yet. It’ll likely be some time before we get something real to work and experiment with, but I like trying to wrap my mind around these sorts of things while they’re still in early days, knowing things are bound to change.
In addition to the early draft spec, Miriam Suzanne published a thorough explainer that helps plug some of the information gaps. Miriam’s an editor on the spec, so I find anything she writes about this to be useful context.
There’s a lot to read! Here are my key takeaways…
Custom functions are advanced custom propertiesWe’re not talking about the single-purpose, built-in functions we’ve come to love in recent years — e.g., calc(), min(), max(), etc. Instead, we’re talking about custom functions defined with an @function at-rule that contains logic for returning an expected value.
That makes custom functions a lot like a custom property. A custom property is merely a placeholder for some expected value that we usually define up front:
:root { --primary-color: hsl(25 100% 50%);}
Custom functions look pretty similar, only they’re defined with @function and take parameters. This is the syntax currently in the draft spec:
@function <function-name> [( <parameter-list> )]? { <function-rules> result: <result>;}
The result is what the ultimate value of the custom function evaluates to. It’s a little confusing to me at the moment, but how I’m processing this is that a custom function returns a custom property. Here’s an example straight from the spec draft (slightly modified) that calculates the area of a circle:
@function --circle-area(--r) { --r2: var(--r) * var(--r); result: calc(pi * var(--r2));}
Calling the function is sort of like declaring a custom property, only without var() and with arguments for the defined parameters:
.element { inline-size: --circle-area(--r, 1.5rem); /* = ~7.065rem */}
Seems like we could achieve the same thing as a custom property with current CSS features:
:root { --r: 1rem; --r2: var(--r) * var(--r); --circle-area: calc(pi * var(--r2));}.element { inline-size: var(--circle-area, 1.5rem);}
That said, the reasons we’d reach for a custom function over a custom property are that (1) they can return one of multiple values in a single stroke, and (2) they support conditional rules, such as @supports and @media to determine which value to return. Check out Miriam’s example of a custom function that returns one of multiple values based on the inline size of the viewport.
/* Function name */@function --sizes( /* Array of possible values */ --s type(length), --m type(length), --l type(length), /* The returned value with a default */) returns type(length) { --min: 16px; /* Conditional rules */ @media (inline-size < 20em) { result: max(var(--min), var(--s, 1em)); } @media (20em < inline-size < 50em) { result: max(var(--min), var(--m, 1em + 0.5vw)); } @media (50em < inline-size) { result: max(var(--min), var(--l, 1.2em + 1vw)); }}
Miriam goes on to explain how a comma-separated list of parameters like this requires additional CSSWG work because it could be mistaken as a compound selector.
Mixins help maintain DRY, reusable style blocksMixins feel more familiar to me than custom functions. Years of writing Sass mixins will do that to you, and indeed, is perhaps the primary reason I still reach for Sass every now and then.
Mixins sorta look like the new custom functions. Instead of @function we’re working with @mixin which is exactly how it works in Sass.
/* Custom function */@function <function-name> [( <parameter-list> )]? { <function-rules> result: <result>;}/* CSS/Sass mixin */@mixin <mixin-name> [( <parameter-list> )]? { <mixin-rules>}
So, custom functions and mixins are fairly similar but they’re certainly different:
@function; mixins are defined with @mixin but are both named with a dashed ident (e.g. --name).result in a value; mixins result in style rules.This makes mixins ideal for abstracting styles that you might use as utility classes, say a class for hidden text that is read by screenreaders:
.sr-text { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden;}
In true utility fashion, we can sprinkle this class on elements in the HTML to hide the text.
<a class="sr-text">Skip to main content</a>
Super handy! But as any Tailwind-hater will tell you, this can lead to ugly markup that’s difficult to interpret if we rely on many utility classes. Screereader text isn’t in too much danger of that, but a quick example from the Tailwind docs should illustrate that point:
```
It’s been a few months out since A Book Apart closed shop. I’m sad about it, of course. You probably are, too, if you have one of their many brightly-colored paperbacks sitting on a bookshelf strategically placed as a backdrop for your video calls.
It looked for a bit like the books would still be available for purchase through third-party distributors who could print them on demand or whatever. And then a redaction on A Book Apart’s original announcement:
UPDATE: Ownership and publishing rights for all books have been given back to their respective authors. Many authors are continuing to offer their work for free or in new editions. Our hope is that these books will continue to live on forever. A Book Apart no longer sells or distributes books, please reach out to authors for information about availability.
Oh, snap. The books are on the loose and several authors are making sure they’re still available. Eric Meyer, for example, says he and co-author Sara Wachter-Boettcher still figuring out what’s next for their Design for Real Life title:
One of the things Sara and I have decided to do is to eventually put the entire text online for free, as a booksite. That isn’t ready yet, but it should be coming somewhere down the road.
In the meantime, we’ve decided to cut the price of print and e-book copies available through Ingram. [Design for Real Life] was the eighteenth book [A Book Apart] put out, so we’ve decided to make the price of both the print and e-book $18, regardless of whether those dollars are American, Canadian, or Australian.
Ethan Marcotte has followed suit by listing his three titles on his personal website and linking up where they can be purchased at a generous discount off the original price tag, including his latest, You Deserve a Tech Union.
Others have quickly responded with free online versions of their books. Mat Marquis has offered JavaScript for Web Designers free online for a long time. He helped Chris Coyier do the same with Practical SVG this past week. Jeremy Keith put out one of my personal ABA faves (and the first ever ABA-published book) for free, HTML5 for Web Designers.
What about all the other titles? I dunno. A Book Apart simply doesn’t sell or distribute them anymore. Rachel McConnell sells Leading Content Design directly. Every other book I checked seems to be a link back to A Book Apart. We’ll have to see where the proverbial dust settles. The authors now hold all the rights to their works and may or may not decide to re-offer them. Meanwhile, many of the titles are listed in places like Goodreads, Amazon, Barnes & Noble, etc.
A couple of folks have even started tracking the books on their personal sites, like Ryan Trimble and Alan Dalton. (Thanks for the tip, Chris!)
Thanks for all the great reads and years, A Book Apart! You’ve helped man, many people become better web citizens, present company included.
Where You Can Still Get A Book Apart Titles originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Smashing Magazine invited me to sit down for a one-on-one with “Uncle” Dave Rupert to discuss web components, yes, but also check in on Dave’s new Microsoft gig and what the ShopTalk co-host is working on these days.
I first met Dave in 2015 when CSS Dev Conf took place in my backyard, Long Beach. It’s not like we’ve been in super close touch between then and now — we may have only chatted one-on-one like that a couple other times — but talking with Dave each time feels like hanging with a close friend ands this time was no different. Good, good vibes and web nerdery.
To Shared Link — Permalink on CSS-Tricks
Smashing Hour With Dave Rupert originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
This post came up following a conversation I had with Emilio Cobos — a senior developer at Mozilla and member of the CSSWG — about the last CSSWG group meeting. I wanted to know what he thought were the most exciting and interesting topics discussed at their last meeting, and with 2024 packed with so many new or coming flashy things like masonry layout, if() conditionals, anchor positioning, view transitions, and whatnot, I thought his answers had to be among them.
He admitted that my list of highlights was accurate on what is mainstream in the community, especially from an author’s point of view. However, and to my surprise, his favorite discussion was on something completely different: an inaccuracy on how the letter-spacing property is rendered across browsers. It’s a flaw so ingrained on the web that browsers have been ignoring the CSS specification for years and that can’t be easily solved by a lack of better options and compatibility issues.
Emilios’s answer makes sense — he works on Gecko and rendering fonts is an art in itself. Still, I didn’t get what the problem is exactly, why he finds it so interesting, and even why it exists in the first place since letter-spacing is a property as old as CSS. It wasn’t until I went into the letter-spacing rabbit hole that I understood how amazingly complex the issue gets and I hope to get you as interested as I did in this (not so) simple property.
What’s letter spacing?The question seems simple: letter spacing is the space between letters. Hooray! That was easy, for humans. For a computer, the question of how to render the space between letters has a lot more nuance. A human just writes the next letter without putting in much thought. Computers, on the other hand, need a strategy on how to render that space: should they add the full space at the beginning of the letter, at the end, or halve it and add it on both sides of the letter? Should it work differently from left-to-right (LTR) languages, like English, to right-to-left (RTL) like Hebrew? These questions are crucial since choosing one as a standard shapes how text measurement and line breaks work across the web.
Which of the three strategies is used on the web? Depends on who you ask. The implementation in the CSS specifications completely differs from what the browsers do, and there is even incompatibility between browsers rendering engines, like Gecko (Firefox), Blink (Chrome, Brave, Opera, etc.), and WebKit (Safari).
What the CSS spec saysLet’s backpedal a bit and first know how the spec says letter spacing should work. At the time of writing, letter-spacing:
Specifies additional spacing between typographic character units. Values may be negative, but there may be implementation-dependent limits.
The formal specification has more juice to it, but this one gives us enough to understand how the CSS spec wants letter-spacing to behave. The keyword is between, meaning that the letter spacing should only affect the space between characters. I know, sounds pretty obvious.
So, as the example given on the spec, the following HTML:
```
abbc
``` …with this CSS:
p { letter-spacing: 1em;}span { letter-spacing: 2em;}
…should give an equal space between the two “b” letters:
However, if we run the same code on any browser (e.g., Chrome, Firefox, or Safari), we’ll see the spacing isn’t contained between the “b” letters, but also at the end of the complete word.
What browsers doI thought it was normal for letter-spacing to attach spacing at the end of a character and didn’t know the spec said otherwise. However, if you think about it, the current behavior does seem off… it’s just that we’re simply used to it.
Why would browsers not follow the spec on this one?
As we saw before, letter spacing isn’t straightforward for computers since they must stick to a strategy for where spacing is applied. In the case of browsers, the standard has been to apply an individual space at the end of each character, ignoring if that space goes beyond the full word. It may have not been the best choice, but it’s what the web has leaned into, and changing it now would result in all kinds of text and layout shifts across the web.
This leaves a space at the end of elements with bigger letter spacing, which is somewhat acceptable for LTR text, but it leaves a hole at the beginning of the text in an RTL writing mode.
CodePen Embed FallbackThe issue is more obvious with centered text, where the ending space pushes the text away from the element’s dead center. You’ve probably had to add padding on the opposite side of an element to make up for any letter-spacing you’ve applied to the text at least one time, like on a button.
CodePen Embed FallbackAs you can see, the blue highlight creates a symmetrical pyramid which our text sadly doesn’t follow.
What’s worse, the “end of each character” means something different to browsers, particularly when working in an RTL writing mode. Chrome and Safari (Blink/WebKit) say the end of a character is always on the right-hand side. Firefox (Gecko), on the other hand, adds space to the “reading” end — which in Hebrew and Arabic is the left-hand side. See the difference yourself:
Can this be fixed?The first thought that comes to mind is to simply follow what the spec says and trim the unnecessary space at the ending character, but this (anti) solution brings compatibility risks that are simply too big to even consider; text measurement and line breaks would change, possibly causing breakage on lots of websites. Pages that have removed that extra space with workarounds probably did it by offsetting the element’s padding/margin, which means changing the behavior as it currently stands makes those offsets obsolete or breaking.
There are two real options for how letter-spacing can be fixed: reworking how the space is distributed around the character or allowing developers an option to choose where we want the ending space.
Option 1: Reworking the space distributionThe first option would be to change the current letter-spacing definition so it says something like this:
Specifies additional spacing applied to each typographic character unit except those with zero advance. The additional spacing is divided equally between the inline-start and -end sides of the typographic character unit. Values may be negative, but there may be implementation-dependent limits.
Simply put, instead of browsers applying the additional space at the end of the character, they would divide it equally at the start and end, and the result is symmetrical text. This would also change text measurements and line breaks, albeit to a lesser degree.
Now text that is center-aligned text is correctly aligned to the center:
Option 2: Allowing developers an option to chooseEven if the offset is halved, it could still bring breaking layout shifts to pages which to some is still (rightfully) unacceptable. It’s a dilemma: most pages need, or at least would benefit, from leaving letter-spacing as-is, while new pages would enjoy symmetrical letter spacing. Luckily, we could do both by giving developers the option to choose how the space is applied to characters. The syntax is anybody’s guess, but we could have a new property to choose where to place the spacing:
letter-spacing-justify: [ before | after | left | right | between | around];
Each value represents where the space should be added, taking into account the text direction:
before: the spacing is added at the beginning of the letter, following the direction of the language.after: the spacing is added at the end of the letter, following the direction of the language.left: the spacing is added at the left of the letter, ignoring the direction of the language.right: the spacing is added at the right of the letter, ignoring the direction of the language.between: the spacing is added between characters, following the spec.around: the spacing is divided around the letter.Logically, the current behavior would be the default to not break anything and letter-spacing would become a shorthand for both properties (length and placing).
letter-spacing: 1px before;letter-spacing: 1px right;letter-spacing: 1px around;letter-spacing: 1px;/* same as: */letter-spacing: 1px before;
What about a third option?And, of course, the third option is to leave things as they are. I’d say this is unlikely since the CSSWG resolved to take action on the issue, and they’ll probably choose the second option if I had to bet the nickel in my pocket on it.
Now you know letter-spacing is broken… and we have to live with it, at least for the time being. But there are options that may help correct the problem down the road.
Letter Spacing is Broken and There’s Nothing We Can Do About It… Maybe originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
I’ve always been fascinated with how much we can do with just HTML and CSS. The new interactive features of the Popover API are yet another example of just how far we can get with those two languages alone.
You may have seen other tutorials out there showing off what the Popover API can do, but this is more of a beating-it-mercilessly-into-submission kind of article. We’ll add a little more pop ~~music~~ to the mix, like with balloons… some literal “pop” if you will.
What I’ve done is make a game — using only HTML and CSS, of course — leaning on the Popover API. You’re tasked with popping as many balloons as possible in under a minute. But be careful! Some balloons are (as Gollum would say) “tricksy” and trigger more balloons.
I have cleverly called it Pop(over) the Balloons and we’re going to make it together, step by step. When we’re done it’ll look something like (OK, exactly like) this:
CodePen Embed FallbackHandling the popover attributeAny element can be a popover as long as we fashion it with the popover attribute:
```
``
We don’t even have to supplypopoverwith a value. By default,popover‘s initial value isauto` and uses what the spec calls “light dismiss.” That means the popover can be closed by clicking anywhere outside of it. And when the popover opens, unless they are nested, any other popovers on the page close. Auto popovers are interdependent like that.
The other option is to set popover to a manual value:
```
``
…which means that the element is manually opened and closed — we literally have to click a specific button to open and close it. In other words,manual` creates an ornery popup that only closes when you hit the correct button and is completely independent of other popovers on the page.
CodePen Embed FallbackUsing the <details> element as a starterOne of the challenges of building a game with the Popover API is that you can’t load a page with a popover already open… and there’s no getting around that with JavaScript if our goal is to build the game with only HTML and CSS.
Enter the <details> element. Unlike a popover, the <details> element can be open by default:
```
``
If we pursue this route, we’re able to show a bunch of buttons (balloons) and “pop” all of them down to the very last balloon by closing the. In other words, we can plop our starting balloons in an open
This is the basic structure I’m talking about:
```
``
In this way, we can click on the balloon into close theand “pop” all of the button balloons, leaving us with one balloon (the` at the end (which we’ll solve how to remove a little later).
You might think that <dialog> would be a more semantic direction for our game, and you’d be right. But there are two downsides with <dialog> that won’t let us use it here:
<dialog> that’s open on page load is with JavaScript. As far as I know, there isn’t a close <button> we can drop in the game that will close a <dialog> that’s open on load.<dialog>s are modal and prevent clicking on other things while they’re open. We need to allow gamers to pop balloons outside of the <dialog> in order to beat the timer.Thus we will be using a <details open> element as the game’s top-level container and using a plain ol’ <div> for the popups themselves, i.e. <div popover>.
All we need to do for the time being is make sure all of these popovers and buttons are wired together so that clicking a button opens a popover. You’ve probably learned this already from other tutorials, but we need to tell the popover element that there is a button it needs to respond to, and then tell the button that there is a popup it needs to open. For that, we give the popover element a unique ID (as all IDs should be) and then reference it on the <button> with a popovertarget attribute:
```
``` This is the idea when everything is wired together:
CodePen Embed FallbackOpening and closing popoversThere’s a little more work to do in that last demo. One of the downsides to the game thus far is that clicking the <button> of a popup opens more popups; click that same <button> again and they disappear. This makes the game too easy.
We can separate the opening and closing behavior by setting the popovertargetaction attribute (no, the HTML spec authors were not concerned with brevity) on the <button>. If we set the attribute value to either show or hide, the <button> will only perform that one action for that specific popover.
```
``
Note, that I’ve added a new