Plain CSS now does jobs that used to need Sass or JavaScript. Native nesting groups related rules, :has() styles a parent based on its children, container queries adapt a component to its own width, and subgrid lines nested items up with the outer grid. All four reached Baseline in 2023, so current browsers support them.
| Feature | Replaces | Baseline since |
|---|---|---|
| Nesting | Sass-style nesting in a preprocessor | 2023 |
| :has() | JavaScript that toggles a parent class | 2023 |
| Container size queries | Viewport media queries inside components | 2023 |
| Subgrid | Hard-coded heights to align card rows | 2023 |
Updated
Each feature below has a demo you can edit, and the container query card responds to a slider so you can watch it change layout without resizing your window. The support table lists the year each feature became Baseline, meaning it works in the latest Chrome, Edge, Firefox and Safari.
The hover, the title and the dark variant all sit inside .card. & refers to .card itself.
<style>
.card {
padding: 16px; border: 1px solid #e5e5e5; border-radius: 12px; max-width: 280px;
& h3 { margin: 0 0 6px; font-size: 18px }
& p { margin: 0; color: #475569 }
&:hover { border-color: #4F98FB }
&.dark { background: #0f172a; color: #fff; & p { color: #cbd5e1 } }
}
</style>
<div class="card"><h3>Light card</h3><p>Hover to see the border change.</p></div>
<br>
<div class="card dark"><h3>Dark card</h3><p>Same rules, one extra class.</p></div>No script runs in this frame. The label styles itself because it has a checked input inside.
<style>
label { display: flex; gap: 8px; align-items: center; padding: 10px 12px; margin: 6px 0; border: 1px solid #e5e5e5; border-radius: 10px }
label:has(input:checked) { border-color: #4F98FB; background: #eff6ff }
form:has(input:checked) .hint { display: none }
</style>
<form>
<label><input type="checkbox"> Add gift wrapping</label>
<label><input type="checkbox"> Send a receipt by email</label>
<p class="hint">Tick an option to see the parent react.</p>
</form>Cards without an image keep the single-column layout; cards with one get two columns.
.card:has(> img) {
display: grid;
grid-template-columns: 120px 1fr;
gap: 1rem;
}container-type makes .shelf measurable; the @container rule then switches the card to a row once the shelf is at least 420px wide.
.shelf { container-type: inline-size; }
.product { display: grid; gap: 12px; }
@container (min-width: 420px) {
.product { grid-template-columns: 140px 1fr; align-items: center; }
}
/* Size relative to the container: 1cqi = 1% of its inline size */
.product h3 { font-size: clamp(1rem, 4cqi, 1.5rem); }Each card spans three rows of the outer grid and uses subgrid for its own rows, so every title, body and button share a line.
<style>
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px }
.card { display: grid; grid-row: span 3; grid-template-rows: subgrid; gap: 6px; padding: 10px; border: 1px solid #e5e5e5; border-radius: 10px }
.card h3 { margin: 0; font-size: 15px }
.card p { margin: 0; font-size: 13px; color: #475569 }
.card a { align-self: end; color: #2563eb }
</style>
<div class="grid">
<div class="card"><h3>Starter</h3><p>One site.</p><a href="#">Choose</a></div>
<div class="card"><h3>Team plan with a much longer name</h3><p>Several sites, shared billing and a longer description that wraps.</p><a href="#">Choose</a></div>
<div class="card"><h3>Agency</h3><p>Client sites.</p><a href="#">Choose</a></div>
</div>The ID selector sits in an earlier layer, so the plain class in the later layer still wins.
<style>
@layer base, overrides;
@layer base { #cta { background: #94a3b8 } }
@layer overrides { .btn { background: #4F98FB; color: #fff } }
.btn { padding: 10px 16px; border: 0; border-radius: 999px }
</style>
<button id="cta" class="btn">Blue wins from the later layer</button>:is() takes the specificity of its strongest argument, :where() always counts as zero.
/* Before */
article h2, article h3, aside h2, aside h3 { line-height: 1.2; }
/* After */
:is(article, aside) :is(h2, h3) { line-height: 1.2; }
/* Reset with no specificity, easy to override */
:where(ul, ol)[role="list"] { list-style: none; padding: 0; }Mixing with white or black in the oklab space gives even tints from one brand colour.
<style>
:root { --brand: #4F98FB }
.s { display: inline-block; width: 60px; height: 50px; border-radius: 8px }
</style>
<span class="s" style="background: color-mix(in oklab, var(--brand) 25%, white)"></span>
<span class="s" style="background: color-mix(in oklab, var(--brand) 60%, white)"></span>
<span class="s" style="background: var(--brand)"></span>
<span class="s" style="background: color-mix(in oklab, var(--brand) 70%, black)"></span>The second heading spreads its words evenly across lines instead of leaving one word alone. Browsers limit balancing to short blocks of a few lines.
<style>
h2 { max-width: 320px; font: 700 22px/1.2 system-ui; margin: 0 0 14px }
.balanced { text-wrap: balance }
</style>
<h2>Website templates for bakeries and coffee shops</h2>
<h2 class="balanced">Website templates for bakeries and coffee shops</h2>| Feature | Baseline | Notes |
|---|---|---|
| :is() and :where() | 2021 | Widely available |
| @layer (cascade layers) | 2022 | Widely available |
| Container size queries (@container, container-type) | 2023 | Container style queries are separate and not yet Baseline; check caniuse |
| color-mix() | 2023 | |
| Subgrid | 2023 | |
| CSS nesting | 2023 | Bare element selectors at the start of a nested rule need 2023 or later releases |
| :has() | 2023 | |
| text-wrap: balance | 2024 | Older browsers ignore it and wrap normally |
Inside a nested block, & stands for the outer selector. &:hover attaches the pseudo-class to the parent itself, & + & targets a sibling of the same kind, and .theme-dark & places the parent inside another context.
A nested selector without & is treated as a descendant, so .card { p { } } means .card p. Browsers released in 2023 and later also accept a bare element name such as p at the start of a nested rule.
@layer reset, base, components, utilities; declares an order up front. A later layer beats an earlier one whatever the specificity, which ends most fights between a component library and your own overrides.
Styles outside any layer beat every layered style. That is handy when bringing an old stylesheet into a layered setup: leave the legacy rules unlayered at first, then move them in one at a time.
If your users are on current browsers, nesting and :is() are free wins that only shorten your stylesheet. Container queries pay off once you have reusable components that appear at different widths. Subgrid matters most for card grids and forms with labels.
Not for nesting alone. Native nesting covers the common pattern of rules inside rules plus the & selector. Sass still offers mixins, loops and functions, though custom properties and color-mix() have taken over much of what people used it for.
A media query asks about the viewport, usually the window width. A container query asks about a chosen ancestor element. That lets one card component show a compact layout in a narrow sidebar and a wide layout in the main column of the same page.
Browser engines optimised it before shipping and ordinary use is fine. Very broad selectors such as body:has(*) on huge pages force more matching work, so anchor :has() to a specific component class where you can.
Baseline is a label from the web platform community that marks a feature as working in the current versions of Chrome, Edge, Firefox and Safari. Newly available is the date the last of those shipped it. Widely available comes about thirty months later.
Use subgrid when children of a grid item must line up with the outer grid tracks, such as titles, text and buttons across a row of cards. A plain nested grid has its own independent tracks, so content of different lengths drifts out of line.
Related: CSS selectors cheat sheet, CSS media queries, Flexbox and grid cheat sheet, CSS variables, CSS specificity calculator.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.