Type something to search...
CSS @supports: Feature Detection for Progressive Enhancement

CSS @supports: Feature Detection for Progressive Enhancement

CSS moves faster today than it ever has. Container queries, :has(), cascade layers, anchor positioning, scroll-driven animations, and relative color syntax have all arrived in the last few years, and they don't land in every browser on the same day. That leaves you with a familiar question every time you want to use something new: what happens to the people whose browser doesn't understand it yet?

The answer, most of the time, is @supports. It's CSS's built-in feature detection. It lets you ask the browser "do you understand this?" and apply a block of styles only when the answer is yes. Combined with a solid baseline, it's the backbone of progressive enhancement: everyone gets a working page, and capable browsers get a better one.

In this guide, I'll walk you through how @supports works, the different kinds of conditions you can test, real patterns for shipping modern features safely, and the mistakes that trip people up.

Why Feature Detection Matters

CSS has a forgiving error-handling model. When a browser meets a property or value it doesn't recognize, it throws away that one declaration and carries on. That's what makes the classic fallback trick work:

.card {
  background: #1e293b;
  background: color-mix(in oklch, #1e293b 80%, #38bdf8);
}

A browser that doesn't understand color-mix() drops the second line and keeps the first. No harm done.

But that trick only works one declaration at a time. It breaks down when a feature needs several related declarations to make sense together. Imagine switching a layout to CSS Grid: you set display: grid, then change widths, margins, and floats that only make sense in a grid context. If the browser ignores display: grid but applies your new widths, you end up with a layout that is neither the old one nor the new one. You need a way to group the whole enhancement behind one condition. That's exactly what @supports gives you.

The Basic Syntax

An @supports rule wraps a block of CSS in a condition. The simplest condition is a single declaration in parentheses:

@supports (display: grid) {
  .gallery {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
    gap: 1rem;
  }
}

The browser checks whether it can parse display: grid as a valid property-value pair. If it can, the rules inside apply. If it can't, the whole block is skipped.

A few points are worth stressing:

  • The condition must be wrapped in parentheses, and it must be a full declaration: a property and a value. @supports (display) is not valid.
  • There's no trailing semicolon inside the parentheses.
  • You can nest normal rules, media queries, and even other @supports rules inside the block.

Combining Conditions with and, or, and not

Conditions can be combined with logical operators. Each sub-condition needs its own parentheses.

/* Both must be supported */
@supports (display: grid) and (gap: 1rem) {
  .layout {
    display: grid;
    gap: 1rem;
  }
}

/* Either is enough */
@supports (backdrop-filter: blur(8px)) or (-webkit-backdrop-filter: blur(8px)) {
  .glass-panel {
    -webkit-backdrop-filter: blur(8px);
    backdrop-filter: blur(8px);
    background: rgb(255 255 255 / 0.15);
  }
}

/* Only when NOT supported */
@supports not (aspect-ratio: 1 / 1) {
  .thumb {
    height: 0;
    padding-top: 100%;
  }
}

You can't mix and and or at the same level without extra parentheses. This is invalid:

/* Invalid: ambiguous mix of and/or */
@supports (a: b) and (c: d) or (e: f) {
}

Group it explicitly instead:

@supports ((display: grid) and (gap: 1rem)) or (display: flex) {
  /* ... */
}

Testing More Than Declarations

Early @supports could only check property-value pairs. Modern browsers support several more condition types, which make it far more useful.

selector(): Detecting Selector Support

The selector() function checks whether the browser can parse a selector. This is the cleanest way to guard :has(), :focus-visible, or other newer pseudo-classes.

/* Baseline: highlight the whole form group via a class set in HTML */
.field.is-invalid {
  border-color: #f472b6;
}

@supports selector(:has(input:user-invalid)) {
  .field:has(input:user-invalid) {
    border-color: #f472b6;
  }
}

It matters because an unsupported selector invalidates the entire rule, not just the selector. If you write .a, .b:has(.c) in a browser that doesn't know :has(), neither .a nor .b gets styled. Wrapping the new selector in its own @supports selector() block keeps it isolated.

Support for selector() itself is broad across current Chromium, Firefox, and Safari releases.

font-tech() and font-format()

These newer functions check whether the browser supports a font technology or format. They're handy when choosing between a color font and a plain fallback:

@supports font-tech(color-COLRv1) {
  .display-heading {
    font-family: "Bungee Spice", system-ui, sans-serif;
  }
}

@supports font-format(woff2) {
  /* woff2 is effectively universal now, but this shows the syntax */
}

Support for these is newer and less uniform than the declaration form, so treat them as an enhancement layer and check caniuse before relying on them.

Testing At-Rules

A long-requested ability is testing whether an at-rule is supported, for example @container or @starting-style. An at-rule() function has been specified, but at the time of writing it is not reliably available across browsers. In practice, you detect at-rule features indirectly by testing a property that shipped alongside them:

/* Container queries shipped together with container-type */
@supports (container-type: inline-size) {
  .sidebar {
    container-type: inline-size;
  }

  @container (min-width: 400px) {
    .sidebar .card {
      display: grid;
      grid-template-columns: 120px 1fr;
    }
  }
}

This proxy approach is the most dependable pattern today.

Progressive Enhancement Patterns That Work

Knowing the syntax is the easy part. The real skill is structuring your CSS so the baseline is solid and enhancements are cleanly layered on top.

Pattern 1: Baseline First, Enhancement Inside

Write the default experience outside any @supports block. Then put the enhancement inside. Browsers without the feature never see the enhanced rules, so there's nothing to undo.

<ul class="product-grid">
  <li class="product-card">...</li>
  <li class="product-card">...</li>
  <li class="product-card">...</li>
</ul>
/* Baseline: a simple flexible wrap layout */
.product-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  list-style: none;
  padding: 0;
}

.product-card {
  flex: 1 1 240px;
}

/* Enhancement: subgrid-aligned cards */
@supports (grid-template-rows: subgrid) {
  .product-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  }

  .product-card {
    display: grid;
    grid-row: span 3;
    grid-template-rows: subgrid;
  }
}

Every browser gets a tidy wrapping list of cards. Browsers with subgrid get cards whose titles, bodies, and buttons line up perfectly across a row.

Pattern 2: Use not to Patch Gaps

Sometimes the modern way is the simple way, and you only need extra code for older engines. Using @supports not puts the legacy code in a clearly labeled box that's easy to delete later.

.video-frame {
  aspect-ratio: 16 / 9;
  width: 100%;
}

@supports not (aspect-ratio: 16 / 9) {
  .video-frame {
    position: relative;
    height: 0;
    padding-top: 56.25%;
  }

  .video-frame > iframe {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
  }
}

When the day comes that you no longer care about those browsers, you delete one block and nothing else changes.

Pattern 3: Guarding New Color Syntax

Modern color features such as oklch(), color-mix(), and relative color syntax are widely supported in current browsers but can still be missing in older ones that users keep around. Define a plain color first, then override it:

:root {
  --brand: #3b82f6;
  --brand-hover: #2563eb;
}

@supports (color: oklch(from red l c h)) {
  :root {
    --brand: oklch(62% 0.19 255);
    --brand-hover: oklch(from var(--brand) calc(l - 0.08) c h);
  }
}

.button {
  background: var(--brand);
}

.button:hover {
  background: var(--brand-hover);
}

This one matters more than it looks. When a custom property holds an invalid value, the browser doesn't fall back to an earlier declaration. The property becomes invalid at computed-value time, and the background quietly resets to its initial value (transparent). Guarding the variable definition with @supports avoids that.

Pattern 4: Anchor Positioning with a Fallback

CSS anchor positioning is a great example of a feature that is available in Chromium-based browsers and has been rolling out elsewhere, but whose support you should verify before depending on it. Build a tooltip that works everywhere, then upgrade it:

<button class="info-btn" aria-describedby="tip-1">Details</button>
<div class="tooltip" id="tip-1" role="tooltip">Ships in 2–3 business days.</div>
/* Baseline: tooltip sits right under the button in normal flow */
.tooltip {
  margin-top: 0.5rem;
  padding: 0.5rem 0.75rem;
  background: #1f2937;
  color: #f8fafc;
  border-radius: 6px;
  width: max-content;
  max-width: 16rem;
}

@supports (anchor-name: --a) {
  .info-btn {
    anchor-name: --info;
  }

  .tooltip {
    position: absolute;
    position-anchor: --info;
    top: anchor(bottom);
    left: anchor(center);
    translate: -50% 0;
    margin-top: 0.5rem;
    position-try-fallbacks: flip-block;
  }
}

Unsupported browsers get a readable note below the button. Supported browsers get a properly anchored, self-flipping tooltip.

Pattern 5: Scroll-Driven Animations

Scroll-driven animations are another feature with mixed support, so they're a natural fit for a guard. Pair the check with a motion preference query:

.reading-progress {
  position: fixed;
  inset: 0 0 auto 0;
  height: 4px;
  background: #38bdf8;
  transform-origin: left;
  transform: scaleX(0);
}

@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .reading-progress {
      animation: grow-progress linear both;
      animation-timeline: scroll(root);
    }
  }
}

@keyframes grow-progress {
  to {
    transform: scaleX(1);
  }
}

Without support, the bar simply stays hidden at zero width. Nothing breaks.

Using Feature Detection in JavaScript

The same checks are available in JavaScript through CSS.supports(). It accepts either two arguments or a single condition string:

const hasAnchor = CSS.supports("anchor-name", "--a");
const hasHas = CSS.supports("selector(:has(a))");
const hasContainer = CSS.supports("(container-type: inline-size)");

if (!hasAnchor) {
  // Load a small positioning library only for browsers that need it
  import("./tooltip-polyfill.js");
}

document.documentElement.classList.toggle("no-has", !hasHas);

This is handy for conditionally loading polyfills, so users on modern browsers don't pay for code they'll never run. Try to keep styling decisions in CSS, though. The JavaScript route is best for behavior, not appearance.

Common Pitfalls

1. Thinking @supports Checks Behavior

@supports checks whether the browser can parse a declaration, not whether it implements it correctly or completely. A browser might accept position: sticky but have a bug with sticky table headers. Parsing success is a strong signal, but it isn't a guarantee. Test the real feature in the real browser.

2. Testing the Wrong Value

Some properties exist for years before gaining new values. display: flex has been supported forever, but gap inside flexbox arrived much later. Testing (gap: 1rem) doesn't help either, because gap was already valid for grid. Choose a condition that actually distinguishes the capability you need. When no clean test exists, design your baseline so the missing piece degrades gracefully, for example by adding margins that gap would otherwise replace.

3. Forgetting Vendor Prefixes

A few features still need a prefix in some engines. backdrop-filter historically needed -webkit-backdrop-filter in Safari. Use or to test both, and declare both inside the block.

4. Wrapping Everything

Not every new property needs a guard. If the feature is purely decorative and degrades on its own, for example text-wrap: balance or accent-color, just write it. The browser ignores what it doesn't know. Save @supports for groups of declarations that depend on each other, or for cases where a missing feature would leave things broken.

5. Invalid Conditions That Silently Fail

A typo in a condition makes the whole condition false, so the block never applies and nothing warns you. @supports (display grid) (missing colon) looks close enough to fool the eye. If an enhancement mysteriously isn't showing up, check the condition in DevTools first: the Styles pane shows @supports rules and whether they matched.

6. Hiding Content Behind Unsupported Features

Never make the baseline depend on the enhancement. If your pure-CSS menu only becomes visible inside @supports selector(:has(...)), users on older browsers get no menu at all. The rule of thumb: the page must be usable with every @supports block deleted.

Best Practices

  • Start from the baseline. Write CSS that works everywhere you care about, then layer on enhancements.
  • Group dependent declarations. Use @supports when several properties only make sense together.
  • Prefer positive conditions. @supports (feature) keeps modern code in the enhancement block. Use not mainly to fence off legacy patches.
  • Keep guards close to the component. A guard next to the component it enhances is easier to maintain than a giant "modern browsers" block at the end of the file.
  • Document why a guard exists. A short comment like /* remove when older Safari no longer matters */ makes cleanup easy later.
  • Combine with media queries. Features like animations should respect both support and user preferences such as prefers-reduced-motion.
  • Check real support data. Use caniuse and MDN's compatibility tables to decide when a guard is worth keeping.

Browser Support for @supports

The @supports rule itself, with the declaration form and and/or/not, has been supported by every major browser for many years. The selector() function is supported in current versions of Chromium-based browsers, Firefox, and Safari. font-tech() and font-format() are newer and less uniform, and an at-rule() test is specified but not something you can depend on yet. For those, check caniuse before relying on them, and fall back to proxy tests as shown above.

Conclusion

@supports is one of the quietest tools in CSS, and one of the most valuable. It lets you adopt new features the week they ship without leaving anyone behind. Build a baseline that works everywhere, wrap related enhancements in clear conditions, isolate new selectors with selector(), and use not blocks to fence off legacy patches you'll eventually delete.

Do that, and you stop asking "can I use this yet?" and start asking a better question: "what's the best experience I can give each browser?"

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading