Reading List

The most recent articles from a list of feeds I subscribe to.

Custom properties with defaults: 3+1 strategies

When developing customizable components, one often wants to expose various parameters of the styling as custom properties, and form a sort of CSS API. This is still underutlized, but there are libraries, e.g. Shoelace, that already list custom properties alongside other parts of each component’s API (even CSS parts!).

Note: I’m using “component” here broadly, as any reusable chunk of HTML/CSS/JS, not necessarily a web component or framework component. What we are going to discuss applies to reusable chunks of HTML just as much as it does to “proper” web components.

Let’s suppose we are designing a certain button styling, that looks like this:

We want to support a --color custom property for creating color variations by setting multiple things internally:

.fancy-button {
	border: .1em solid var(--color);
	background: transparent;
	color: var(--color);
}

.fancy-button:hover {
	background: var(--color);
	color: white;
}

Note that with the code above, if no --color is set, the three declarations using it will be IACVT and thus we’ll get a nearly unstyled text-only button with no background on hover (transparent), no border on hover, and the default black text color (canvastext to be precise).

That’s no good! IT’s important that we set defaults. However, using the fallback parameter for this gets tedious, and WET:

.fancy-button {
	border: .1em solid var(--color, black);
	background: transparent;
	color: var(--color, black);
}

.fancy-button:hover {
	background: var(--color, black);
	color: white;
}

To avoid the repetition and still ensure --color always has a value, many people do this:

.fancy-button {
	--color: black;
	border: .1em solid var(--color);
	background: transparent;
	color: var(--color);
}

.fancy-button:hover {
	background: var(--color);
	color: white;
}

However, this is not ideal for a number of reasons:

  • It means that people cannot take advantage of inheritance to set --color on an ancestor.
  • It means that people need to use specificity that overrides your own rules to set these properties. In this case this may only be 0,1,0, but if your selectors are complex, it could end up being quite annoying (and introduce tight couplings, because developers should not need to know what your selectors are).

If you insist going that route, :where() can be a useful tool to reduce specificity of your selectors while having as fine grained selection criteria as you want. It’s also one of the features I proposed for CSS, so I’m very proud that it’s now supported everywhere. :where() won’t solve the inheritance problem, but at least it will solve the specificity problem.

What if we still use the fallback parameter and use a variable for the fallback?

.fancy-button {
	--color-initial: black;
	border: .1em solid var(--color, var(--color-initial));
	background: transparent;
	color: var(--color, var(--color-initial));
}

.fancy-button:hover {
	background: var(--color, var(--color-initial));
	color: white;
}

This works, and it has the advantage that people could even customize your default if they want to (though I cannot think of any use cases for that). But isn’t it so horribly verbose? What else could we do?

My preferred solution is what I call pseudo-private custom properties. You use a different property internally than the one you expose, which is set to the one you expose plus the fallback:

.fancy-button {
	--_color: var(--color, black);
	border: .1em solid var(--_color);
	background: transparent;
	color: var(--_color);
}

.fancy-button:hover {
	background: var(--_color);
	color: white;
}

I tend to use the same name prepended with an underscore. Some people may flinch at the idea of private properties that aren’t really private, but I will remind you that we’ve done this in JS for over 20 years (we only got real private properties fairly recently).

Bonus: Defaults via @property registration

If @property is fair game (it’s only supported in Chromium, but these days that still makes it supported in 70% of users’ browsers — which is a bit sad, but that’s another discussion), you could also set defaults that way:

@property --color {
	syntax: "<color>";
	inherits: true;
	initial-value: black;
}

.fancy-button {
	border: .1em solid var(--color);
	background: transparent;
	color: var(--color);
}

.fancy-button:hover {
	background: var(--color);
	color: white;
}

Registering your property has several benefits (e.g. it makes it animatable), but if you’re only registering it for the purposes of setting a default, this way has several drawbacks:

  • Property registration is global. Your component’s custom properties may clash with the host page’s custom properties, which is not great. The consequences of this can be quite dire, because @property fails silently, and the last one wins so you may just get the initial value of the host page’s property. In this case, that could very likely be transparent, with terrible results. And if your declaration is last and you get your own registered property, that means the rest of the page will also get yours, with equally potentially terrible results.
  • With this method you cannot set different initial values per declaration (although you usually don’t want to).
  • Not all custom property syntaxes can be described via @property yet.

Bonus: Customizable single-checkbox pure CSS switch

Just for the lulz, I made a switch (styling loosely inspired from Shoelace switch) that is just a regular <input type=checkbox> with a pretty extensive custom property API:

It is using the pseudo-private properties approach. Note how another bonus of this method is that there’s a little self-documentation right there about the component’s custom property API, even before any actual documentation is written.

As an aside, things like this switch make me wish it was possible to create web components that subclass existing elements. There is an existing — somewhat awkward — solution with the is attribute, but Apple is blocking it. The alternative is to use a web component with ElementInternals to make it form-associated and accessible and mirror all checkbox methods and properties, but that is way too heavyweight, and prone to breakage in the future, as native checkboxes add more methods. There is also a polyfill, but for a simple switch it may be a bit overkill. We really shouldn’t need to be painstakingly mirroring native elements to subclass them…

Enjoyed this article and want to learn more? I do teach courses on unlocking the full potential of CSS custom properties. You can watch my Frontend Masters Dynamic CSS course (currently in production), or attend my upcoming Smashing workshop.

Inherit ancestor font-size, for fun and profit

Reading Time: 7 minutes If you’ve been writing CSS for any length of time, you’re probably familiar with the em unit, and possibly the other type-relative units. We are going to refer to em for the rest of this post, but anything described works for all type-relative units. As you well know, em resolves to the current font size […]

Inherit ancestor font-size, for fun and profit

If you’ve been writing CSS for any length of time, you’re probably familiar with the em unit, and possibly the other type-relative units. We are going to refer to em for the rest of this post, but anything described works for all type-relative units. As you well know, em resolves to the current font size […]

Inherit ancestor font-size, for fun and profit

If you’ve been writing CSS for any length of time, you’re probably familiar with the em unit, and possibly the other type-relative units. We are going to refer to em for the rest of this post, but anything described works for all type-relative units.

As you well know, em resolves to the current font size on all properties except font-size, where it resolves to the parent font size. It can be quite useful for making scalable components that adapt to their context size.

However, I have often come across cases where you actually need to “circumvent” one level of this. Either you need to set font-size to the grandparent font size instead of the parent one, or you need to set other properties to the parent font size, not the current one.

If you’re already familiar with the problem and just want the solution, skip ahead. The next few paragraphs are for those thinking “but when would you ever need this?”

Sometimes, there are workarounds, and it’s just a matter of keeping DRY. For example, take a look at this speech bubble:

Note this in the CSS:

/* This needs to change every time the font-size changes: */
top: calc(100% + 1em / 2.5);
font-size: 250%;

Note that every time we change the font size we also need to adjust top. And ok, when they’re both defined in the same rule we can just delegate this to a variable:

--m: 2.5;
top: calc(100% + 1em / var(--m));
font-size: calc(var(--m) * 100%);

However, in the general case the font size may be defined elsewhere. For example, a third party author may want to override the emoji size, they shouldn’t also need to override anything else, our CSS should just adapt.

In other cases, it is simply not possible to multiply and divide by a factor and restore the ancestor font size. Most notably, when the current (or parent) font-size is set to 0 and we need to recover what it was one level up.

I’ve come across many instances of this in the 16 years I’ve been writing CSS. Admittedly, there were way more use cases pre-Flexbox and friends, but it’s still useful, as we will see. In fact, it was the latest one that prompted this post.

I needed to wrap <option> elements by a generic container for a library I’m working on. Let me stop you there, no, I could not just set classes on the options, I needed an actual container in the DOM.

As you can see in this pen, neither <div> nor custom elements work here: when included in the markup they are just discarded by the parser, and when inserted via script they are in the DOM, but the options they contain are not visible. The only elements that work inside a <select> are: <option>, <optgroup>, and script-supporting elements (currently <template> and <script>). Except <optgroup>, none of the rest renders any contents and thus, is not fit for my use case. It had to be <optgroup>, sadly.

However, using <optgroup>, even without a label attribute inserts an ugly gap in the select menu, where the label would have gone (pen):

(There were also gaps on the left of the labels, but we applied some CSS to remove them)

There appears to be no way to remove said gap.

Ideally, this should be fixed on the user agent level: Browsers should not generate a label box when there is no label attribute. However, I needed a solution now, not in the far future. There was no pseudo-element for targeting the generated label. The only solution that worked was along these lines ([pen](optgroup:not([label]) { display: contents; font-size: 0; }

optgroup:not([label])> * { font-size: 13.333px; })):
optgroup:not([label]) {
	font-size: 0;
}

optgroup:not([label]) > * {
	font-size: 13.333px;
}

The weird 13.333px value was taken directly from the Chrome UA stylesheet (as inspected). However, it is obviously flimsy, and will break any additional author styling. It would be far better if we could say “give me whatever 1em is on the grandparent”. Can we?

The solution

What if we could use custom properties to solve this? Our first attempt might look something like this:

select {
	--em: 1em;
}

optgroup:not([label]) {
	font-size: 0;
}

optgroup:not([label]) > * {
	font-size: var(--em);
}

However this is horribly broken:

All the options have disappeared!!

What on Earth happened here?!

By default, custom properties are just containers for CSS tokens.When they inherit, they inherit as specified, with only any var() references substituted and no other processing. This means that the 1em we specified inherits as the 1em token, not as whatever absolute length it happens to resolve to on select. It only becomes an absolute length at the point of usage, and this is whatever 1em would be there, i.e. 0. So all our options disappeared because we set their font size to 0!

If only we could make 1em resolve to an actual absolute length at the point of declaration and inherit as that, just like native properties that accept lengths?

Well, you’re in luck, because today we can!

You may be familiar with the @property rule as “the thing that allows us to animate custom properties”. However, it is useful for so much more than that.

If we register our custom property as a <length>, this makes the 1em resolve on the element we specified it on, and inherit as an absolute length! Let’s try this:

@property --em {
	syntax: "<length>";
	initial-value: 0;
	inherits: true;
}

select {
	--em: 1em;
}

optgroup:not([label]) {
	display: contents;
	font-size: 0;
}

optgroup:not([label]) > * {
	font-size: var(--em);
}

/* Remove Chrome gap */
:where(optgroup:not([label]) > option)::before {
	content: "";
}

And here is the same technique used for the speech bubble:

Fallback

This is all fine and dandy for the 68% (as of June 2021) of users that are using a browser that supports @property, but what happens in the remaining 32%? It’s not pretty:

We get the default behavior of an unregistered property, and thus none of our options show up! This is bad.

We should clearly either provide a fallback or conditionally apply these rules only in browsers that support @property.

We can easily detect @property support in JS and add a class to our root element:

if (window.CSSPropertyRule) {
	let root = document.documentElement;
	root.classList.add("supports-atproperty");
}

Then we can just use the descendant combinator:

:root.supports-atproperty optgroup:not([label]) {
	font-size: 0;
}

CSS-only fallback for @property

While the JS fallback works great, I couldn’t help but wonder if there’s a CSS only way.

My first thought was to use @supports:

@supports (--em: flugelhorn) {
	/* Does not support @property */
}

The theory was, if a browser supported any value to be assigned on a property registered as a <length>, surely it does not support property registration.

It turns out, registered properties do not validate their syntax at parse time, and thus are always valid for @supports. This is explained in the spec:

When parsing a page’s CSS, UAs commonly make a number of optimizations to help with both speed and memory.

One of those optimizations is that they only store the properties that will actually have an effect; they throw away invalid properties, and if you write the same property multiple times in a single declaration block, all but the last valid one will be thrown away. (This is an important part of CSS’s error-recovery and forward-compatibility behavior.)

This works fine if the syntax of a property never changes over the lifetime of a page. If a custom property is registered, however, it can change its syntax, so that a property that was previously invalid suddenly becomes valid.

The only ways to handle this are to either store every declaration, even those that were initially invalid (increasing the memory cost of pages), or to re-parse the entire page’s CSS with the new syntax rules (increasing the processing cost of registering a custom property). Neither of these are very desirable.

Further, UA-defined properties have their syntax determined by the version of the UA the user is viewing the page with; this is out of the page author’s control, which is the entire reason for CSS’s error-recovery behavior and the practice of writing multiple declarations for varying levels of support. A custom property, on the other hand, has its syntax controlled by the page author, according to whatever stylesheet or script they’ve included in the page; there’s no unpredictability to be managed. Throwing away syntax-violating custom properties would thus only be, at best, a convenience for the page author, not a necessity like for UA-defined properties.

Ok this is great, and totally makes sense, but what can we do? How can we provide a fallback?

It turns out that there is a way, but brace yourself, as it’s quite hacky. I’m only going to describe it for entertainment purposes, but I think for real usage, the JS way is far more straightforward, and it’s the one I’ll be using myself.

The main idea is to take advantage of the var() fallback argument of a second registered variable, that is registered as non-inheriting. We set it to the fallback value on an ancestor. If @property is supported, then this property will not be defined on the element of interest, since it does not inherit. Any other properties referencing it will be invalid at computed value time, and thus any var() fallbacks will apply. If @property is not supported, the property will inherit as normal and thus using it becomes our fallback.

Here is an example with a simple green/red test to illustrate this concept:

@property --test {
	syntax: "*";
	inherits: false;
}

html {
	--test: red;
}

body {
	background: var(--test, green);
}

And here is how we can use the same concept to provide a fallback for the <select> example:

@property --test {
	syntax: "*";
	inherits: false;
}

select {
	--test: 1em; /* fallback */
	--em: 1em;
}

optgroup:not([label]) {
	font-size: var(--test, 0);
}

Here is the finished demo.

Inherit ancestor font-size, for fun and profit

If you’ve been writing CSS for any length of time, you’re probably familiar with the em unit, and possibly the other type-relative units. We are going to refer to em for the rest of this post, but anything described works for all type-relative units.

As you well know, em resolves to the current font size on all properties except font-size, where it resolves to the parent font size. It can be quite useful for making scalable components that adapt to their context size.

However, I have often come across cases where you actually need to “circumvent” one level of this. Either you need to set font-size to the grandparent font size instead of the parent one, or you need to set other properties to the parent font size, not the current one.

If you’re already familiar with the problem and just want the solution, skip ahead. The next few paragraphs are for those thinking “but when would you ever need this?”

Sometimes, there are workarounds, and it’s just a matter of keeping DRY. For example, take a look at this speech bubble:

Note this in the CSS:

/* This needs to change every time the font-size changes: */
top: calc(100% + 1em / 2.5);
font-size: 250%;

Note that every time we change the font size we also need to adjust top. And ok, when they’re both defined in the same rule we can just delegate this to a variable:

--m: 2.5;
top: calc(100% + 1em / var(--m));
font-size: calc(var(--m) * 100%);

However, in the general case the font size may be defined elsewhere. For example, a third party author may want to override the emoji size, they shouldn’t also need to override anything else, our CSS should just adapt.

In other cases, it is simply not possible to multiply and divide by a factor and restore the ancestor font size. Most notably, when the current (or parent) font-size is set to 0 and we need to recover what it was one level up.

I’ve come across many instances of this in the 16 years I’ve been writing CSS. Admittedly, there were way more use cases pre-Flexbox and friends, but it’s still useful, as we will see. In fact, it was the latest one that prompted this post.

I needed to wrap <option> elements by a generic container for a library I’m working on. Let me stop you there, no, I could not just set classes on the options, I needed an actual container in the DOM.

As you can see in this pen, neither <div> nor custom elements work here: when included in the markup they are just discarded by the parser, and when inserted via script they are in the DOM, but the options they contain are not visible. The only elements that work inside a <select> are: <option>, <optgroup>, and script-supporting elements (currently <template> and <script>). Except <optgroup>, none of the rest renders any contents and thus, is not fit for my use case. It had to be <optgroup>, sadly.

However, using <optgroup>, even without a label attribute inserts an ugly gap in the select menu, where the label would have gone (pen):

(There were also gaps on the left of the labels, but we applied some CSS to remove them)

There appears to be no way to remove said gap.

Ideally, this should be fixed on the user agent level: Browsers should not generate a label box when there is no label attribute. However, I needed a solution now, not in the far future. There was no pseudo-element for targeting the generated label. The only solution that worked was along these lines ([pen](optgroup:not([label]) { display: contents; font-size: 0; }

optgroup:not([label])> * { font-size: 13.333px; })):
optgroup:not([label]) {
	font-size: 0;
}

optgroup:not([label]) > * {
	font-size: 13.333px;
}

The weird 13.333px value was taken directly from the Chrome UA stylesheet (as inspected). However, it is obviously flimsy, and will break any additional author styling. It would be far better if we could say “give me whatever 1em is on the grandparent”. Can we?

The solution

What if we could use custom properties to solve this? Our first attempt might look something like this:

select {
	--em: 1em;
}

optgroup:not([label]) {
	font-size: 0;
}

optgroup:not([label]) > * {
	font-size: var(--em);
}

However this is horribly broken:

All the options have disappeared!!

What on Earth happened here?!

By default, custom properties are just containers for CSS tokens.When they inherit, they inherit as specified, with only any var() references substituted and no other processing. This means that the 1em we specified inherits as the 1em token, not as whatever absolute length it happens to resolve to on select. It only becomes an absolute length at the point of usage, and this is whatever 1em would be there, i.e. 0. So all our options disappeared because we set their font size to 0!

If only we could make 1em resolve to an actual absolute length at the point of declaration and inherit as that, just like native properties that accept lengths?

Well, you’re in luck, because today we can!

You may be familiar with the @property rule as “the thing that allows us to animate custom properties”. However, it is useful for so much more than that.

If we register our custom property as a <length>, this makes the 1em resolve on the element we specified it on, and inherit as an absolute length! Let’s try this:

@property --em {
	syntax: "<length>";
	initial-value: 0;
	inherits: true;
}

select {
	--em: 1em;
}

optgroup:not([label]) {
	display: contents;
	font-size: 0;
}

optgroup:not([label]) > * {
	font-size: var(--em);
}

/* Remove Chrome gap */
:where(optgroup:not([label]) > option)::before {
	content: "";
}

And here is the same technique used for the speech bubble:

Fallback

This is all fine and dandy for the 68% (as of June 2021) of users that are using a browser that supports @property, but what happens in the remaining 32%? It’s not pretty:

We get the default behavior of an unregistered property, and thus none of our options show up! This is bad.

We should clearly either provide a fallback or conditionally apply these rules only in browsers that support @property.

We can easily detect @property support in JS and add a class to our root element:

if (window.CSSPropertyRule) {
	let root = document.documentElement;
	root.classList.add("supports-atproperty");
}

Then we can just use the descendant combinator:

:root.supports-atproperty optgroup:not([label]) {
	font-size: 0;
}

CSS-only fallback for @property

While the JS fallback works great, I couldn’t help but wonder if there’s a CSS only way.

My first thought was to use @supports:

@supports (--em: flugelhorn) {
	/* Does not support @property */
}

The theory was, if a browser supported any value to be assigned on a property registered as a <length>, surely it does not support property registration.

It turns out, registered properties do not validate their syntax at parse time, and thus are always valid for @supports. This is explained in the spec:

When parsing a page’s CSS, UAs commonly make a number of optimizations to help with both speed and memory.

One of those optimizations is that they only store the properties that will actually have an effect; they throw away invalid properties, and if you write the same property multiple times in a single declaration block, all but the last valid one will be thrown away. (This is an important part of CSS’s error-recovery and forward-compatibility behavior.)

This works fine if the syntax of a property never changes over the lifetime of a page. If a custom property is registered, however, it can change its syntax, so that a property that was previously invalid suddenly becomes valid.

The only ways to handle this are to either store every declaration, even those that were initially invalid (increasing the memory cost of pages), or to re-parse the entire page’s CSS with the new syntax rules (increasing the processing cost of registering a custom property). Neither of these are very desirable.

Further, UA-defined properties have their syntax determined by the version of the UA the user is viewing the page with; this is out of the page author’s control, which is the entire reason for CSS’s error-recovery behavior and the practice of writing multiple declarations for varying levels of support. A custom property, on the other hand, has its syntax controlled by the page author, according to whatever stylesheet or script they’ve included in the page; there’s no unpredictability to be managed. Throwing away syntax-violating custom properties would thus only be, at best, a convenience for the page author, not a necessity like for UA-defined properties.

Ok this is great, and totally makes sense, but what can we do? How can we provide a fallback?

It turns out that there is a way, but brace yourself, as it’s quite hacky. I’m only going to describe it for entertainment purposes, but I think for real usage, the JS way is far more straightforward, and it’s the one I’ll be using myself.

The main idea is to take advantage of the var() fallback argument of a second registered variable, that is registered as non-inheriting. We set it to the fallback value on an ancestor. If @property is supported, then this property will not be defined on the element of interest, since it does not inherit. Any other properties referencing it will be invalid at computed value time, and thus any var() fallbacks will apply. If @property is not supported, the property will inherit as normal and thus using it becomes our fallback.

Here is an example with a simple green/red test to illustrate this concept:

@property --test {
	syntax: "*";
	inherits: false;
}

html {
	--test: red;
}

body {
	background: var(--test, green);
}

And here is how we can use the same concept to provide a fallback for the <select> example:

@property --test {
	syntax: "*";
	inherits: false;
}

select {
	--test: 1em; /* fallback */
	--em: 1em;
}

optgroup:not([label]) {
	font-size: var(--test, 0);
}

Here is the finished demo.