Front End Web Development
Color Everything in CSS
I have had the opportunity to edit over a lot of the new color entries coming to the CSS-Tricks Almanac. We’ve already published several with more on the way, including a complete guide on color functions:
And I must admit: I didn’t know a lot about color in CSS (I still used rgb(), which apparently isn’t what cool people do anymore), so it has been a fun learning experience. One of the things I noticed while trying to keep up with all this new information was how long the glossary of color goes, especially the “color” concepts. There are “color spaces,” “color models,” “color gamuts,” and basically a “color” something for everything.
They are all somewhat related, and it can get confusing as you dig into using color in CSS, especially the new color functions that have been shipped lately, like contrast-color() and color-mix(). Hence, I wanted to make the glossary I wish I had when I was hearing for the first time about each concept, and that anyone can check whenever they forget what a specific “color” thing is.
As a disclaimer, I am not trying to explain color, or specifically, color reproduction, in this post; that would probably be impossible for a mortal like me. Instead, I want to give you a big enough picture for some technicalities behind color in CSS, such that you feel confident using functions like lab() or oklch() while also understanding what makes them special.
What’s a color?Let’s slow down first. In order to understand everything in color, we first need to understand the color in everything.
While it’s useful to think about an object being a certain color (watch out for the red car, or cut the white cable!), color isn’t a physical property of objects, or even a tangible thing. Yes, we can characterize light as the main cause of color1, but it isn’t until visible light enters our eyes and is interpreted by our brains that we perceive a color. As said by Elle Stone:
Light waves are out there in the world, but color happens in the interaction between light waves and the eye, brain, and mind.
Even if color isn’t a physical thing, we still want to replicate it as reliably as possible, especially in the digital era. If we take a photo of a beautiful bouquet of lilies (like the one on my desk) and then display it on a screen, we expect to see the same colors in both the image and reality. However, “reality” here is a misleading term since, once again, the reality of color depends on the viewer. To solve this, we need to understand how light wavelengths (something measurable and replicable) create different color responses in viewers (something not so measurable).
Luckily, this task was already carried out 95 years ago by the International Commission on Illumination (CIE, by its French name). I wish I could get into the details of the experiment, but we haven’t gotten into our first color thingie yet. What’s important is that from these measurements, the CIE was able to map all the colors visible to the average human (in the experiment) to light wavelengths and describe them with only three values.
Initially, those three primary values corresponded to the red, green, and blue wavelengths used in the experiment, and they made up the CIERGB Color Space, but researchers noticed that some colors required a negative wavelength2 to represent a visible color. To avoid that, a series of transformations were performed on the original CIERGB and the resulting color space was called CIEXYZ.
This new color space also has three values, X and Z represent the chromaticity of a color, while Y represents its luminance. Since it has three axes, it makes a 3D shape, but if we slice it such that its luminance is the same, we get all the visible colors for a given luminance in a figure you have probably seen before.
This is called the xy chromaticity diagram and holds all the colors visible by the average human eye (based on the average viewer in the CIE 1931 experiment). Colors inside the shape are considered real, while those outside are deemed imaginary.
Color 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:
- sRGB: We can work in sRGB using the ol’ hexadecimal notation, named colors, and the rgb(), rgba(), hsl(), hsla() and hwb() functions.
- CIELAB: Here we have the lab() for Cartesian coordinates and lch() for polar coordinates.
- Oklab: Similar to CIELAB, we have oklab() for Cartesian coordinates and oklch() for polar coordinates.
- More through the color() and color-mix(). Outside these three color spaces, we can use many more using the color() and color-mix() functions. Specifically, we can use the RGB color spaces: rgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020 and the XYZ color space: xyz, xyz-d50, or xyz-d65.
- Color spaces are a mapping between available colors and a coordinate system. In CSS, we have three main color spaces: sRGB, CIELAB, and Oklab, but many more are accessible through the color() function.
- Color models define color with tuples of numbers, but they don’t give us information about the actual color until we pair them with a color space. For example, the RGB model doesn’t mean anything until we assign it an RGB color space.
- Most of the time, we want to talk about how many colors a color space holds, so we use the term color gamut for the task. However, a color gamut is also tied to the physical limitations of a camera/display. A color may be out-of-gamut, meaning it can’t be represented in a given color space.
- In CSS, we can access all these color spaces through color functions, of which there are many.
- The CIEXYZ color space is extremely useful to define other color spaces, describe their gamuts, and convert between them.
- 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)
1 Light is the main cause of color, but color can be created by things other than light. For example, rubbing your closed eyes mechanically stimulates your retina, creating color in what’s called phosphene. ⤴️
2 If negative light also makes you scratch your head, and for more info on how the CIEXYZ color space was created, I highly recommend Douglas A. Kerr The CIE XYZ and xyY Color Spaces paper. ⤴️
3 We also need to define the darkest dark color (“black”) and the lightest light color (“white”). However, for well-behaved color spaces, these two can be abstracted from the reddest, blues, and greenest colors. ⤴️
Color Everything in CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Steven Heller’s Font of the Month: Kefir
Read the book, Typographic Firsts
This month, Steven Heller takes a closer look at Roch Modrzejewski’s Kefir font family.
The post Steven Heller’s Font of the Month: Kefir appeared first on I Love Typography.
CSS Color Functions
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() functionrgb() 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() functionrgba() is essentially the same as rgb() with an extra alpha value used for transparency.
In terms of syntax, the rgba() function can be written in two ways:
- Comma-separated and without percentages
- Space-separated, with the alpha value written after a forward slash (/)
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 notationThe hexadecimal CSS color code is a 3, 4, 6, or 8 (being the maximum) digit code for colors in sRGB. It’s basically a shorter way of writing rgb(). The hexadecimal color (or hex color) begins with a hash token (#) and then a hexadecimal number, which means it goes from 0 to 9 and then skips to letters a to f (a being 10, b being 11, and so on, up to f for 15).
In the hexadecimal color system, the 6-digit style is done in pairs. Each pair represents red (RR), blue (BB), and green (GG).
Each value in the pair can go from 00 to FF, which it’s equivalent to 255 in rgb().
Notice how I used caps for the letters (F) and not lowercase letters like I did previously? Well, that’s because hexadecimals are not case-sensitive in CSS, so you don’t have to worry about uppercase or lowercase letters when dealing with hexadecimal colors.
- 3-digit hexadecimal. The 3-digit hexadecimal system is a shorter way of writing the 6-digit hexadecimal system, where each value represents the color’s redness, greenness, and blueness, respectively
In reality, each value in the 3-digit system is duplicated and then translated to a visible color
.element { color: #abc; /* Equals #AABBCC */ }BUT, this severely limits the colors you can set. What if I want to target the color 213 in the red space, or how would I get a blue of value 103? It’s impossible. That’s why you can only get a total number of 4,096 colors here as opposed to the 17 million in the 6-digit notation. Still, if you want a fast way of getting a certain color in hexadecimal without having to worry about the millions of other colors, use the 3-digit notation.
- 4-digit hexadecimal. This is similar to the 3-digit hexadecimal notation except it includes the optional alpha value for opacity. It’s a shorter way of writing the 8-digit hexadecimal which also means that all values here are repeated once during color translation.
For the alpha value, 0 represents 00 (a fully transparent color) and F represents FF (a fully opaque color).
- 6-digit hexadecimal. The 6-digit hexadecimal system just specifies a hexadecimal color’s redness, blueness, and greenness without its alpha value for color opacity.
- 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.
Both hsl() and rgb() live in the sRGB space, but they access colors differently. And while the consensus is that hsl() is far more intuitive than rgb(), it all boils down to your preference.
hsl() takes three values: h, s, and l, which set its hue, saturation, and lightness, respectively.
- The hue sets the base color and represents a direction in the color wheel, so it’s written in angles from 0deg to 360deg.
- The saturation sets how much of the base color is present and goes from 0 (or 0%) to 100 (or 100%).
- The lightness represents how close to white or black the color gets.
One cool thing: the hue angle goes from (0deg–360deg), but we might as well use negative angles or angles above 360deg, and they will circle back to the right hue. Especially useful for infinite color animation. Pretty neat, right?
Plus, you can easily get a complementary color from the opposite angle (i.e., adding 180deg to the current hue) on the color wheel.
/* Current color */ .element { color: hsl(120deg 40 60 / 0.8); } /* Complementary color */ .element { color: hsl(300deg 40 60 / 0.8); }You want to combine the two syntax formats like in rgb(), yes? That’s also a no-no. It won’t work.
/* This would not work */ .element { color: hsl(130deg, 50, 20 / 0.5); } /* Neither will this */ .element { color: hsl(130deg 50 20, 0.5); } /* Or this */ .element { color: hsl(130deg 50, 20 / 0.5); }Instead, stick to one of the syntaxes, like in rgb():
/* Valid (Modern syntax) */ .element { color: hsl(130deg 50 20 / 0.5); } /* Valid (Modern syntax) */ .element { color: hsl(130deg, 50, 20, 0.5); } CodePen Embed Fallback The hsla() functionhsla() is essentially the same with hsl(). It uses three values to represent its color’s hue (h), saturation (s), and lightness (l), and yes (again), an alpha value for transparency (a). We can write hsla() in two different ways:
- Comma separated
- Space separated, with the alpha value written after a forward slash (/)
So, what’s the difference between hsla() and hsl()?
Breaking news (again)! They’re the same. hsl() and hsla() both:
- Support legacy and modern syntax
- Have the power to increase or reduce color opacity
So, why does hsla() still exist? Well, apart from being one of the mistakes of CSS, many applications on the web still use hsla() since there wasn’t a way to set opacity with hsl() when it was first conceived.
My advice: just use hsl(). It’s the same as hsla() but less to write.
/* This works */ .element-1 { color: hsla(120deg 80 90 / 0.8); } /* And this works too, so why not just use this? */ .element-2 { color: hsl(120deg 80 90 / 0.8); } The hwb() functionhwb() also uses hue for its first value, but instead takes two values for whiteness and blackness to determine how your colors will come out (and yes, it also does have an optional transparency value, a, just like rgb() and hsl()).
.element { color: hwb(80deg 20 50 / 0.5); }- The first value h is the same as the hue angle in hsl(), which represents the color position in the color wheel from 0 (or 0deg) to 360 (or 360deg).
- The second value, w, represents the whiteness in the color. It ranges from 0/0% (no white) to 100/100% (full white if b is 0).
- The third value, b, represents the blackness in the color. It ranges from 0/0% (no black) to 100/100% (fully black if w is 0).
- The final (optional) value is the alpha value, a, for the color’s opacity, preceded by a forward slash The value’s range is from 0.0 (or 0%) to 1.0 (or 100%).
Although this color function is barely used, it’s completely valid to use, so it’s up to personal preference.
CodePen Embed Fallback Named 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() functionFor this color function, we have three axes in a space-separated list to determine how the color is set.
.element { color: lab(50 20 20 / 0.9); }- The first value l represents the degree of whiteness to blackness of the color. Its range being 0/(or 0%) (black) to 100 (or 100%) (white).
- The second value a represents the degree of greenness to redness of the color. Its range being from -125/0% (green) to125 (or 100%) (red).
- The third value b represents the degree of blueness to yellowness of the color. Its range is also from -125 (or 0%) (blue) to 125 (or 100%) (red).
- The fourth and final value is its alpha value for color’s opacity. The value’s range is from 0.0 (or 0%) to 1.0 (or 100%).
This is useful when you’re trying to obtain new colors and provide support for screens that do support them. Actually, most screens and all major browsers now support lab(), so you should be good.
The CSS lab() color function’s a and b values are actually unbounded. Meaning they don’t technically have an upper or lower limit. But, at practice, those are their limits according to the spec.
CodePen Embed Fallback The lch() functionThe CSS lch() color function is said to be better and more intuitive than lab().
.element { color: lch(10 30 300deg); }They both use the same color space, but instead of having l, a, and b, lch uses lightness, chroma, and hue.
- The first value l represents the degree of whiteness to blackness of the color. Its range being 0 (or 0%) (black) to 100 (or 100%) (white).
- The second value c represents the color’s chroma (which is like saturation). Its range being from 0 (or 100%) to 150 or (or 100%).
- The third value h represents the color hue. The value’s range is also from 0 (or 0deg) to 360 (or 360deg).
- The fourth and final value is its alpha value for color’s opacity. The value’s range is from 0.0 (or 0%) to 1.0 (or 100%).
The CSS lch() color function’s chroma (c) value is actually unbounded. Meaning it doesn’t technically have an upper or lower limit. But, in practice, the chroma values above are the limits according to the spec.
CodePen Embed Fallback The OkLab Color 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() functionThe oklab() color function, just like lab(), generates colors according to their lightness, red/green axis, blue/yellow axis, and an alpha value for color opacity. Also, the values for oklab() are different from that of lab() so please watch out for that.
.element { color: oklab(30% 20% 10% / 0.9); }- The first value l represents the degree of whiteness to blackness of the color. Its range being 0 (or 0%) (black) to 0.1 (or 100%) (white).
- The second value a represents the degree of greenness to redness of the color. Its range being from -0.4 (or -100%) (green) to 0.4 (or 100%) (red).
- The third value b represents the degree of blueness to yellowness of the color. The value’s range is also from -0.4 (or 0%) (blue) to 0.4 (or -100%) (red).
- The fourth and final value is its alpha value for color’s opacity. The value’s range is from 0.0 (or 0%) to 1.0 (or 100%).
Again, this solves one of the issues in lab which is perceptual uniformity so if you’re looking to use a better alternative to lab, use oklab().
The CSS oklab() color function’s a and b values are actually unbounded. Meaning they don’t technically have an upper or lower limit. But, theoretically, those are the limits for the values according to the spec.
CodePen Embed Fallback The oklch() functionThe 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:
- The first value specifies the color space you want to access colors from. They can either be srgb, srgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020, xyz, xyz-d50, or xyz-d65
- The next three values (c1, c2, and c3) specifies the coordinates in the color space for the color ranging from 0.0 – 1.0.
- The sixth and final value is its alpha value for color’s opacity. The value’s range is from 0.0 (or 0%) to 1.0 (or 100%).
The color-mix() function mixes two colors of any type in a given color space. Basically, you can create an endless number of colors with this method and explore more options than you normally would with any other color function. A pretty powerful CSS function, I would say.
.element { color-mix(in oklab, hsl(40 20 60) 80%, red 20%); }You’re basically mixing two colors of any type in a color space. Do take note, the accepted color spaces here are different from the color spaces accepted in the color() function.
To use this function, you must be aware of these three values:
- The first value in colorspace specifies the interpolation method used to mix the colors, and these can be any of these 15 color spaces: srgb, srgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020, lab, oklab, xyz, xyz-d50, xyz-d65, hsl, hwb, lch, and oklch.
- The second and third values specifies an accepted color value and a percentage from 0% to 100%.
Here’s how it works. We have:
.element{ color-function(from origin-color c1 c2 c3 / alpha) }- The first value from is a mandatory keyword you must set to extract the color values from origin-color.
- The second value, origin-color, represents a color function or value or even another relative color that you want to get color from.
- The next three values, c1, c2, and c3 represent the current color function’s color channels and they correspond with the color function’s valid color values.
- The sixth and final value is its alpha value for color’s opacity. The value’s range is from 0.0 (or 0%) to 1.0 (or 100%) which either set from the origin-color or set manually,
Let’s take an example, say, converting a color from rgb() to lab():
.element { color: lab(from rgb(255 210 01 / 0.5) l a b / a); }All the values above will be translated to the corresponding colors in rgb(). Now, let’s take a look at another example where we convert a color from rgb() to oklch():
.element { color: oklch(from rgb(255 210 01 / 0.5) 50% 20% h / a); }Although, the l and c values were changed, the h and a would be taken from the original color, which in this case is a light yellowish color in rgb().
You can even be wacky and use math functions:
All CSS color functions support the relative color syntax. The relative color syntax, simply put, is a way to access other colors in another color function or value, then translating it to the values of the current color function. It goes “from <color>” to another.
.element { color: oklch(from rgb(255 210 01 / 0.5) calc(50% + var(--a)) calc(20% + var(--b)) h / a); }The relative color syntax is, however, different than the color() function in that you have to include the color space name and then fully write out the channels, like this:
.element { color: color(from origin-color colorspace c1 c2 c3 / alpha); }Remember, the color-mix() function is not a part of this. You can have relative color functions inside the color functions you want to mix, yes, but the relative color syntax is not available in color-mix() directly.
Color 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 references Color 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- Colors are in everything
- What’s a color space?
- The sRGB Color Space
- The CIELAB Color Space
- The OkLab Color Space
- The color() function
- The color-mix() function
- The Relative Color Syntax
- Color gradients
- Properties that support color values
- Almanac references
- Related articles and tutorials
CSS Color Functions originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
How to Keep Up With New CSS Features
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:
- New to the web platform in January
- New to the web platform in February
- New to the web platform in March
- New to the web platform in April
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.
A Better API for the Resize Observer
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 event
- MutationObserver replaces the now deprecated Mutation Events
- IntersectionObserver 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:
- Create a new observer with the new keyword: This observer takes in an observer function to execute.
- Do something with the observed changes: This is done via the observer function that is passed into the observer.
- Observe a specific element: By using the observe method.
- (Optionally) unobserve the element: By using the unobserve or disconnect method. (depending on which observer you’re using).
In practice, the above steps looks like this with the ResizeObserver.
// Step 1: Create a new observer const observer = new ResizeObserver(observerFn) // Step 2: Do something with the observed changes function observerFn (entries) { for (let entry of entries) { // Do something with entry } } // Step 3: Observe an element const element = document.querySelector('#some-element') observer.observe(element); // Step 4 (optional): Disconnect the observer observer.disconnect(element)This looks clear (and understandable) after the steps have been made clear. But it can look like a mess without the comments:
const observer = new ResizeObserver(observerFn) function observerFn (entries) { for (let entry of entries) { // Do something with entry } } const element = document.querySelector('#some-element') observer.observe(element);The good news is: I think we can improve the observer APIs and make them easier to use.
The Resize 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 function const node = document.querySelector('#some-element') const obs = resizeObserver(node)This is already much easier than writing all of the ResizeObserver code from scratch whenever you wish to use it.
Next, it’s quite obvious that we have to pass in an observer function to the callback. So, we can potentially do this:
// Not great function resizeObserver (node, observerFn) { const observer = new ResizeObserver(observerFn) observer.observe(node); }Since observerFn is always the same — it loops through the entries and acts on every entry — we could keep the observerFn and pass in a callback to perform tasks when the element is resized.
// Better function resizeObserver (node, callback) { const observer = new ResizeObserver(observerFn) function observerFn (entries) { for (let entry of entries) { callback(entry) } } observer.observe(node); }To use this, we can pass callback into the resizeObserver — this makes resizeObserver operate somewhat like an event listener which we are already familiar with.
// Usage of the resizeObserver function const node = document.querySelector('#some-element') const obs = resizeObserver(node, entry => { // Do something with each entry })We can make the callback slightly better by providing both entry and entries. There’s no performance hit for passing an additional variable so there’s no harm providing more flexibility here.
function resizeObserver (element, callback) { const observer = new ResizeObserver(observerFn) function observerFn (entries) { for (let entry of entries) { callback({ entry, entries }) } } observer.observe(element); }Then we can grab entries in the callback if we need to.
// Usage of the resizeObserver function // ... const obs = resizeObserver(node, ({ entry, entries }) => { // ... })Next, it makes sense to pass the callback as an option parameter instead of a variable. This will make resizeObserver more consistent with the mutationObserver and intersectionObserver functions that we will create in the next article.
function resizeObserver (element, options = {}) { const { callback } = options const observer = new ResizeObserver(observerFn) function observerFn (entries) { for (let entry of entries) { callback({ entry, entries }) } } observer.observe(element); }Then we can use resizeObserver like this.
const obs = resizeObserver(node, { callback ({ entry, entries }) { // Do something ... } }) The observer can take in an option 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:
function resizeObserver (node, options = {}) { // ... return { unobserve(node) { observer.unobserve(node) }, disconnect() { observer.disconnet() } } }Both methods do the same thing for what we have built so far since we only allowed resizeObserver to observe one element. So, pick whatever method you prefer to stop observing the element.
const obs = resizeObserver(node, { callback ({ entry, entries }) { // Do something ... } }) // Stops observing all elements obs.disconect()With this, we’ve completed the creation of a better API for the ResizeObserver — the resizeObserver function.
Code 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 once const subset = [items[0], items[1]] obs.unobserve(subset) Found this refactoring helpful?Refactoring is ultra useful (and important) because its a process that lets us create code that’s easy to use or maintain.
If you found this refactoring exercise useful, you might just love how I teach JavaScript to budding developers in my Learn JavaScript course.
In this course, you’ll learn to build 20 real-world components. For each component, we start off simple. Then we add features and you’ll learn to refactor along the way.
That’s it!
Hope you enjoyed this piece and see you in the next one.
A Better API for the Resize Observer originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Breaking Boundaries: Building a Tangram Puzzle With (S)CSS
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…
- To know how far CSS can be pushed in creating interactive UIs.
- To get better at my CSS skills.
- And it’s fun!
Fair enough?
Now, here’s the unsurprising truth: CSS isn’t exactly made for this. It’s not a logic language, and let’s be honest, it’s not particularly dynamic either. (Sure, we have CSS variables and some handy built-in functions now, hooray!)
In JavaScript, we naturally think in terms of functions, loops, conditions, objects, comparisons. We write logic, abstract things into methods, and eventually ship a bundle that the browser understands. And once it’s shipped? We rarely look at that final JavaScript bundle — we just focus on keeping it lean.
Now ask yourself: isn’t that exactly what Sass does for CSS?
Why should we hand-write endless lines of repetitive CSS when we can use mixins and functions to generate it — cleanly, efficiently, and without caring how many lines it takes, as long as the output is optimized?
So, we put it to the test and it turns out Sass can replace JavaScript, at least when it comes to low-level logic and puzzle behavior. With nothing but maps, mixins, functions, and a whole lot of math, we managed to bring our Tangram puzzle to life, no JavaScript required.
Let the (CSS-only) games begin! 🎉
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:
- A puzzle goal, which is the target shape the player has to recreate.
- A start button that shuffles all the pieces into a staging area.
- Each piece is clickable and interactive.
- The puzzle should let the user know when they get a piece wrong and also celebrate when they finish the puzzle.
I started by setting up the HTML structure, which is no small task, considering the number of elements involved.
- Each shape was given seven radio buttons. I chose radios over checkboxes to take advantage of their built-in exclusivity. Only one can be selected within the same group. This made it much easier to track which shape and state were currently active.
- The start button? Also a radio input. A checkbox could’ve worked too, but for the sake of consistency, I stuck with radios across the board.
- The puzzle map itself is just a plain old <div>, simple and effective.
- For rotation, we added eight radio buttons, each representing a 45-degree increment: 45°, 90°, 135°, all the way to 360°. These simulate rotation controls entirely in CSS.
- Every potential shadow position got its own radio button too. (Yes, it’s a lot, I know.)
- And to wrap it all up, I included a classic reset button inside a <form> using <button type="reset">, so players can easily start over at any point.
Given the sheer number of elements required, I used Pug to generate the HTML more efficiently. It was purely a convenience choice. It doesn’t affect the logic or behavior of the puzzle in any way.
Below is a sample of the compiled HTML. It might look overwhelming at first glance (and this is just a portion of it!), but it illustrates the structural complexity involved. This section is collapsed to not nuke your screen, but it can be expanded if you’d like to explore it.
Open HTML Code <div class="wrapper"> <div class="tanagram-box"></div> <div class="tanagram-box"></div> <form class="container"> <input class="hide_input start" type="checkbox" id="start" autofocus /> <button class="start-button" type="reset" id="restart">Restart</button> <label class="start-button" for="start">Start </label> <div class="shadow"> <input class="hide_input" type="radio" id="blueTriangle-tan" name="tan-active" /> <input class="hide_input" type="radio" id="yellowTriangle-tan" name="tan-active" /> <!-- Inputs for others tans --> <input class="hide_input" type="radio" id="rotation-reset" name="tan-active" /> <input class="hide_input" type="radio" id="rotation-45" name="tan-rotation" /> <input class="hide_input" type="radio" id="rotation-90" name="tan-rotation" /> <!--radios for 90, 225, 315, 360 --> <input class="hide_input" type="checkbox" id="yellowTriangle-tan-1-135" name="tan-rotation" /> <input class="hide_input" type="checkbox" id="yellowTriangle-tan-1-225" name="tan-rotation" /> <!-- radio for every possible shape shadows--> <label class="rotation rot" for="rotation-45" id="rot45">⟲</label> <label class="rotation rot" for="rotation-90" id="rot90">⟲</label> <!--radios for 90, 225, 315, 360 --> <label class="rotation" for="rotation-reset" id="rotReset">✘</label> <label class="blueTriangle tans" for="blueTriangle-tan" id="tanblueTrianglelab"></label> <div class="tans tan_blocked" id="tanblueTrianglelabRes"></div> <!-- labels for every tan and disabled div --> <label class="blueTriangle tans" for="blueTriangle-tan-1-90" id="tanblueTrianglelab-1-90"></label> <label class="blueTriangle tans" for="blueTriangle-tan-1-225" id="tanblueTrianglelab-1-225"></label> <!-- labels radio for every possible shape shadows--> <div class="shape"></div> </div> </form> <div class="tanagram-box"></div> <div class="tanagram-box"></div> <div class="tanagram-box"></div> <div class="tanagram-box"></div> <div class="tanagram-box"></div> </div> Creating maps for shape 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:
- the background-color for each tan,
- the clip-path coordinates that define their shapes,
- the initial position for each tan,
- the position of the blocking div (which disables interaction when a tan is selected),
- the shadow positions (coordinates for the tan’s silhouette displayed on the task board),
- the grid information, and
- the winning combinations — the exact target coordinates for each tan, marking the correct solution.
You can see this in action on CodePen, where these maps drive the actual look and behavior of each puzzle piece. At this point, there’s no visible change in the preview. We’ve simply prepared and stored the data for later use.
CodePen Embed Fallback Using mixins to read from 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 Fallback Start 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:
#blueTriangle-tan:checked ~ #rotation-45:checked ~ #tanblueTrianglelab { transform: translate(-6vmin,-37vmin) rotate(45deg); } #blueTriangle-tan:checked ~ #rotation-45:checked ~ #tanblueTrianglelabRes { visibility: hidden; }OK, the following mixins use the set-clip-path and set-rotation mixins. They contain all the information about the tans and their behavior in relation to which tan is clicked and which rotation is selected, as well as their positions (as defined in the second mixin).
@mixin generate-tan-shapes-and-interactions($tansShapes) { // Applies styling logic and UI interactions for each individual tan shape from the $tansShapes map. @each $tanName, $values in $tansShapes{ $color: color.scale(map.get($values, color), $lightness: 10%); $angles: (45, 90, 135, 180, 225, 270, 315, 360); @include set-tan-clip-path($tanName, $values); ##{$tanName}-tan:checked{ & ~ #tan#{$tanName}Res{ visibility:hidden; } & ~ #tan#{$tanName}lab{opacity: 1 !important;background: #{$color};cursor:auto;} @each $key in map.keys($tansShapes){ & ~ #tan#{$tanName}Res:checked ~ #tan#{$key}labRes{visibility: visible;} } & ~ #rot45{display: flex;visibility: visible;} & ~ #rotReset{ transform: translate(get-coordinates($values, exit-mode-btn-position,',', vmin)); } @include set-tan-rotation-states($tanName, $values, $angles, $color); } } } @mixin set-initial-tan-position($tansShapes) { // This mixin sets the initial position and transformation for both the interactive (`lab`) and shadow (`labRes`) versions // of each tan shape, based on coordinates provided in the $tansShapes map. @each $tanName, $values in $tansShapes{ & ~ .shadow #tan#{$tanName}lab{ transform-origin: get-coordinates($values, transform-origin,' ' ,vmin); transform: translate( get-coordinates($values,tan-position,',', vmin)) rotate(360deg) ; cursor: pointer; } & ~ .shadow #tan#{$tanName}labRes{ visibility:hidden; transform: translate(get-coordinates($values,diable-lab-position,',',vmin)); } } } CodePen Embed 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:
#blueTriangle-tan:checked ~ #tanblueTrianglelab-1-360 { visibility: visible; background-color: #53a0e0; opacity: 0.2; z-index: 2; transform-origin: 4.17vmin 12.5vmin; transform: translate(4.7vmin,13.5vmin) rotate(360deg); }This next mixin is tied to the previous one and manages when and how the tan shadows appear while their parent tan is being rotated using the button. It listens for the current rotation angle and checks whether there are any shadow positions defined for that specific angle. If there are, it displays them; if not — no shadows!
@mixin render-possible-positions-by-rotation { // This mixin applies rotation to each tan shape. It loops through each tan, calculates its possible positions for each angle, and handles visibility and transformation. // It ensures that rotation is applied correctly, including handling the transitions between various tan positions and visibility states. @each $tanName, $values in $tansShapes{ $possiblePositions: map.get($values, poss-positions); $possibleTansColor: map.get($values, color); $validPosition: get-coordinates($values, correct-position,',' ,vmin); $transformOrigin: get-coordinates($values,transform-origin,' ' ,vmin); $rotResPosition: get-coordinates($values,exit-mode-btn-position ,',' ,vmin ); $angle: 0; @for $i from 1 through 8{ $angle: $i * 45; $nextAngle: if($angle + 45 > 360, 45, $angle + 45); @include render-position-feedback-on-task($tanName,$angle, $possiblePositions,$possibleTansColor, #{$tanName}-tan, $validPosition,$transformOrigin, $rotResPosition); ##{$tanName}-tan{ @include render-possible-tan-positions($tanName,$angle, $possiblePositions,hidden, $possibleTansColor, #{$tanName}-tan,$transformOrigin) } ##{$tanName}-tan:checked{ @include render-possible-tan-positions($tanName,360, $possiblePositions,visible, $possibleTansColor, #{$tanName}-tan,$transformOrigin); & ~ #rotation-#{$angle}:checked { @include render-possible-tan-positions($tanName,360, $possiblePositions,hidden, $possibleTansColor, #{$tanName}-tan,$transformOrigin); & ~ #tan#{$tanName}lab{transform:translate( get-coordinates($values,tan-position,',', vmin)) rotate(#{$angle}deg) ;} & ~ #tan#{$tanName}labRes{ visibility: hidden; } & ~ #rot#{$angle}{ visibility: hidden; } & ~ #rot#{$nextAngle}{ visibility: visible } @include render-possible-tan-positions($tanName,$angle, $possiblePositions,visible, $possibleTansColor, #{$tanName}-tan,$transformOrigin); } } } } } CodePen Embed 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.
Creating an Auto-Closing Notification With an HTML Popover
The HTML popover attribute transforms elements into top-layer elements that can be opened and closed with a button or JavaScript. Most popovers can be light-dismissed, closing when the user clicks or taps outside the popup. Currently, HTML popover lacks built-in auto-close functionality, but it’s easy to add. Auto closing popups are useful for user interfaces like banner notifications — the new-message alerts in phones, for instance.
A picture demo, is worth a thousand words, right? Click on the “Add to my bookmarks” button in the following example. It triggers a notification that dismisses itself after a set amount of time.
CodePen Embed Fallback Let’s start with the 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.
<div popover="manual" id="pop">Bookmarked!</div>A manual popover simply means it cannot be light-dismissed by clicking outside the element. As a result, we have to hide, show, or toggle the popover’s visibility ourselves explicitly with either buttons or JavaScript. Let’s use a semantic HTML button.
<button popovertarget="pop" popovertargetaction="show"> Add to my bookmarks </button> <div popover="manual" id="pop">Bookmarked!</div>The popovertarget and popovertargetaction attributes are the final two ingredients, where popovertarget links the button to the popover element and popovertargetaction ensures that the popover is show-n when the button is clicked.
Hiding the popover with a CSS 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.
<noscript> <div popover="auto" id="pop">Bookmarked!</div> </noscript> <div popover="manual" id="pop">Bookmarked!</div> <!-- goes where <head> element's descendants go --> <noscript> <style> [popover] { transition: none; height: 1lh; } </style> </noscript> When to use this method?Another way to implement all of this would be to use setTimeout() to create a delay before closing the popover in JavaScript when the button is clicked, then adding a class to the popover element to trigger the transition effect. That way, no observer is needed.
With the method covered in this post, the delay can be set and triggered in CSS itself, thanks to @starting-style and transition-delay — no extra class required! If you prefer to implement the delay through CSS itself, then this method works best. The JavaScript will catch up to the change CSS makes at the time CSS defines, not the other way around.
Creating an Auto-Closing Notification With an HTML Popover originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Better CSS Shapes Using shape() — Part 3: Curves
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()- Lines and Arcs
- More on Arcs
- Curves (you are here!)
- Close and Move
This command adds a Bézier curve between two points by specifying control points. We can either have one control point and create a Quadratic curve or two control points and create a Cubic curve.
Bézier, Quadratic, Cubic, control points? What?!
For many of you, that definition is simply unclear, or even useless! You can spend a few minutes reading about Bézier curves but is it really worth it? Probably not, unless your job is to create shapes all the day and you have a solid background in geometry.
We already have cubic-bezier() as an easing function for animations but, honestly, who really understands how it works? We either rely on a generator to get the code or we read a “boring” explanation that we forget in two minutes. (I have one right here by the way!)
Don’t worry, this article will not be boring as I will mostly focus on practical examples and more precisely the use case of rounding the corners of irregular shapes. Here is a figure to illustrate a few examples of Bézier curves.
The blue dots are the starting and ending points (let’s call them A and B) and the black dots are the control points. And notice how the curve is tangent to the dashed lines illustrated in red.
In this article, I will consider only one control point. The syntax will follow this pattern:
clip-path: shape( from Xa Ya, curve to Xb Yb with Xc Yc ); arc command vs. curve 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 Fallback Let’s draw more shapes!Now that we have a good overview of the curve command, let’s consider more complex shapes where arc won’t help us round the corners and the only solution is to draw curves instead. Considering that each shape is unique, so I will focus on the technique rather than the code itself.
Slanted 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 Fallback Arrow-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()- Lines and Arcs
- More on Arcs
- Curves (you are here!)
- Close and Move
Better CSS Shapes Using shape() — Part 3: Curves originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Exploring the CSS contrast-color() Function… a Second Time
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 is out!
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()
- Scroll-driven animations
- Container scroll-state queries
- CSS Carousels
- text-box-edge and text-box-trim
- field-sizing
- ::target-text
- @function
- display: contents
- Advanced attr()
- 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.
Getting Creative With HTML Dialog
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 Fallback ConclusionWe 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.
What We Know (So Far) About CSS Reading Order
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-flowreading-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-visual
- flex-flow
- grid-rows
- grid-columns
- grid-order
- source-order
Let’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> <a>1</a> <a>2</a> <a>3</a> <a>4</a> <a>5</a> </div> 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> <a>1</a> <a>2</a> <a>3</a> <a>4</a> <a>5</a> <a>6</a> <a>7</a> <a>8</a> <a>9</a> <a>10</a> <a>11</a> <a>12</a> </div> 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-orderreading-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.
New business wanted
Last week Krijn and I decided to cancel performance.now() 2021. Although it was the right decision it leaves me in financially fairly dire straits. So I’m looking for new jobs and/or donations.
Even though the Corona trends in NL look good, and we could probably have brought 350 people together in November, we cannot be certain: there might be a new flare-up. More serious is the fact that it’s very hard to figure out how to apply the Corona checks Dutch government requires, especially for non-EU citizens. We couldn’t figure out how UK and US people should be tested, and for us that was the straw that broke the camel’s back. Cancelling the conference relieved us of a lot of stress.
Still, it also relieved me of a lot of money. This is the fourth conference in a row we cannot run, and I have burned through all my reserves. That’s why I thought I’d ask for help.
So ...
Has QuirksMode.org ever saved you a lot of time on a project? Did it advance your career? If so, now would be a great time to make a donation to show your appreciation.
I am trying my hand at CSS coaching. Though I had only few clients so far I found that I like it and would like to do it more. As an added bonus, because I’m still writing my CSS for JavaScripters book I currently have most of the CSS layout modules in my head and can explain them straight away — even stacking contexts.
Or if there’s any job you know of that requires a technical documentation writer with a solid knowledge of web technologies and the browser market, drop me a line. I’m interested.
Anyway, thanks for listening.
position: sticky, draft 1
I’m writing the position: sticky part of my book, and since I never worked with sticky before I’m not totally sure if what I’m saying is correct.
This is made worse by the fact that there are no very clear tutorials on sticky. That’s partly because it works pretty intuitively in most cases, and partly because the details can be complicated.
So here’s my draft 1 of position: sticky. There will be something wrong with it; please correct me where needed.
The inset properties are top, right, bottom and left. (I already introduced this terminology earlier in the chapter.)
h3,h4,pre {clear: left} section.scroll-container { border: 1px solid black; width: 300px; height: 250px; padding: 1em; overflow: auto; --text: 'scroll box'; float: left; clear: left; margin-right: 0.5em; margin-bottom: 1em; position: relative; font-size: 1.3rem; } .container,.outer-container { border: 1px solid black; padding: 1em; position: relative; --text: 'container'; } .outer-container { --text: 'outer container'; } :is(.scroll-container,.container,.outer-container):before { position: absolute; content: var(--text); top: 0.2em; left: 0.2em; font-size: 0.8rem; } section.scroll-container h2 { position: sticky; top: 0; background: white; margin: 0 !important; color: inherit !important; padding: 0.5em !important; border: 1px solid; font-size: 1.4rem !important; } .nowrap p { white-space: nowrap; } Introductionposition: sticky is a mix of relative and fixed. A sticky box takes its normal position in the flow, as if it had position: relative, but if that position scrolls out of view the sticky box remains in a position defined by its inset properties, as if it has position: fixed. A sticky box never escapes its container, though. If the container start or end scrolls past the sticky box abandons its fixed position and sticks to the top or the bottom of its container.
It is typically used to make sure that headers remain in view no matter how the user scrolls. It is also useful for tables on narrow screens: you can keep headers or the leftmost table cells in view while the user scrolls.
Scroll box and containerA sticky box needs a scroll box: a box that is able to scroll. By default this is the browser window — or, more correctly, the layout viewport — but you can define another scroll box by setting overflow on the desired element. The sticky box takes the first ancestor that could scroll as its scroll box and calculates all its coordinates relative to it.
A sticky box needs at least one inset property. These properties contain vital instructions, and if the sticky box doesn’t receive them it doesn’t know what to do.
A sticky box may also have a container: a regular HTML element that contains the sticky box. The sticky box will never be positioned outside this container, which thus serves as a constraint.
The first example shows this set-up. The sticky <h2> is in a perfectly normal <div>, its container, and that container is in a <section> that is the scroll box because it has overflow: auto. The sticky box has an inset property to provide instructions. The relevant styles are:
section.scroll-container { border: 1px solid black; width: 300px; height: 300px; overflow: auto; padding: 1em; } div.container { border: 1px solid black; padding: 1em; } section.scroll-container h2 { position: sticky; top: 0; } The rules Sticky headerRegular content
Regular content
Regular content
Regular content
Regular content
Regular content
Regular content
Content outside container
Content outside container
Content outside container
Content outside container
Content outside container
Content outside container
Now let’s see exactly what’s going on.
A sticky box never escapes its containing box. If it cannot obey the rules that follow without escaping from its container, it instead remains at the edge. Scroll down until the container disappears to see this in action.
A sticky box starts in its natural position in the flow, as if it has position: relative. It thus participates in the default flow: if it becomes higher it pushes the paragraphs below it downwards, just like any other regular HTML element. Also, the space it takes in the normal flow is kept open, even if it is currently in fixed position. Scroll down a little bit to see this in action: an empty space is kept open for the header.
A sticky box compares two positions: its natural position in the flow and its fixed position according to its inset properties. It does so in the coordinate frame of its scroll box. That is, any given coordinate such as top: 20px, as well as its default coordinates, is resolved against the content box of the scroll box. (In other words, the scroll box’s padding also constrains the sticky box; it will never move up into that padding.)
A sticky box with top takes the higher value of its top and its natural position in the flow, and positions its top border at that value. Scroll down slowly to see this in action: the sticky box starts at its natural position (let’s call it 20px), which is higher than its defined top (0). Thus it rests at its position in the natural flow. Scrolling up a few pixels doesn’t change this, but once its natural position becomes less than 0, the sticky box switches to a fixed layout and stays at that position.
The sticky box has bottom: 0
Regular content
Regular content
Regular content
Regular content
Regular content
Regular content
Sticky headerContent outside container
Content outside container
Content outside container
Content outside container
Content outside container
Content outside container
It does the same for bottom, but remember that a bottom is calculated relative to the scroll box’s bottom, and not its top. Thus, a larger bottom coordinate means the box is positioned more to the top. Now the sticky box compares its default bottom with the defined bottom and uses the higher value to position its bottom border, just as before.
With left, it uses the higher value of its natural position and to position its left border; with right, it does the same for its right border, bearing in mind once more that a higher right value positions the box more to the left.
If any of these steps would position the sticky box outside its containing box it takes the position that just barely keeps it within its containing box.
Details Sticky headerVery, very long line of content to stretch up the container quite a bit
Regular content
Regular content
Regular content
Regular content
Regular content
Regular content
Content outside container
Content outside container
Content outside container
Content outside container
Content outside container
Content outside container
Content outside container
The four inset properties act independently of one another. For instance the following box will calculate the position of its top and left edge independently. They can be relative or fixed, depending on how the user scrolls.
p.testbox { position: sticky; top: 0; left: 0; }Content outside container
Content outside container
Content outside container
Content outside container
Content outside container
The sticky box has top: 0; bottom: 0
Regular content
Regular content
Regular content
Regular content
Sticky headerRegular content
Regular content
Regular content
Regular content
Regular content
Content outside container
Content outside container
Content outside container
Content outside container
Content outside container
Setting both a top and a bottom, or both a left and a right, gives the sticky box a bandwidth to move in. It will always attempt to obey all the rules described above. So the following box will vary between 0 from the top of the screen to 0 from the bottom, taking its default position in the flow between these two positions.
p.testbox { position: sticky; top: 0; bottom: 0; } No containerRegular content
Regular content
Sticky headerRegular content
Regular content
Regular content
Regular content
Regular content
Regular content
Regular content
Regular content
Regular content
So far we put the sticky box in a container separate from the scroll box. But that’s not necessary. You can also make the scroll box itself the container if you wish. The sticky element is still positioned with respect to the scroll box (which is now also its container) and everything works fine.
Several containers Sticky headerRegular content
Regular content
Regular content
Regular content
Regular content
Regular content
Regular content
Content outside container
Content outside container
Content outside outer container
Content outside outer container
Or the sticky item can be several containers removed from its scroll box. That’s fine as well; the positions are still calculated relative to the scroll box, and the sticky box will never leave its innermost container.
Changing the scroll box Sticky headerThe container has overflow: auto.
Regular content
Regular content
Regular content
Regular content
Regular content
Regular content
Content outside container
Content outside container
Content outside container
One feature that catches many people (including me) unaware is giving the container an overflow: auto or hidden. All of a sudden it seems the sticky header doesn’t work any more.
What’s going on here? An overflow value of auto, hidden, or scroll makes an element into a scroll box. So now the sticky box’s scroll box is no longer the outer element, but the inner one, since that is now the closest ancestor that is able to scroll.
The sticky box appears to be static, but it isn’t. The crux here is that the scroll box could scroll, thanks to its overflow value, but doesn’t actually do so because we didn’t give it a height, and therefore it stretches up to accomodate all of its contents.
Thus we have a non-scrolling scroll box, and that is the root cause of our problems.
As before, the sticky box calculates its position by comparing its natural position relative to its scroll box with the one given by its inset properties. Point is: the sticky box doesn’t scroll relative to its scroll box, so its position always remains the same. Where in earlier examples the position of the sticky element relative to the scroll box changed when we scrolled, it no longer does so, because the scroll box doesn’t scroll. Thus there is no reason for it to switch to fixed positioning, and it stays where it is relative to its scroll box.
The fact that the scroll box itself scrolls upward is irrelevant; this doesn’t influence the sticky box in the slightest.
Sticky headerRegular content
Regular content
Regular content
Regular content
Regular content
Regular content
Regular content
Content outside container
Content outside container
Content outside container
Content outside container
Content outside container
Content outside container
One solution is to give the new scroll box a height that is too little for its contents. Now the scroll box generates a scrollbar and becomes a scrolling scroll box. When we scroll it the position of the sticky box relative to its scroll box changes once more, and it switches from fixed to relative or vice versa as required.
Minor itemsFinally a few minor items:
- It is no longer necessary to use position: -webkit-sticky. All modern browsers support regular position: sticky. (But if you need to cater to a few older browsers, retaining the double syntax doesn’t hurt.)
- Chrome (Mac) does weird things to the borders of the sticky items in these examples. I don’t know what’s going on and am not going to investigate.
Breaking the web forward
Safari is holding back the web. It is the new IE, after all. In contrast, Chrome is pushing the web forward so hard that it’s starting to break. Meanwhile web developers do nothing except moan and complain. The only thing left to do is to pick our poison.
blockquote { font-size: inherit; font-family: inherit; } blockquote p { font-size: inherit; font-family: inherit; } Safari is the new IERecently there was yet another round of “Safari is the new IE” stories. Once Jeremy’s summary and a short discussion cleared my mind I finally figured out that Safari is not IE, and that Safari’s IE-or-not-IE is not the worst problem the web is facing.
Perry Sun argues that for developers, Safari is crap and outdated, emulating the old IE of fifteen years ago in this respect. He also repeats the theory that Apple is deliberately starving Safari of features in order to protect the app store, and thus its bottom line. We’ll get back to that.
The allegation that Safari is holding back web development by its lack of support for key features is not new, but it’s not true, either. Back fifteen years ago IE held back the web because web developers had to cater to its outdated technology stack. “Best viewed with IE” and all that. But do you ever see a “Best viewed with Safari” notice? No, you don’t. Another browser takes that special place in web developers’ hearts and minds.
Chrome is the new IE, but in reverseJorge Arango fears we’re going back to the bad old days with “Best viewed in Chrome.” Chris Krycho reinforces this by pointing out that, even though Chrome is not the standard, it’s treated as such by many web developers.
“Best viewed in Chrome” squares very badly with “Safari is the new IE.” Safari’s sad state does not force web developers to restrict themselves to Safari-supported features, so it does not hold the same position as IE.
So I propose to lay this tired old meme to rest. Safari is not the new IE. If anything it’s the new Netscape 4.
Meanwhile it is Chrome that is the new IE, but in reverse.
Break the web forwardBack in the day, IE was accused of an embrace, extend, and extinguish strategy. After IE6 Microsoft did nothing for ages, assuming it had won the web. Thanks to web developers taking action in their own name for the first (and only) time, IE was updated once more and the web moved forward again.
Google learned from Microsoft’s mistakes and follows a novel embrace, extend, and extinguish strategy by breaking the web and stomping on the bits. Who cares if it breaks as long as we go forward. And to hell with backward compatibility.
Back in 2015 I proposed to stop pushing the web forward, and as expected the Chrome devrels were especially outraged at this idea. It never went anywhere. (Truth to tell: I hadn’t expected it to.)
I still think we should stop pushing the web forward for a while until we figure out where we want to push the web forward to — but as long as Google is in charge that won’t happen. It will only get worse.
On alertA blog storm broke out over the decision to remove alert(), confirm() and prompt(), first only the cross-origin variants, but eventually all of them. Jeremy and Chris Coyier already summarised the situation, while Rich Harris discusses the uses of the three ancient modals, especially when it comes to learning JavaScript.
With all these articles already written I will only note that, if the three ancient modals are truly as horrendous a security issue as Google says they are it took everyone a bloody long time to figure that out. I mean, they turn 25 this year.
Although it appears Firefox and Safari are on board with at least the cross-origin part of the proposal, there is no doubt that it’s Google that leads the charge.
From Google’s perspective the ancient modals have one crucial flaw quite apart from their security model: they weren’t invented there. That’s why they have to be replaced by — I don’t know what, but it will likely be a very complicated API.
Complex systems and arrogant priests rule the webThus the new embrace, extend, and extinguish is breaking backward compatibility in order to make the web more complicated. Nolan Lawson puts it like this:
we end up with convoluted specs like Service Worker that you need a PhD to understand, and yet we still don't have a working <dialog> element.
In addition, Google can be pretty arrogant and condescending, as Chris Ferdinandi points out.
The condescending “did you actually read it, it’s so clear” refrain is patronizing AF. It’s the equivalent of “just” or “simply” in developer documentation.
I read it. I didn’t understand it. That’s why I asked someone whose literal job is communicating with developers about changes Chrome makes to the platform.
This is not isolated to one developer at Chrome. The entire message thread where this change was surfaced is filled with folks begging Chrome not to move forward with this proposal because it will break all-the-things.
If you write documentation or a technical article and nobody understands it, you’ve done a crappy job. I should know; I’ve been writing this stuff for twenty years.
Extend, embrace, extinguish. And use lots of difficult words.
Patience is a virtueAs a reaction to web dev outcry Google temporarily halted the breaking of the web. That sounds great but really isn’t. It’s just a clever tactical move.
I saw this tactic in action before. Back in early 2016 Google tried to break the de-facto standard for the mobile visual viewport that I worked very hard to establish. I wrote a piece that resonated with web developers, whose complaints made Google abandon the plan — temporarily. They tried again in late 2017, and I again wrote an article, but this time around nobody cared and the changes took effect and backward compatibility was broken.
So the three ancient modals still have about 12 to 18 months to live. Somewhere in late 2022 to early 2023 Google will try again, web developers will be silent, and the modals will be gone.
The pursuit of appinessBut why is Google breaking the web forward at such a pace? And why is Apple holding it back?
Safari is kept dumb to protect the app store and thus revenue. In contrast, the Chrome team is pushing very hard to port every single app functionality to the browser. Ages ago I argued we should give up on this, but of course no one listened.
When performing Valley Kremlinology, it is useful to see Google policies as stemming from a conflict between internal pro-web and anti-web factions. We web developers mainly deal with the pro-web faction, the Chrome devrel and browser teams. On the other hand, the Android team is squarely in the anti-web camp.
When seen in this light the pro-web camp’s insistence on copying everything appy makes excellent sense: if they didn’t Chrome would lag behind apps and the Android anti-web camp would gain too much power. While I prefer the pro-web over the anti-web camp, I would even more prefer the web not to be a pawn in an internal Google power struggle. But it has come to that, no doubt about it.
Solutions?Is there any good solution? Not really.
Jim Nielsen feels that part of the issue is the lack of representation of web developers in the standardization process. That sounds great but is proven not to work.
Three years ago Fronteers and I attempted to get web developers represented and were met with absolute disinterest. Nobody else cared even one shit, and the initiative sank like a stone.
So a hypothetical web dev representative in W3C is not going to work. Also, the organisational work would involve a lot of unpaid labour, and I, for one, am not willing to do it again. Neither is anyone else. So this is not the solution.
And what about Firefox? Well, what about it? Ten years ago it made a disastrous mistake by ignoring the mobile web for way too long, then it attempted an arrogant and uninformed come-back with Firefox OS that failed, and its history from that point on is one long slide into obscurity. That’s what you get with shitty management.
Pick your poisonSo Safari is trying to slow the web down. With Google’s move-fast-break-absofuckinglutely-everything axiom in mind, is Safari’s approach so bad?
Regardless of where you feel the web should be on this spectrum between Google and Apple, there is a fundamental difference between the two.
We have the tools and procedures to manage Safari’s disinterest. They’re essentially the same as the ones we deployed against Microsoft back in the day — though a fundamental difference is that Microsoft was willing to talk while Apple remains its old haughty self, and its “devrels” aren’t actually allowed to do devrelly things such as managing relations with web developers. (Don’t blame them, by the way. If something would ever change they’re going to be our most valuable internal allies — just as the IE team was back in the day.)
On the other hand, we have no process for countering Google’s reverse embrace, extend, and extinguish strategy, since a section of web devs will be enthusiastic about whatever the newest API is. Also, Google devrels talk. And talk. And talk. And provide gigs of data that are hard to make sense of. And refer to their proprietary algorithms that “clearly” show X is in the best interest of the web — and don’t ask questions! And make everything so fucking complicated that we eventually give up and give in.
So pick your poison. Shall we push the web forward until it’s broken, or shall we break it by inaction? What will it be? Privately, my money is on Google. So we should say goodbye to the old web while we still can.
Custom properties and @property
You’re reading a failed article. I hoped to write about @property and how it is useful for extending CSS inheritance considerably in many different circumstances. Alas, I failed. @property turns out to be very useful for font sizes, but does not even approach the general applicability I hoped for.
Grandparent-inheritingIt all started when I commented on what I thought was an interesting but theoretical idea by Lea Verou: what if elements could inherit the font size of not their parent, but their grandparent? Something like this:
div.grandparent { /* font-size could be anything */ } div.parent { font-size: 0.4em; } div.child { font-size: [inherit from grandparent in some sort of way]; font-size: [yes, you could do 2.5em to restore the grandparent's font size]; font-size: [but that's not inheriting, it's just reversing a calculation]; font-size: [and it will not work if the parent's font size is also unknown]; }Lea told me this wasn’t a vague idea, but something that can be done right now. I was quite surprised — and I assume many of my readers are as well — and asked for more information. So she wrote Inherit ancestor font-size, for fun and profit, where she explained how the new Houdini @property can be used to do this.
This was seriously cool. Also, I picked up a few interesting bits about how CSS custom properties and Houdini @property work. I decided to explain these tricky bits in simple terms — mostly because I know that by writing an explanation I myself will understand them better — and to suggest other possibilities for using Lea’s idea.
Alas, that last objective is where I failed. Lea’s idea can only be used for font sizes. That’s an important use case, but I had hoped for more. The reasons why it doesn’t work elsewhere are instructive, though.
Tokens and valuesLet’s consider CSS custom properties. What if we store the grandparent’s font size in a custom property and use that in the child?
div.grandparent { /* font-size could be anything */ --myFontSize: 1em; } div.parent { font-size: 0.4em; } div.child { font-size: var(--myFontSize); /* hey, that's the grandparent's font size, isn't it? */ }This does not work. The child will have the same font size as the parent, and ignore the grandparent. In order to understand why we need to understand how custom properties work. What does this line of CSS do?
--myFontSize: 1em;It sets a custom property that we can use later. Well duh.
Sure. But what value does this custom property have?
... errr ... 1em?
Nope. The answer is: none. That’s why the code example doesn’t work.
When they are defined, custom properties do not have a value or a type. All that you ordered the browsers to do is to store a token in the variable --myFontSize.
This took me a while to wrap my head around, so let’s go a bit deeper. What is a token? Let’s briefly switch to JavaScript to explain.
let myVar = 10;What’s the value of myVar in this line? I do not mean: what value is stored in the variable myVar, but: what value does the character sequence myVar have in that line of code? And what type?
Well, none. Duh. It’s not a variable or value, it’s just a token that the JavaScript engine interprets as “allow me to access and change a specific variable” whenever you type it.
CSS custom properties also hold such tokens. They do not have any intrinsic meaning. Instead, they acquire meaning when they are interpreted by the CSS engine in a certain context, just as the myVar token is in the JavaScript example.
So the CSS custom property contains the token 1em without any value, without any type, without any meaning — as yet.
You can use pretty any bunch of characters in a custom property definition. Browsers make no assumptions about their validity or usefulness because they don’t yet know what you want to do with the token. So this, too, is a perfectly fine CSS custom property:
--myEgoTrip: ppk;Browsers shrug, create the custom property, and store the indicated token. The fact that ppk is invalid in all CSS contexts is irrelevant: we haven’t tried to use it yet.
It’s when you actually use the custom property that values and types are assigned. So let’s use it:
background-color: var(--myEgoTrip);Now the CSS parser takes the tokens we defined earlier and replaces the custom property with them:
background-color: ppk;And only NOW the tokens are read and intrepreted. In this case that results in an error: ppk is not a valid value for background-color. So the CSS declaration as a whole is invalid and nothing happens — well, technically it gets the unset value, but the net result is the same. The custom property itself is still perfectly valid, though.
The same happens in our original code example:
div.grandparent { /* font-size could be anything */ --myFontSize: 1em; /* just a token; no value, no meaning */ } div.parent { font-size: 0.4em; } div.child { font-size: var(--myFontSize); /* becomes */ font-size: 1em; /* hey, this is valid CSS! */ /* Right, you obviously want the font size to be the same as the parent's */ /* Sure thing, here you go */ }In div.child he tokens are read and interpreted by the CSS parser. This results in a declaration font-size: 1em;. This is perfectly valid CSS, and the browsers duly note that the font size of this element should be 1em.
font-size: 1em is relative. To what? Well, to the parent’s font size, of course. Duh. That’s how CSS font-size works.
So now the font size of the child becomes the same as its parent’s, and browsers will proudly display the child element’s text in the same font size as the parent element’s while ignoring the grandparent.
This is not what we wanted to achieve, though. We want the grandparent’s font size. Custom properties — by themselves — don’t do what we want. We have to find another solution.
@propertyLea’s article explains that other solution. We have to use the Houdini @property rule.
@property --myFontSize { syntax: "<length>"; initial-value: 0; inherits: true; } div { border: 1px solid; padding: 1em; } div.grandparent { /* font-size could be anything */ --myFontSize: 1em; } div.parent { font-size: 0.4em; } div.child { font-size: var(--myFontSize); }Now it works. Wut? Yep — though only in Chrome so far.
@property --myFontSize { syntax: ""; initial-value: 0; inherits: true; } section.example { max-width: 500px; } section.example div { border: 1px solid; padding: 1em; } div.grandparent { font-size: 23px; --myFontSize: 1em; } div.parent { font-size: 0.4em; } div.child { font-size: var(--myFontSize); } This is the grandparent This is the parent This is the childWhat black magic is this?
Adding the @property rule changes the custom property --myFontSize from a bunch of tokens without meaning to an actual value. Moreover, this value is calculated in the context it is defined in — the grandfather — so that the 1em value now means 100% of the font size of the grandfather. When we use it in the child it still has this value, and therefore the child gets the same font size as the grandfather, which is exactly what we want to achieve.
(The variable uses a value from the context it’s defined in, and not the context it’s executed in. If, like me, you have a grounding in basic JavaScript you may hear “closures!” in the back of your mind. While they are not the same, and you shouldn’t take this apparent equivalency too far, this notion still helped me understand. Maybe it’ll help you as well.)
Unfortunately I do not quite understand what I’m doing here, though I can assure you the code snippet works in Chrome — and will likely work in the other browsers once they support @property.
Misson completed — just don’t ask me how.
SyntaxYou have to get the definition right. You need all three lines in the @property rule. See also the specification and the MDN page.
@property --myFontSize { syntax: "<length>"; initial-value: 0; inherits: true; }The syntax property tells browsers what kind of property it is and makes parsing it easier. Here is the list of possible values for syntax, and in 99% of the cases one of these values is what you need.
You could also create your own syntax, e.g. syntax: "ppk | <length>"
Now the ppk keyword and any sort of length is allowed as a value.
Note that percentages are not lengths — one of the many things I found out during the writing of this article. Still, they are so common that a special value for “length that may be a percentage or may be calculated using percentages” was created:
syntax: "<length-percentage>"Finally, one special case you need to know about is this one:
syntax: "*"MDN calls this a universal selector, but it isn’t, really. Instead, it means “I don’t know what syntax we’re going to use” and it tells browsers not to attempt to interpret the custom property. In our case that would be counterproductive: we definitely want the 1em to be interpreted. So our example doesn’t work with syntax: "*".
initial-value and inheritsAn initial-value property is required for any syntax value that is not a *. Here that’s simple: just give it an initial value of 0 — or 16px, or any absolute value. The value doesn’t really matter since we’re going to overrule it anyway. Still, a relative value such as 1em is not allowed: browsers don’t know what the 1em would be relative to and reject it as an initial value.
Finally, inherits: true specifies that the custom property value can be inherited. We definitely want the computed 1em value to be inherited by the child — that’s the entire point of this experiment. So we carefully set this flag to true.
Other use casesSo far this article merely rehashed parts of Lea’s. Since I’m not in the habit of rehashing other people’s articles my original plan was to add at least one other use case. Alas, I failed, though Lea was kind enough to explain why each of my ideas fails.
Percentage of what?Could we grandfather-inherit percentual margins and paddings? They are relative to the width of the parent of the element you define them on, and I was wondering if it might be useful to send the grandparent’s margin on to the child just like the font size. Something like this:
@property --myMargin { syntax: "<length-percentage>"; initial-value: 0; inherits: true; } div.grandparent { --myMargin: 25%; margin-left: var(--myMargin); } div.parent { font-size: 0.4em; } div.child { margin-left: var(--myMargin); /* should now be 25% of the width of the grandfather's parent */ /* but isn't */ }Alas, this does not work. Browsers cannot resolve the 25% in the context of the grandparent, as they did with the 1em, because they don’t know what to do.
The most important trick for using percentages in CSS is to always ask yourself: “percentage of WHAT?”
That’s exactly what browsers do when they encounter this @property definition. 25% of what? The parent’s font size? Or the parent’s width? (This is the correct answer, but browsers have no way of knowing that.) Or maybe the width of the element itself, for use in background-position?
Since browsers cannot figure out what the percentage is relative to they do nothing: the custom property gets the initial value of 0 and the grandfather-inheritance fails.
ColoursAnother idea I had was using this trick for the grandfather’s text colour. What if we store currentColor, which always has the value of the element’s text colour, and send it on to the grandchild? Something like this:
@property --myColor { syntax: "<color>"; initial-value: black; inherits: true; } div.grandparent { /* color unknown */ --myColor: currentColor; } div.parent { color: red; } div.child { color: var(--myColor); /* should now have the same color as the grandfather */ /* but doesn't */ }Alas, this does not work either. When the @property blocks are evaluated, and 1em is calculated, currentColor specifically is not touched because it is used as an initial (default) value for some inherited SVG and CSS properties such as fill. Unfortunately I do not fully understand what’s going on, but Tab says this behaviour is necessary, so it is.
Pity, but such is life. Especially when you’re working with new CSS functionalities.
ConclusionSo I tried to find more possbilities for using Lea’s trick, but failed. Relative units are fairly sparse, especially when you leave percentages out of the equation. em and related units such as rem are the only ones, as far as I can see.
So we’re left with a very useful trick for font sizes. You should use it when you need it (bearing in mind that right now it’s only supported in Chromium-based browsers), but extending it to other declarations is not possible at the moment.
Many thanks to Lea Verou and Tab Atkins for reviewing and correcting an earlier draft of this article.
Let’s talk about money
Let’s talk about money!
Let’s talk about how hard it is to pay small amounts online to people whose work you like and who could really use a bit of income. Let’s talk about how Coil aims to change that.
Taking a subscription to a website is moderately easy, but the person you want to pay must have enabled them. Besides, do you want to purchase a full subscription in order to read one or two articles per month?
Sending a one-time donation is pretty easy as well, but, again, the site owner must have enabled them. And even then it just gives them ad-hoc amounts that they cannot depend on.
Then there’s Patreon and Kickstarter and similar systems, but Patreon is essentially a subscription service while Kickstarter is essentially a one-time donation service, except that both keep part of the money you donate.
And then there’s ads ... Do we want small content creators to remain dependent on ads and thus support the entire ad ecosystem? I, personally, would like to get rid of them.
The problem today is that all non-ad-based systems require you to make conscious decisions to support someone — and even if you’re serious about supporting them you may forget to send in a monthly donation or to renew your subscription. It sort-of works, but the user experience can be improved rather dramatically.
That’s where Coil and the Web Monetization Standard come in.
Web MonetizationThe idea behind Coil is that you pay for what you consume easily and automatically. It’s not a subscription - you only pay for what you consume. It’s not a one-time donation, either - you always pay when you consume.
Payments occur automatically when you visit a website that is also subscribed to Coil, and the amount you pay to a single site owner depends on the time you spend on the site. Coil does not retain any of your money, either — everything goes to the people you support.
In this series of four articles we’ll take a closer look at the architecture of the current Coil implementation, how to work with it right now, the proposed standard, and what’s going to happen in the future.
OverviewSo how does Coil work right now?
Both the payer and the payee need a Coil account to send and receive money. The payee has to add a <meta> tag with a Coil payment pointer to all pages they want to monetize. The payer has to install the Coil extension in their browsers. You can see this extension as a polyfill. In the future web monetization will, I hope, be supported natively in all browsers.
Once that’s done the process works pretty much automatically. The extension searches for the <meta> tag on any site the user visits. If it finds one it starts a payment stream from payer to payee that continues for as long as the payer stays on the site.
The payee can use the JavaScript API to interact with the monetization stream. For instance, they can show extra content to paying users, or keep track of how much a user paid so far. Unfortunately these functionalities require JavaScript, and the hiding of content is fairly easy to work around. Thus it is not yet suited for serious business purposes, especially in web development circles.
This is one example of how the current system is still a bit rough around the edges. You’ll find more examples in the subsequent articles. Until the time browsers support the standard natively and you can determine your visitors’ monetization status server-side these rough bits will continue to exist. For the moment we will have to work with the system we have.
This article series will discuss all topics we touched on in more detail.
Start now!For too long we have accepted free content as our birthright, without considering the needs of the people who create it. This becomes even more curious for articles and documentation that are absolutely vital to our work as web developers.
Take a look at this list of currently-monetized web developer sites. Chances are you’ll find a few people whose work you used in the past. Don’t they deserve your direct support?
Free content is not a right, it’s an entitlement. The sooner we internalize this, and start paying independent voices, the better for the web.
The only alternative is that all articles and documentation that we depend on will written by employees of large companies. And employees, no matter how well-meaning, will reflect the priorities and point of view of their employer in the long run.
So start now.
In order to support them you should invest a bit of time once and US$5 per month permanently. I mean, that’s not too much to ask, is it?
ContinueI wrote this article and its sequels for Coil, and yes, I’m getting paid. Still, I believe in what they are doing, so I won’t just spread marketing drivel. Initially it was unclear to me exactly how Coil works. So I did some digging, and the remaining parts of this series give a detailed description of how Coil actually works in practice.
For now the other three articles will only be available on dev.to. I just published part 2, which gives a high-level overview of how Coil works right now. Part 3 will describe the meta tag and the JavaScript API, and in part 4 we’ll take a look at the future, which includes a formal W3C standard. Those parts will be published next week and the week after that.
