TechEarl

CSS @supports (Feature Queries): Progressive Enhancement Done Right

How to use CSS @supports feature queries to apply styles only where they work: the not/and/or combinators, the selector() function for gating :has(), and the CSS.supports() JavaScript equivalent. Write a baseline, then enhance.

Ishan Karunaratne⏱️ 7 min readUpdated
Share thisCopied
Use CSS @supports feature queries to apply styles only where the browser supports them: not/and/or combinators, the selector() function, and CSS.supports() in JavaScript.

The short version: @supports lets you write CSS that only applies when the browser actually understands the property, value, or selector you are testing. You test a declaration, and the rules inside the block run only on a match.

css
@supports (display: grid) {
  .layout {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
  }
}

That is feature detection in pure CSS, no JavaScript, no library. The pattern that makes it worth using is the one most people get backwards, so I will get to that first.

Write the baseline first, enhance inside @supports

The mistake is to put your normal styles inside @supports and treat everything outside it as the fallback. Do the opposite. Your unconditional CSS is the floor that works everywhere, and the @supports block is the upgrade for browsers that can do better.

css
/* Baseline: works in every browser, no feature query */
.cards {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
}

/* Enhancement: only browsers that support grid get this */
@supports (display: grid) {
  .cards {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
  }
}

A browser that does not understand grid never sees the second block and keeps the flexbox layout. A browser that does understand it applies both, and the grid declaration wins on source order. Nothing breaks, and old browsers are not punished for the new ones existing. That is progressive enhancement: a working baseline, then layered improvements where they are available.

This is the inverse of an old reflex from the vendor-prefix era, when people wrapped real styles in detection and shipped a degraded experience by default. Skip that. Ship the working thing unconditionally; gate only the upgrade.

not, and, or

Conditions chain with three combinators. Wrap each individual test in its own parentheses.

css
/* not: apply only where the feature is missing */
@supports not (aspect-ratio: 1) {
  .video { padding-top: 56.25%; } /* manual 16:9 fallback */
}

/* and: both conditions must hold */
@supports (display: grid) and (gap: 1rem) {
  .grid { display: grid; gap: 1rem; }
}

/* or: any condition holds */
@supports (-webkit-backdrop-filter: blur(8px)) or (backdrop-filter: blur(8px)) {
  .panel { backdrop-filter: blur(8px); }
}

A common surprise: a comma is not or inside a feature query. In a selector list, a, b means "either," but @supports has no comma syntax. If you want disjunction you write or explicitly, as above. There is no shorthand for it.

You can also nest groups with parentheses, the way you would in any boolean expression:

css
@supports ((display: grid) and (gap: 1rem)) or (display: flex) {
  /* grid-with-gap, or failing that, flex */
}

The selector() feature query

Testing a property and value covers most cases, but sometimes the thing you want to gate on is a selector, not a declaration. For that there is selector(), which asks whether the browser understands a given selector syntax.

css
/* Only run this where :has() is understood */
@supports selector(:has(*)) {
  .card:has(img) {
    padding: 0;
  }
}

This is the clean way to gate the :has() parent selector. Without the guard, a browser that does not parse :has() would simply ignore the .card:has(img) rule, which is usually fine on its own. But selector() lets you do something more useful: provide a real alternative for the non-supporting case and the enhanced version for the rest.

css
/* Baseline for browsers without :has() */
.field { border: 1px solid #ccc; }

@supports selector(:has(*)) {
  /* Style a field group when it contains an invalid input */
  .field:has(:user-invalid) {
    border-color: #c00;
  }
}

selector() is the right tool for any newer selector: :has(), :is(), the :where() and :is() grouping selectors, the of argument in :nth-child(), and so on. It is widely supported (around 95% of browsers in mid-2026), so it is safe to lean on.

CSS.supports(): the JavaScript equivalent

When you need to branch in script rather than in a stylesheet, CSS.supports() answers the same question and returns a boolean. It comes in two forms.

javascript
// Form 1: property name and value as two strings
CSS.supports("display", "grid");        // true in modern browsers
CSS.supports("accent-color", "rebeccapurple");

// Form 2: a single string, same syntax as the @supports condition
CSS.supports("display: grid");
CSS.supports("(display: grid) and (gap: 1rem)");
CSS.supports("selector(:has(a))");

The two-argument form takes a property and a value separately; the one-argument form takes the whole condition string, including selector(), and/or/not, and parenthesised groups. I reach for CSS.supports() when a decision has to happen in JavaScript anyway, for example choosing which of two code paths to wire up, rather than for styling. If the branch only changes appearance, do it in CSS with @supports, not in script.

javascript
// Pick an interaction model based on a CSS capability
if (CSS.supports("selector(:has(*))")) {
  // lean on :has() in the stylesheet, no JS class-toggling needed
} else {
  attachLegacyClassToggling();
}

When is this actually still useful?

A fair objection: a browser that supports @supports at all supports nearly everything else too, so what is left to detect? Two honest answers.

First, the feature frontier never stops moving. There is always a recently shipped property, value, or selector that some installed browsers have and others do not, and @supports is how you adopt it the day it ships without waiting for universal support. selector(:has(*)), @supports (aspect-ratio: 1), typed attr(), and newer color functions are current examples.

Second, it documents intent. A guarded enhancement block says "this is an upgrade, the layout above is the contract." That is clearer to the next person reading the file than an unannotated rule that happens to no-op in older engines.

What you should not do anymore is the vendor-prefix detection these queries were born for. Prefixing -webkit-flex against -moz-flex is long dead. Gate on real, unprefixed features and ship the baseline plain.

One honest limit before you lean on it for everything: @supports only tests whether the browser parses your declaration as valid syntax, not whether the feature is implemented correctly or completely. A partial or buggy implementation can return true and still misbehave. Early :has() builds are the classic case, where a relational test like selector(li:has(+ a)) matched even though the engine handled it inconsistently. So @supports answers "does this parse," not "does this work to spec," and it cannot detect partial implementations. For most properties the gap is theoretical, but when you are adopting something on its very first shipping version, verify the behaviour, do not trust the query alone.

See also

Sources

Authoritative references this article was fact-checked against.

Tags@supportsfeature queriesCSS.supportsprogressive enhancementselector():has()CSS

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Cloudways for WordPress Agencies: Honest Review

Cloudways is the agency host that gives you VPS flexibility with managed-WordPress simplicity, at a per-site price closer to shared hosting. The honest take on what that hybrid actually means in practice, plus the DigitalOcean acquisition implications.

Rocket.net for WordPress Agencies: Honest Review

Rocket.net is the newer managed WordPress entrant that has gained agency mindshare for performance and Cloudflare Enterprise inclusion. The honest take on speed, pricing, the developer experience, and where Rocket.net wins or loses against the established hosts.