← Back to Blog

Modern CSS You Probably Missed: :has(), Container Queries, @layer and Native Nesting

September 2026~12 min read

:has() — The Parent Selector We Waited Two Decades For

For as long as CSS has existed, selectors have only moved down the tree. You could style a child based on its parent, never the reverse. Every workaround — JavaScript class toggles, sibling hacks, restructuring markup — existed because of that one limitation.

:has() ends it. It selects an element based on what it contains:

/* A card that holds an image gets a different layout */
.card:has(img) {
  grid-template-columns: 120px 1fr;
}

/* Highlight the whole form row containing an invalid input */
.form-row:has(input:invalid) {
  border-left: 3px solid crimson;
}

/* Drop the header shadow only on pages with a hero banner */
.site-header:has(~ .hero) {
  box-shadow: none;
}

The second example is the one that changes how you build. Validation styling used to require either JavaScript or an awkward placement of the error element. Now the container just knows. Same for empty states:

/* Show a placeholder only when the list is genuinely empty */
.todo-list:not(:has(li))::before {
  content: "Nothing here yet.";
  color: #888;
}

Performance used to be the objection, and it was a fair one — a naive implementation means re-evaluating large parts of the tree. Browser engines optimized the common cases heavily, and in practice :has() is fine unless you write something pathological like body:has(*). Scope it to a component and you're good.

Container Queries — Components That Style Themselves

Media queries respond to the viewport, which is why the same sidebar component looks wrong when you move it from a wide page into a narrow column. The component doesn't know anything about the viewport; it only knows about the box it was given.

Container queries fix the mismatch by letting an element respond to the size of its container:

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

/* Default: stacked layout for narrow containers */
.card { display: grid; gap: 0.5rem; }

/* Switch to two columns only when the container is wide */
@container card (min-width: 420px) {
  .card {
    grid-template-columns: 140px 1fr;
    gap: 1.5rem;
  }
}

The result is a genuinely portable component. Drop it in a 300px sidebar and it stacks; drop it in a 900px main area and it goes side-by-side. No page-level overrides, no modifier classes, no JavaScript measuring anything.

Style queries take it further — you can respond to a container's custom property value, not just its size:

@container style(--theme: dark) {
  .card { background: #1a1a24; color: #eee; }
}

@layer — Cascade Control That Actually Works

Specificity wars are a symptom of a missing feature, not a discipline problem. @layer lets you declare the priority order up front, so a single class can beat a long descendant selector without !important.

/* Declare the order once, lowest to highest priority */
@layer reset, base, components, utilities;

@layer reset {
  * { box-sizing: border-box; margin: 0; }
}

@layer components {
  /* Specificity 0,1,0 */
  .button { padding: 0.5rem 1rem; }
}

@layer utilities {
  /* Also 0,1,0 — but wins anyway, because the layer is later */
  .p-0 { padding: 0; }
}

Third-party CSS becomes far less scary too. Wrap a vendor stylesheet in a low layer and your own rules stop needing specificity hacks to override it:

@import url("some-ui-library.css") layer(vendor);

This is the single change that most improved the maintainability of stylesheets I work on. When everything is in a declared layer, "why is this rule winning" has an answer you can read off one line at the top of the file.

Native Nesting — No Preprocessor Required

CSS now nests natively, with the same mental model you already have from Sass:

.card {
  padding: 1rem;
  border-radius: 12px;

  & .title { font-weight: 600; }

  &:hover { background: #f6f6f9; }

  @media (min-width: 640px) {
    padding: 1.5rem;
  }
}

One genuine gotcha: in native nesting, a bare selector like .title { } inside a rule is not treated as a descendant — you need the explicit &. Browsers used to guess, and the guess turned out to be a compatibility problem, so the & is now required for anything other than pseudo-classes and pseudo-elements.

Keep nesting shallow anyway. Three levels is readable; six levels is how you end up with specificity you can't override without a layer.

Subgrid — Alignment Across Nested Layouts

Grid solved page layout, but nested components each started their own grid, so their internal rows never lined up with their siblings. Cards with different text lengths looked ragged, and the only fix was fixed heights.

subgrid lets a child inherit the parent's track definitions:

.card-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1rem;
}

.card {
  display: grid;
  /* Inherit the parent's row tracks instead of making new ones */
  grid-template-rows: subgrid;
  grid-row: span 3;
}

Every card's title, body, and footer now align with the neighbouring cards' — regardless of how much text each contains. No JavaScript height equalization, no min-height guesswork.

color-mix() and Relative Color Syntax

Generating tints and shades used to mean either a preprocessor function or twenty hardcoded hex values in custom properties. Now CSS can compute them:

:root {
  --brand: #6366f1;
}

.button {
  background: var(--brand);
  /* 12% brand, 88% transparent — for hover states, borders, scrims */
  border-color: color-mix(in oklab, var(--brand) 25%, transparent);
}

.button:hover {
  background: color-mix(in oklab, var(--brand) 85%, white);
}

Use oklab as the interpolation space rather than srgb — the midtones come out noticeably less muddy, especially mixing toward black or across hues.

Relative color syntax goes further, deriving new colors from an existing one:

/* Same hue, guaranteed 95% lightness */
.badge {
  background: hsl(from var(--brand) h s 95%);
}

/* Fully transparent version of whatever --brand is */
.scrim {
  background: rgb(from var(--brand) r g b / 20%);
}

Change --brand in one place and every derived shade follows. That is what we were trying to achieve with Sass variables a decade ago.

Logical Properties and Why Direction Matters

If your site might ever be translated, stop writing margin-left. Logical properties describe position relative to the text flow, so they flip automatically for right-to-left languages:

/* Physical — breaks in RTL */
.callout { margin-left: 1rem; padding-right: 2rem; border-left: 3px solid gold; }

/* Logical — works in both directions */
.callout { margin-inline-start: 1rem; padding-inline-end: 2rem; border-inline-start: 3px solid gold; }

The full mapping: inline is the direction text flows (horizontal in English, vertical in Japanese), block is the direction lines stack. So margin-block-end replaces margin-bottom, inline-size replaces width, and inset-inline: 0 replaces left: 0; right: 0.

It reads oddly for about a day. After that, it's hard to go back — and your layout works in Arabic without a single override.

Adopting This Without Breaking Anything

All of the above is broadly supported now, but "broadly" still deserves a plan when you ship to real users:

/* Progressive enhancement: only apply when supported */
@supports selector(:has(*)) {
  .form-row:has(input:invalid) { border-left: 3px solid crimson; }
}

/* Or check container queries */
@supports (container-type: inline-size) {
  .card-wrapper { container-type: inline-size; }
}

My approach: use @layer, logical properties, and color-mix() freely — the failure modes are cosmetic. For :has(), container queries, and subgrid, pair them with @supports if the fallback layout would genuinely break, and skip the guard if the fallback is merely less polished.

The pattern behind all seven features is the same: CSS keeps absorbing the things we used to need JavaScript or a preprocessor for. Every year, the amount of styling logic that has to live outside CSS gets smaller.

Related Articles