On CSS Forgiveness

Published on

One of the main features of the CSS language (and of HTML) is that it handles errors gracefully. It is said that browsers ignore any CSS they don't understand.

But not all errors in CSS are handled in the same way. In some cases, the browser ignores more or less than what we would expect. For example, the recently introduced :is() and :where() selectors are forgiving, while :has() ended up being unforgiving. But what does this mean? Let's dig into the details of CSS error handling.

CSS Syntax

Let's start by looking at the basics. CSS is a simple (but not easy) language. We can sum up most of the language syntax in a simple picture:

Diagram showing a summary of CSS basic terminology: Ruleset, selector, declaration, property and value
Image taken from https://css-tricks.com/css-ruleset-terminology/

There are statements which can be at-rules (not in the diagram above) or rulesets. Rulesets are composed by selector (lists) and declarations. Declarations are made up of properties and values. You can find more details about the CSS syntax in MDN

So, when we say that a browser doesn't understand some CSS, it can be one of 2 things:

Syntax errors

These are the kind of errors we don't make on purpose. With modern code editors and tools we can avoid them most of the time, but it's easy to forget a semicolon, for example.

In general, browsers will recover from syntax errors trying not to break much. If it's just a missing semicolon, only a couple of declarations get broken, which is great. Of course, depending on the error, it might get much worse.

Let's looks at the other kind of "errors", which are much more interesting.

CSS forgiveness

We can write CSS taking advantage of the way errors are handled. Let's look at how CSS works when it doesn't understand syntactically correct code.

Declarations

When it comes to declaration, any properties or values that the browser doesn't recognize are ignored. The browser only ignores the invalid declaration, but other declarations in the ruleset work fine. This allows us to try out new values or properties that are not widely supported yet.

This is perhaps the most common technique that takes advantage of CSS forgiveness. We just write a declaration twice: One for the fallback, using widely supported CSS and on the next line we use newer CSS that overrides the first declaration, or is just ignored if the browser doesn't understand it. Here's an example taken from https://web.dev/learn/css/the-cascade/#position-and-order-of-appearance

.my-element {
	font-size: 1.5rem;
	font-size: clamp(1.5rem, 1rem + 3vw, 2rem);
}

If the browser supports clamp it will apply the second statement, otherwise it will just ignore it.

Selectors

Normally, when there's an error in a selector, the whole selector group (or list) is consider invalid and the ruleset is ignored. However there are 2 exceptions to this rule:

Forgiving selector parsing

:is() and :where() are pseudo-classes that accept forgiving selector lists. This means that if any of the selectors in the list is invalid, only that selector is ignored, instead of the whole list.

Let's look at the following

article:has(footer, ::-crap) { ... }
:is(header, ::-crap) { ... }

The selector list in :has is not forgiving, so the invalid ::-crap makes browser ignore the whole ruleset. On the other hand, the second ruleset works fine. Only ::-crap is ignored. It would be equivalent to:

:is(header) { ... }

-webkit- pseudo-elements

Any webkit prefixed pseudo-element, is accepted as valid, even if it doesn't exist. So ::-webkit-blah would be accepted. If the browser doesn't know how to handle it, it's ignored and the rest of the selector list works fine. We can say that invalid -webkit- pseudo-elements are always forgiven by the CSS parser.

The reason for this is that back in the day browsers experimented with new features by using vendor prefixes, which ended up all over the place, and people used them without much care. This resulted in some websites that only worked for Chrome, or other Webkit based browsers. Firefox had to add this exception to the Gecko engine and now it's part of the CSS spec.

At-rules

An at-rule begins with an at-keyword, like @import, and can followed by some values. Some of them, called block at-rules, are then followed by a { } block of statements or declarations, like in the case of @media and @font-face.

Each at-rule has its own syntax, but in general any unrecognized at-keyword or value causes the whole statement to be ignored. This is similar to how errors are handled for selector lists.

Conditional at-rules

We can think of an exception to the rule above for conditional at-rules, which are @media and @supports. These at-rules take one or more conditions separated by logical operators. If one of the conditions has an unrecognized value, only that condition is evaluated as false. So we can have something like this:

@media (min-height: 20lh) or (min-height: 20em) {
	font-size: 2em;
}

If the browser doesn't understand the lh unit, it will only return false to the first condition, but the whole condition won't be ignored, and since the or operator is used, the font-size: 2em will be applied when the second condition is met.

Summing up

This was a great opportunity to revisit the CSS syntax, terminology and admire the amazing design of the language, that has manage to keep its simplicity even with the amount of powerful features that have been added in recent years.

Nowadays it's pretty easy to write error-free CSS code, but we can also take advantage of the forgiving nature of the language to try out new functionality in a clean and safe way. This is what Progressive Enhancement is all about.