Skip to main content
Illustration: one card component reflowing across three container sizes

Several problems that once needed JavaScript workarounds now have native CSS solutions.

The common thread in this list is that each feature replaces a workaround: a resize observer, a class toggled from a script, a preprocessor, a specificity hack. Most of them are now supported in every major browser engine. Where support is still uneven, that is called out, along with a sensible fallback.

Container Queries

Container queries have fundamentally changed responsive design. Instead of relying solely on viewport dimensions, components can now adapt based on their parent container’s size. This makes truly reusable components possible — a card component that responds to the space it occupies rather than the screen width.

.card-container {
  container-type: inline-size;
}

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

Two details make container queries more useful in practice.

Name your containers. Nested containers are common in real layouts, and an unnamed @container query matches the nearest ancestor container. Naming removes the ambiguity:

.sidebar {
  container: sidebar / inline-size;
}

@container sidebar (min-width: 320px) {
  .nav-item { flex-direction: row; }
}

Use container query units. cqi is 1% of the container’s inline size, so font-size: clamp(1rem, 4cqi, 1.5rem) scales a heading with its card rather than with the window.

Container style queries are the next step: @container style(--variant: compact) lets a component react to a custom property set by an ancestor, a cleaner alternative to modifier classes. Support for style queries is not yet universal, so keep a non-query default in place.

One caveat: container-type: inline-size applies size containment on the inline axis, so the element can no longer size itself from its content’s width. Put it on a wrapper whose width comes from the layout, such as a grid cell, not on a shrink-to-fit element.

The :has() Selector

Often called the “parent selector,” :has() enables styles that depend on descendant or sibling state. Need to style a form group differently when it contains an invalid input? :has() makes this trivial without JavaScript intervention.

.field:has(input:user-invalid) {
  --field-border: var(--color-danger);
}

.card:has(img) {
  grid-template-rows: auto 1fr;
}

/* Stop the page scrolling while a modal dialog is open */
html:has(dialog[open]:modal) {
  overflow: hidden;
}

The :user-invalid pseudo-class in the first rule is worth adopting alongside it: unlike :invalid, it only matches after the user has interacted with the field, so empty required inputs are not shown as errors on page load.

:has() can also look sideways. h2:has(+ p) matches a heading immediately followed by a paragraph, which is handy for tuning spacing in prose. Keep selectors anchored to a specific element rather than writing body:has(...) checks against the whole document, both for readability and to give the browser less work to invalidate.

Cascade Layers

@layer declarations give developers explicit control over the cascade, solving specificity wars that have plagued stylesheets for decades. Organize your styles into reset, base, components, and utility layers to maintain predictable styling outcomes regardless of source order.

@layer reset, base, components, utilities;

@layer components {
  .button { background: var(--color-primary); }
}

@layer utilities {
  .bg-transparent { background: transparent; }
}

The order declared on the first line wins: a rule in utilities beats a rule in components even when the component selector is more specific. Specificity only matters between rules in the same layer.

Two behaviours catch people out:

  • Unlayered styles beat all layered styles for normal declarations. If you import a third-party stylesheet without a layer, it will override your layered component styles. Wrap it instead: @import url("vendor.css") layer(vendor);.
  • !important reverses layer order. An important declaration in an early layer beats one in a later layer, which lets a reset protect a critical rule deliberately.

Tailwind CSS v4 is built on native cascade layers, which is a good sign that layers are ready for production architecture.

Subgrid

Card grids have long had a familiar flaw: titles of different lengths push each card’s body and button to different heights. subgrid lets a child grid adopt its parent’s tracks, so items in sibling cards line up.

.cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
  gap: 1.5rem;
}

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

Each card spans three rows of the parent and passes those rows down, so every title, body, and footer in a row shares a height. Subgrid also inherits the parent’s gap by default, which you can override on the child.

Color: oklch() and color-mix()

oklch() describes color by perceptual lightness, chroma, and hue. Its practical advantage over hsl() is that equal lightness values actually look equally light, so a palette built by stepping lightness behaves predictably across hues. It can also express colors in wider gamuts such as Display P3.

:root {
  --brand: oklch(62% 0.19 255);
  --brand-hover: color-mix(in oklch, var(--brand), black 15%);
  --brand-subtle: color-mix(in oklch, var(--brand) 12%, transparent);
}

color-mix() derives tints, shades, and translucent variants from a single token, so a theme can change one variable instead of a dozen. Relative color syntax goes further, letting you adjust one channel of an existing color: oklch(from var(--brand) calc(l + 0.1) c h) lightens a color without touching its hue.

Pair these with light-dark() and color-scheme: light dark to define both theme values in one declaration, such as color: light-dark(#1a1a1a, #f2f2f2).

View Transitions

The View Transitions API brings native page transition animations to multi-page applications. Combined with Astro’s <ClientRouter /> component, creating smooth, app-like navigation experiences requires minimal code and delivers a polished feel.

There are two flavours. Same-document transitions, started from script with document.startViewTransition(), are what client-side routers use. Cross-document transitions need no JavaScript at all; both pages opt in from CSS:

@view-transition {
  navigation: auto;
}

.post-hero {
  view-transition-name: post-hero;
}

Giving matching elements on the old and new page the same view-transition-name makes the browser morph one into the other. Cross-document transitions are not yet supported in every engine, but browsers without support simply navigate normally, which makes this a safe progressive enhancement. Wrap custom transition animations in @media (prefers-reduced-motion: no-preference) so users who ask for less motion get it.

Nesting

Native CSS nesting eliminates the need for preprocessors in many projects. Write styles that mirror your HTML structure without reaching for Sass or Less, keeping your toolchain leaner.

.card {
  padding: 1.5rem;

  & h2 {
    margin-block-end: 0.5rem;
  }

  &:hover {
    box-shadow: var(--shadow-md);
  }

  @media (min-width: 48rem) {
    padding: 2rem;
  }
}

There are differences from Sass. You cannot concatenate selectors, so &__title in the BEM style does not work. And a nested rule is treated like :is(), which takes the specificity of its most specific argument, so nesting under a list of selectors can raise specificity in ways Sass would not. Keep nesting shallow; two levels is usually plenty.

Scoping with @scope

@scope limits a set of rules to a subtree, and optionally stops at an inner boundary:

@scope (.card) to (.card-content) {
  img { border-radius: 0.5rem; }
}

Here images inside a card are rounded, but images inside the card’s content slot are left alone. It also adds scoping proximity to the cascade: when two scoped rules conflict at equal specificity, the one whose scope root is closer to the element wins. Support arrived in Firefox later than in Chromium and Safari, so check your browser matrix before relying on it for anything critical.

Anchor Positioning

Tooltips and dropdown menus have traditionally needed a JavaScript positioning library. CSS anchor positioning lets one element position itself relative to another declaratively:

.trigger {
  anchor-name: --menu-trigger;
}

.menu {
  position: absolute;
  position-anchor: --menu-trigger;
  position-area: block-end span-inline-end;
  position-try-fallbacks: flip-block;
}

position-try-fallbacks tells the browser to flip the menu above the trigger when there is no room below, which is the edge case positioning libraries exist to handle. It combines naturally with the popover attribute for light-dismiss behaviour. Anchor positioning shipped in Chromium first and other engines have followed more recently, so treat it as an enhancement: keep a simple fallback position and verify current support before dropping a library.

Smaller Wins Worth Adopting

A handful of smaller features deserve a place in every new stylesheet:

  • text-wrap: balance evens out line lengths in headings, avoiding a single orphaned word on the last line.
  • @property registers a custom property with a type, so it can be animated, such as a gradient angle.
  • margin-inline, padding-block, inset-inline-start, and the other logical properties make layouts adapt to right-to-left languages with no extra rules.
  • :focus-visible shows focus rings for keyboard users without showing them on every mouse click.

Adopting New Features Safely

The best way to use all of this is incrementally. Check a feature’s Baseline status before treating it as a requirement, and reach for @supports when the fallback needs different rules:

@supports not (anchor-name: --a) {
  .menu { inset-block-start: 100%; }
}

Layers, container queries, :has(), nesting, and modern color functions are dependable foundations today. View transitions, @scope, and anchor positioning are excellent enhancements. Together they let CSS handle a large share of the work that used to require JavaScript, with less code to ship and less to maintain.

Share this article
frontend design-systems accessibility