Articles /

New feature no-js

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 @scope for 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() and if() — progressive-enhance with them when their support reaches your target.
  • Reserve JavaScript for true interactivity: data fetching, complex validation, state that spans multiple components.