Articles /
State-aware CSS
How if(), @scope, and scroll-state queries reduce JavaScript — a deep dive for 2026.
The problem state-aware CSS solves
A huge percentage of "JavaScript on a web page" exists for one reason: toggling classes. Show a menu. Hide an error. Highlight a card on hover. Change a button's appearance when its form is invalid. All of this is conditional — "if this state, do that visually."
CSS has always had state selectors (:hover, :checked, :focus). But 2025–2026 is when that toolbox expanded dramatically. :has(), @scope, scroll-state queries, and the upcoming if() function give CSS the ability to handle more of its own state without reaching for JavaScript.
:has() — the state you can already ship
:has() is the parent selector CSS never had. It selects an element when it has a descendant (or adjacent element) matching a condition.
/* Style a card when it contains a focused input */
.card:has(input:focus-within) {
outline: 2px solid oklch(0.52 0.22 265);
}
/* Highlight a form when it has any invalid field */
form:has(input:invalid:not(:placeholder-shown)) .submit-btn {
opacity: 0.5;
cursor: not-allowed;
}
/* Show a menu when its details is open */
.nav-item:has([open]) .menu {
opacity: 1;
pointer-events: auto;
}
Browser support: Chrome 105+, Safari 15.4+, Firefox 121+. Widely usable in production in 2026.
@scope — component-bound specificity
@scope limits selector reach to a subtree. Instead of fighting specificity or using BEM to namespace selectors, you declare a scope boundary and your selectors only apply inside it.
@scope (.card) {
/* These selectors only match inside .card */
h2 { font-size: 1rem; }
p { color: oklch(0.48 0.025 260); }
}
/* Or: scope with an exclusion (don't enter nested .card) */
@scope (.card) to (.card .card) {
p { font-size: 0.875rem; }
}
This replaces the pattern of prefixing every selector with a component class or using CSS Modules. @scope is supported in Chrome 118+ and Safari 17.4+. Firefox support is in progress.
Scroll-state queries — state from scroll
Scroll-state container queries (part of the Container Queries Level 3 spec) let you apply styles based on scroll state — whether a sticky element is "stuck", whether a snap element is "snapped", or whether an overflow container is scrollable.
/* Make a sticky header visually indicate it's stuck */
.sticky-header {
container-type: scroll-state;
position: sticky; top: 0;
}
@container scroll-state(stuck: top) {
.sticky-header { box-shadow: 0 2px 8px oklch(0 0 0 / 0.1); }
}
This replaces the common JavaScript pattern: IntersectionObserver + class toggle on a sentinel element. Support is landing in 2025–2026 — check Baseline before shipping.
if() — inline conditionals (upcoming)
The if() function is a CSS Values Level 5 feature that lets you write conditional logic inside a property value. It's not widely supported yet, but it shows where CSS is going.
/* Conceptual — not yet shipping broadly */
.button {
background: if(style(--variant: primary): oklch(0.52 0.22 265); else: var(--subtle));
color: if(style(--variant: primary): white; else: var(--text));
}
When it ships, if() will reduce the number of modifier classes and JS-toggled attributes. For now, rely on :has(), [data-*] attribute selectors, and custom properties for conditional styling.
Practical strategy for 2026
- Replace JS class toggles with
:has()on native state (:checked,[open],:focus-within,:invalid). - Use
@scopefor component styles to avoid specificity wars. - Use scroll-driven animations (
animation-timeline: scroll()) instead of JS scroll listeners for visual effects. - Watch
@container scroll-state()andif()— progressive-enhance with them when their support reaches your target. - Reserve JavaScript for true interactivity: data fetching, complex validation, state that spans multiple components.