Skip to main
Article
A large, carved, wooden hand
supporting a tree that has nearly fallen over

Support (Not) Unknown

Adding Container Query tests to CSS @support

Working on a new CSS feature like Container Queries, one of the most important considerations is to ensure a “migration path” – a way for developers to start integrating the new code, without breaking their sites on legacy browsers. That looks different depending on the feature, but can often include new tests for the @supports conditional rule.

Understanding @supports

The @supports rule-block in CSS allows us to “test” a bit of CSS, to find out if the browser understands it. If the test condition is supported (true), the browser will continue to parse any CSS inside the rule-block. If the condition is not supported (false), the entire block is ignored.

The basic syntax only allows us to test isolated property-value pairs:

/* subgrid support */
@supports (grid-template-columns: subgrid) {
/* only used if the browser understands */
/* "grid-template-columns: subgrid" */
.grid-item { margin: 0; }
}

/* variable support (any valid variable) */
@supports (--css: vars) {
/* only used if the browser understands "--css: vars" */
button { background: var(--button); }
}

Since CSS is already resilient, allowing browsers to ignore code they don’t understand, we can often use new features without any explicit test. The @supports rule is only necessary when we need support for one property to impact how we use other properties – like changing a margin based on support for subgrid.

It can also be useful to test for lack of support, by adding a “negation” (not) to our test:

/* NO variable support */
@supports not (--css: vars) {
/* only used if the browser DOES NOT understand "--css: vars" */
button { background: rebeccapurple; }
}

Adding new support tests

When new properties and values are added to CSS, browsers don’t need to update anything – the new tests can be written in the existing syntax, and even legacy browsers give us the proper answer (as long as they understand the basics of @supports).

But sometimes we need to test CSS features that are not based on a property/values pair. Over the last couple years, browsers have mostly implemented a syntax for testing support on selectors:

/* can we style list markers? */
@supports selector(::marker) {
/* applied if `::marker` IS supported */
}

@supports not selector(::marker) {
/* applied if `::marker` IS NOT supported */
}

I covered that in more detail with a video about selector support queries back in 2019.

Now we’re also planning to add a new syntax for testing if container queries are supported:

/* can we use this container query? */
@supports container(min-width: 1em) {
/* applied if min-width container queries ARE supported */
}

@supports not container(min-width: 1em) {
/* applied if min-width container queries ARE NOT supported */
}

This will also allow us to test potential new queries as they get added to the specification.

Over time, I imagine CSS will continue to add even more features that need testing, and some of those will require new testing syntax. We should be planning ahead to make sure those new features, and new feature-tests, work as expected in our current browsers.

Unknown feature or unknown test?

Maybe you’ve already noticed the problem. In order to signal support, the browser has to understand both the feature being tested, and the syntax of the test. When we use new syntax to test for support of a new feature, we’re actually testing for support of both at once.

Because of the resilience mentioned above, browsers that don’t understand the new test syntax will also give us a negative result (no support) and skip the code-block in question, even if they do support the new feature.

For some time now, WebKit browsers (like Safari) have had support for the ::marker pseudo-element selector, but do not yet support the @supports selector() syntax. Several versions of Safari would give us a false negative when we test for support of the ::marker selector, even though they understand it just fine. The problem isn’t lack of support for the selector, but lack of support for the new @supports syntax.

(The relevant bug has been closed, and the selector() syntax now works in Safari Technology Preview, so I expect this will be out-of-date soon.)

While there’s some potential for a false-negative, browsers all agree on how to handle this situation… at least on the surface. Behind the scenes they are doing something slightly different, which we’ll see in a minute.

Negating the unknown

With both selector() and container(), we’re testing for support of “wrapping” features: Selectors wrap around any number of declarations, and the @container query rule wraps around any number of selector rule-blocks.

Since browsers ignore blocks of code that they don’t understand, these wrapping features often act as their own @supports conditional test:

/* only applied if the selector has support */
.cat-item::marker {
content: '😻';
}

/* only applied if container queries have support */
@container (min-width: 60ch) {
main { display: grid; }
}

In these cases, it is much more useful to test for lack of support, and provide a fallback value. We want to use the negation syntax:

@supports not selector(::marker) {
.cat-item { list-style: none; }
.cat-item::before { content: '😻';}
}

@supports not container(min-width: 60ch) {
@media (min-width: 50em) {
main { display: grid; }
}
}

This is where browsers disagree, and we start to see the implications around how they handle unknown syntax internally:

Those two values, false and unknown, behave the same when we’re testing basic support – both of them act like false. But these values behave very differently when we negate them:

You can’t negate the unknown.

The path forward

When it comes to testing support for new features, like Container Queries, we really want the ability to add new syntax, and then write negative tests of that syntax, and get the not false (true) result in old browsers.

I know this is a lot of double-negatives, so let’s look at it in context:

@supports not container(min-width: 1em) {
/* we want old browsers to parse this code, */
/* so we need the negated test to be "true" */
/* "yes, we do not support container queries" */
}

I opened an issue with the CSS Working Group, where Oriol Brufau pointed out that the answer depends on exactly how we ask the question. Specifically, we can get different results by adding/removing function arguments, or wrapping our query in parenthesis.

I’ve expanded on his table here to show the different results across browsers. You can see the live result from your current browser, along with known results from the other major browsers. I’m using value and fn() as placeholders for “unknown tests” – since neither of these are supported anywhere:

@supports Live Result Firefox Chromium WebKit
not (value) true false true true true
not fn() true false true false false
not fn(value) true false true true false
not (fn()) true false true false true
not (fn(value)) true false true true true

Again, what we want to see is true across the board. That would mean we can add future support for unknown features (e.g. value and fn()), and have old browsers acknowledge their lack of support.

Earlier this week, the CSS Working Group resolved that unknown @supports expressions should evaluate to false for all @supports rules – which is good news moving forward. But the table above suggests that we also have a temporary solution we can use right away.

The conclusion

While we wait for browsers to standardize how they handle negation of unknown support, developers should wrap any new test syntax in parentheses:

@supports not (container(min-width: 1em)) {
/* true, across the board! */
}

Any legacy browser that understands @supports will parse and apply the CSS inside this block. Then, as browsers start to implement container queries, they will also implement the new testing syntax – hiding the fallback for you.

🥳 Migration path achieved!
🎉 Progressive enhancement!


Cover photo by Neil Thomas on Unsplash.