You’re four media queries deep adjusting the same padding by four pixels. Or you’re writing your third data-state="loading" class toggle this sprint. Or you’ve got a JavaScript function whose only job is flipping thirty custom properties when the theme button gets clicked.
You know it’s bad code. The alternatives were worse. So you shipped it.
The CSS if() function landed in Chrome 137 last June, and Edge picked it up the same month. It is, finally, a real inline conditional for CSS. Three patterns in your codebase are worth refactoring with it. Here’s which ones — and which ones to leave alone.
What if() Actually Does (and the One Gotcha That Catches Everyone)
The CSS if() function sets property values conditionally, inline — replacing media query blocks, custom property toggles, and calc() hacks with a single declaration like color: if(style(--theme: dark): #fff; else: #111).
It accepts three query types. style() reads a custom property on the element itself. media() runs a media query inline. supports() checks feature support, the same as @supports but as an expression. Conditions evaluate top-to-bottom, first match wins, with an optional else at the end.
One gotcha catches everyone exactly once: if( is valid, if ( is a parse error. It’s a function call, not a statement. The space breaks it. Save yourself an hour of staring at devtools and remember it now.
What follows is three patterns, each shown as the old way and the new way. Decide for yourself which ones are worth your Monday.
Pattern 1: Theme Switching Without the JS Round-Trip
The old theming pattern is one of two flavors, and both have a tax.
Flavor one: a [data-theme="dark"] selector for every component that needs theming.
.card { background: #fff; color: #111; }
[data-theme="dark"] .card { background: #1a1a1a; color: #fafafa; }
.button { background: #f4f4f5; color: #111; }
[data-theme="dark"] .button { background: #27272a; color: #fafafa; }
You add a component, you forget to add its dark-mode selector, and the bug ships. Every dark-mode codebase has at least one of these.
Flavor two: a JavaScript function that flips thirty custom properties on :root when the theme toggles. The CSS is clean, but now your styling logic lives in a script tag.
The if() version moves the conditional into the property itself:
.card {
background: if(style(--theme: dark): #1a1a1a; else: #fff);
color: if(style(--theme: dark): #fafafa; else: #111);
}
.button {
background: if(style(--theme: dark): #27272a; else: #f4f4f5);
color: if(style(--theme: dark): #fafafa; else: #111);
}
You set --theme: dark once on <html>, and every component reads it directly. Theme logic colocates with the property it affects. There’s no separate selector block to forget, and no JavaScript to keep in sync.
The crucial detail: this is if(style(...)), not @container style(...). The @container style query requires a parent container to read from. The if() version reads the element’s own custom property, which is why this works without restructuring your DOM.
One honest caveat. This pays off best when your theming already flows through a single --theme custom property. If your theme system is built on classes — .theme-dark, .theme-sepia — the migration is bigger than the payoff. Convert the toggle first; then this becomes a one-line refactor per component.
Theming is one variable. The other half of your stylesheet is the part nobody wants to look at: repeated media queries.
Pattern 2: Responsive Sizing Without the Media Query Cascade
This is the pattern most senior devs will recognize hardest. You have a card that needs different padding at three breakpoints. The code looks like this:
.card { padding: 1rem; }
@media (min-width: 640px) {
.card { padding: 1.5rem; }
}
@media (min-width: 1024px) {
.card { padding: 2rem; }
}
Three blocks. One property. Three places to update when you rename the variable or add a new breakpoint. And .card isn’t the only thing that needs responsive padding — you’ve got this pattern across the whole stylesheet.
The if() rewrite collapses the cascade into one declaration:
.card {
padding: if(
media(width >= 1024px): 2rem;
media(width >= 640px): 1.5rem;
else: 1rem
);
}
Order matters. First match wins, so list conditions from most-specific (largest viewport) to least. Get this backwards and padding: 1.5rem will win at 1440px because it matched first.
The win compounds across a codebase. The responsive logic for padding lives on padding. When you delete .card, every line of breakpoint logic for it disappears with the rule. No more orphaned media query blocks referencing classes you removed last quarter — a familiar problem if you’ve worked through a CSS @scope refactor.
You can also mix query types inside the same if(). Respond to viewport and a component variant in one go:
.card {
padding: if(
style(--size: compact): 0.5rem;
media(width >= 1024px): 2rem;
else: 1rem
);
}
One limit worth stating clearly: if() returns a value. It does not toggle display, restructure grid-template-columns, or change which container queries apply. If your responsive change is “stack the layout on mobile,” that still belongs in an @media block — same as it does today. The same trade-off shows up with container queries: values, yes; tree-wide layout shifts, no.
Sizing solved. Which leaves the third pain point — the one driven from JavaScript.
Pattern 3: State-Dependent Styles Without the Class Soup
Every interactive component in your app has states. Loading. Error. Success. Disabled. The current pattern is some flavor of this:
<button data-state="loading" disabled>Submit</button>
.btn[data-state="loading"] { opacity: 0.5; cursor: wait; }
.btn[data-state="error"] { border-color: #ef4444; }
.btn[data-state="success"] { border-color: #22c55e; }
Plus a JavaScript handler that toggles the attribute. Plus a code review thread arguing about whether it should be a class or a data attribute. Plus a :has() rule somewhere reading the state from a parent.
The if() version pulls each property’s state logic into the property itself. Set --state once from JS (or via :has() if your DOM allows it), then:
.btn {
opacity: if(style(--state: loading): 0.5; else: 1);
cursor: if(style(--state: loading): wait; else: pointer);
border-color: if(
style(--state: error): #ef4444;
style(--state: success): #22c55e;
else: #d4d4d8
);
}
The payoff is local. “What does opacity look like in the loading state?” is now answered by reading the opacity line, not by hunting across three selectors. Combined with @starting-style or transition-behavior, state changes also animate cleanly.
Honest limit. If a single state changes ten properties on a component, you’ll write ten if() declarations, and the consolidated [data-state="loading"] selector starts looking better again. The rule of thumb: reach for if() when one to three properties change per state. Past that, the selector block wins on readability.
Three patterns, three wins. But Firefox and Safari users exist — and the refactor isn’t free if the feature breaks for a third of your traffic.
When NOT to Use if() (and How to Ship It Today Anyway)
Browser reality as of May 2026: Chrome 137+ and Edge 137+ ship if(). Firefox hasn’t. Safari hasn’t. That’s roughly a third of your users getting nothing.
The pragmatic shipping strategy is progressive enhancement, written backwards from how you’d usually do it. Write the fallback as your default rule, then layer the if() version on top inside @supports:
.card { padding: 1rem; }
@media (min-width: 1024px) { .card { padding: 2rem; } }
@supports (padding: if(style(--x: 1): 1rem; else: 2rem)) {
.card {
padding: if(media(width >= 1024px): 2rem; else: 1rem);
}
}
The old code stays. The new code runs where it can. Nobody gets a broken layout.
Three cases where if() is the wrong tool regardless of support:
- Layout changes.
if()returns values. If you need to swapdisplay: blocktodisplay: grid, that’s a media block, same as it’s always been. - Pseudo-class state. Hover, focus,
:disabled— these already work and selectors compose them naturally. Reaching forif(style(--hovered: 1): ...)is solving a problem you don’t have. The:has()selector covers most of the remaining gaps. - Tree-wide cascades. Anything that needs to change every descendant of a node is still better expressed as a selector. Inline conditionals don’t cascade.
Which leaves one question. Of the three patterns above, which is actually safe to refactor Monday morning?
What to Refactor This Week
The hook listed three hacks: media query repetition, JS-driven theme toggles, data-attribute class soup. Here’s the order they’re worth touching, ranked by refactor ROI.
Pattern 2 (responsive sizing) first. It’s the highest-volume pain in most codebases, the cleanest before/after, and the easiest to fence behind @supports. Pattern 1 (theming) second, but only if you already drive theming through one custom property — otherwise convert the toggle first and revisit. Pattern 3 (state) is incremental: refactor a component’s states when you’re already in that file for another reason.
Don’t refactor in a sweep. Refactor when you touch the file anyway. Pair it with native CSS nesting and your Sass dependency starts looking optional.
if() doesn’t replace your stylesheet. It deletes the parts that always made you wince. Behind a @supports fence, the first one ships today.