Modern CSS in 2023: Container Queries, :has(), and Native Nesting

2023 was the year CSS finally caught up with everything we'd been using preprocessors and JavaScript hacks for. Container queries, the :has() selector, native nesting, cascade layers, and new color functions all landed with full cross-browser support. Any one of these would have been a headline feature. Getting all of them in the same year feels like CSS skipped a decade of incremental progress and just jumped forward.
Let me walk through the features I've been reaching for most and how they change real code.
Container Queries
Media queries respond to the viewport. Container queries respond to the parent element's size. This is the feature component-based architecture has been begging for since the first React component library shipped.
The old problem: you have a card component. It looks great in a three-column grid. But when you put that same card in a narrow sidebar, the media query doesn't help because the viewport hasn't changed. You'd resort to JavaScript resize observers or awkward class-based overrides.
Now you define a containment context on the parent, then query it:
.card-container {
container-type: inline-size;
container-name: card;
}
@container card (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 200px 1fr;
gap: 1rem;
}
}
@container card (max-width: 399px) {
.card {
display: flex;
flex-direction: column;
}
.card img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
}
The card adapts to its container, not the viewport. Drop it in a sidebar, and it stacks vertically. Put it in a wide content area, and it goes horizontal. Same component, zero JavaScript, no class juggling. This is genuinely transformative for design systems.
The :has() Selector
For years, developers asked for a "parent selector" in CSS. The :has() pseudo-class is the answer, and it's even more powerful than what most people asked for. It lets you style an element based on what it contains or what comes after it.
Basic usage: style a card differently when it contains an image.
/* Card with an image gets a different layout */
.card:has(img) {
grid-template-rows: 200px 1fr;
}
/* Card without an image gets more padding */
.card:not(:has(img)) {
padding: 2rem;
}
/* Style a label when its input is invalid */
label:has(input:invalid) {
color: red;
}
/* Style a form when any required field is empty */
form:has(input:required:placeholder-shown) {
border-left: 3px solid orange;
}
That last example is wild. Pure CSS form validation feedback without a single line of JavaScript. The form gets an orange border whenever any required input is still showing its placeholder (meaning the user hasn't typed anything). When all required fields are filled, the border disappears.
Here's a real pattern I've used: styling navigation items based on whether a dropdown is open.
/* When a nav item's dropdown is visible, highlight the trigger */
.nav-item:has(.dropdown:not([hidden])) > .nav-link {
background: var(--color-active);
color: white;
}
Previously this required JavaScript to toggle a class on the parent. Now CSS handles it natively.
Native CSS Nesting
This is the feature that might finally let some projects drop Sass. Native CSS nesting lets you write nested selectors directly in your stylesheets, just like you've been doing in SCSS for years.
Before (flat CSS):
.card { border: 1px solid #ddd; }
.card .title { font-size: 1.25rem; }
.card .title:hover { color: blue; }
.card .body { padding: 1rem; }
.card .body p { margin-bottom: 0.5rem; }
After (nested CSS):
.card {
border: 1px solid #ddd;
.title {
font-size: 1.25rem;
&:hover {
color: blue;
}
}
.body {
padding: 1rem;
p {
margin-bottom: 0.5rem;
}
}
}
The syntax is nearly identical to Sass nesting. The `&` character references the parent selector, just like in preprocessors. One important detail: as of the initial spec, nesting with element selectors (like putting `p` directly inside a rule) requires the nested selector to start with a symbol. In practice, browsers now handle bare element selectors in nested contexts, but using `&` is still the safest habit.
color-mix() for Dynamic Colors
Creating color variations used to mean defining every shade manually or pulling in a Sass function. `color-mix()` lets you blend colors directly in CSS:
:root {
--brand: #3b82f6;
}
.button {
background: var(--brand);
}
.button:hover {
/* 20% black mixed with brand color = darker shade */
background: color-mix(in srgb, var(--brand), black 20%);
}
.button:active {
background: color-mix(in srgb, var(--brand), black 40%);
}
.button-light {
/* 80% white mixed with brand = light tint */
background: color-mix(in srgb, var(--brand), white 80%);
color: var(--brand);
}
No Sass functions, no hardcoded hex values for every shade. Define one brand color and derive everything else. Change the brand color, and every variation updates automatically.
Individual Transform Properties
This one is less flashy but incredibly practical. Previously, if you wanted to animate just the scale of an element that also had a rotation, you had to restate the entire transform:
/* Old way: restate everything */
.icon {
transform: rotate(45deg) scale(1);
transition: transform 0.2s;
}
.icon:hover {
transform: rotate(45deg) scale(1.2);
}
Now each transform function has its own property:
/* New way: independent properties */
.icon {
rotate: 45deg;
scale: 1;
transition: scale 0.2s;
}
.icon:hover {
scale: 1.2;
}
Each property can have its own transition timing. You can animate scale on hover while rotate stays put, without restating both. This is a huge win for animation code clarity.
Cascade Layers with @layer
Cascade layers give you explicit control over which styles win when specificity alone isn't enough. This is particularly useful when integrating third-party CSS with your own styles.
@layer reset, base, components, utilities;
@layer reset {
* { margin: 0; padding: 0; box-sizing: border-box; }
}
@layer base {
body { font-family: system-ui; line-height: 1.6; }
}
@layer components {
.button { padding: 0.5rem 1rem; border-radius: 4px; }
}
@layer utilities {
.mt-4 { margin-top: 1rem; }
}
Layers declared later always beat layers declared earlier, regardless of selector specificity. So a simple `.mt-4` in the utilities layer will override a complex `.container .section .button` in the components layer. Tailwind CSS leverages this extensively in version 3.4 and later.
The Bigger Picture
What excites me most isn't any individual feature. It's the compound effect. Container queries plus :has() plus nesting means you can build genuinely responsive, context-aware components in pure CSS that would have required JavaScript frameworks just two years ago. The gap between what CSS can do natively and what you need a preprocessor or JS for has shrunk dramatically.
My practical advice: start using nesting and :has() today. They're supported in every evergreen browser. Container queries should be your default over media queries for any reusable component. And if you're still writing Sass just for nesting and color functions, take a hard look at whether you still need it. For many projects, the answer in 2023 is finally no.