Modern CSS Features
Get a working overview of the most important recent additions to CSS — cascade layers, native nesting, the :has(), :is(), and :where() selectors, container queries, subgrid, modern viewport units, and object/aspect-ratio sizing — including which ones need a browser-support check before you rely on them in production.
CSS has evolved faster in the last few years than in the previous decade combined. This lesson is a tour of the features reshaping how modern stylesheets get written — not exhaustive treatments (several have their own dedicated lessons), but enough working knowledge to recognize each one, know what problem it solves, and know when to check caniuse.com before shipping it.
Cascade layers: @layer
Before cascade layers, controlling specificity conflicts between, say, a CSS reset, a component library, and your own overrides meant fighting with selector specificity or reaching for !important. @layer lets you name explicit layers and control their priority order up front — layers declared earlier lose to layers declared later, regardless of how specific the selectors inside them are.
@layer reset, base, components, utilities; declares the order once. Later, @layer utilities { .hidden { display: none !important; } } — wait, no !important needed — @layer utilities { .hidden { display: none; } } will beat any selector in the components layer, even a more specific one, purely because utilities was declared later in the layer order.
Native CSS nesting
CSS can now nest rules inside other rules directly, without a preprocessor like Sass. A nested selector that starts with a combinator (&, or implicitly a descendant) refers back to the parent. This dramatically reduces repetition for component-scoped styles..card { color: #333; &:hover { color: #000; }.title { font-weight: bold; } } compiles conceptually to.card { color: #333; },.card:hover { color: #000; }, and.card.title { font-weight: bold; } — all without leaving plain CSS.
:has(), :is(), and :where()
:has() is often called the 'parent selector' — it matches an element if it contains a match for the given selector inside it..card:has(img) matches a.card element only if it contains an <img> somewhere inside. This was impossible in CSS for decades and previously required JavaScript.:is() and:where() both let you group several selectors into one, e.g.:is(h1, h2, h3) matches any of the three. The difference is specificity::is() takes on the specificity of its most specific argument, while:where() always contributes zero specificity, making it ideal for base styles you want to be trivially overridable later.
Container queries (cross-reference)
Container queries let a component style itself based on the size of its containing element rather than the viewport — covered in full in Lesson 30. They're mentioned here because they're one of the most consequential recent CSS additions: they make truly reusable, context-aware components possible for the first time.
Subgrid
Normally, a nested CSS Grid has no relationship to its parent grid's tracks. subgrid lets a grid item that is itself a grid container inherit its parent's row or column tracks directly (grid-template-columns: subgrid), so nested content can align to the outer grid's lines — extremely useful for aligning card contents (image, title, description) across a row of otherwise independently-sized cards.
Modern viewport units: svh, lvh, dvh
The classic vh unit has a well-known mobile problem: on phones, the browser's address bar and toolbar can show or hide as the user scrolls, actually changing the visible viewport height — but vh was defined against the LARGEST possible viewport, so 100vh routinely overflows past the visible screen on mobile, hiding content behind the browser chrome.
Three new units solve this precisely: svh (small viewport height) measures against the viewport when browser UI is fully expanded (the smallest visible area), lvh (large viewport height) measures against the viewport with browser UI hidden (the largest visible area, matching old vh's behavior), and dvh (dynamic viewport height) continuously tracks whichever is currently visible, updating live as the browser chrome shows or hides. Matching dvw/lvw/svw exist for width, though width is rarely affected the same way.
aspect-ratio, object-fit, and object-position
aspect-ratio: 16 / 9 sets a fixed width-to-height ratio on an element, so its height is computed automatically from its width (or vice versa) — no more padding-top percentage hacks to reserve space for a responsive video or image before it loads.
object-fit controls how a replaced element's content (like an <img> or <video>) fills its box when the box's aspect ratio doesn't match the content's: cover crops to fill the box completely, contain shrinks to fit entirely inside the box (letterboxing if needed), and fill (the default) stretches the content, distorting it if the ratios differ. object-position works alongside object-fit to control which part of the content is kept in view — object-position: top means 'if cropping, keep the top of the image visible.'
Modern CSS feature syntax at a glance
Cascade layer priority is decided purely by declaration order of the layers, not by selector specificity within them & in native nesting refers to the parent selector, similar to Sass but native to the browser with no build step :where() always has zero specificity, making its rules trivially overridable by a single class later
/* Cascade layers */
@layer reset, base, components, utilities;
@layer components {
.btn { padding: 0.5em 1em; }
}
/* Native nesting */
.card {
color: #333;
&:hover {
color: #000;
}
.title {
font-weight: bold;
}
}
/* :has(), :is(), :where() */
.card:has(img) { border: 1px solid #ddd; }
:is(h1, h2, h3) { font-family: "Georgia", serif; }
:where(ul, ol) { margin: 0; padding: 0; }
/* Subgrid (needs support check) */
.card-body {
display: grid;
grid-template-columns: subgrid;
}
/* Modern viewport units (needs support check on some) */
.full-height-hero {
height: 100dvh;
}
/* aspect-ratio + object-fit */
img.thumbnail {
aspect-ratio: 4 / 3;
object-fit: cover;
object-position: center top;
}Code Examples
Before:has(), highlighting a container based on the validity or focus state of an input inside it required JavaScript. Now.form-group:has(input:invalid) matches the wrapping element itself whenever an invalid input exists somewhere inside it, purely in CSS..form-group:has(input:invalid) { border-color: red; }
The second declaration overrides the first in browsers that support dvh, while older browsers that don't recognize dvh simply ignore that line and keep using the 100vh fallback above it — a safe progressive-enhancement pattern. dvh has strong but not universal support (check caniuse.com before relying on it as the only declaration, especially if you must support older mobile browser versions).
aspect-ratio directly expresses the intent — 'this box is 16:9' — without the positioning gymnastics the old percentage-padding trick required, and it's far easier to read and maintain.
.form-group:has(input:invalid) {
border-color: red;
}
.form-group:has(input:focus) {
box-shadow: 0 0 0 2px #99ccff;
}.hero {
min-height: 100vh; /* old approach: overflows behind mobile browser chrome */
min-height: 100dvh; /* modern fix: tracks the actual visible viewport */
}/* Old technique: reserve 16:9 space with a padding-top percentage hack */
.video-wrapper-old {
position: relative;
padding-top: 56.25%; /* 9 / 16 */
}
.video-wrapper-old iframe {
position: absolute;
inset: 0;
}
/* Modern technique */
.video-wrapper {
aspect-ratio: 16 / 9;
}
.video-wrapper iframe {
width: 100%;
height: 100%;
}Small vs large vs dynamic viewport height on mobile
svh always assumes the smallest visible area, lvh always assumes the largest, and dvh dynamically follows the browser chrome's actual current state.
Browser chrome COLLAPSED (address bar hidden while scrolling):
+----------------------+ <- top of visible area
| |
| lvh = 100% of THIS |
| |
+----------------------+
Browser chrome EXPANDED (address bar visible):
+----------------------+
| address bar |
+----------------------+ <- top of visible area shrinks
| svh = 100% of THIS |
+----------------------+
dvh = tracks whichever is currently true, live, as the user scrollsImportant Rules
@layer priority is decided by the order layers are declared, independent of selector specificity inside each layer.:is() takes the specificity of its most specific argument;:where() always contributes zero specificity.:has() lets a selector match based on descendants — effectively a 'parent selector' — something plain CSS never had before.
svh/lvh/dvh solve the classic mobile '100vh overflows behind the browser toolbar' problem; dvh updates live as browser chrome shows/hides.
Subgrid, :has(), and dvh/svh/lvh are newer features — always check current browser support before shipping them as the only implementation with no fallback.
Support-sensitive features: check before you rely on them
Subgrid,:has(), and some of the modern viewport units are newer additions and — while support is strong in current evergreen browsers — a production site with a meaningfully older user base should verify support on caniuse.com and provide a sensible fallback (as shown with the vh/dvh pairing above) rather than assuming universal availability. Cascade layers, native nesting,:is(),:where(), container queries, aspect-ratio, and object-fit/object-position all have solid, safe support in current browsers and are reasonable to use directly..hero { height: 100vh; } /* only declaration, mobile hero content hidden behind browser toolbar */.hero { height: 100vh; height: 100dvh; } /* dvh as a progressive enhancement over the vh fallback */
Relying solely on 100vh causes real, reproducible mobile bugs where a full-height hero section is taller than the actually-visible screen, hiding a call-to-action button behind the browser's address bar. Layering dvh on top (with vh as the fallback line before it) fixes this while staying safe for browsers without dvh support.
@layer utilities {.text-red { color: red !important; } } @layer utilities {.text-red { color: red; } }
Once you're using cascade layers correctly, !important should rarely be necessary — a later-declared layer already wins over an earlier one regardless of selector specificity, so reaching for !important on top of that usually signals the layer order itself needs fixing, not a hack on top of it.
[Concept Matching] Match each modern CSS feature to what it actually solves: (1) :has(), (2) dvh, (3) @layer, (4) subgrid. Which pairing below is correct? A. 1: styling a parent based on its children · 2: fixing mobile viewport height overflow behind browser chrome · 3: controlling override priority independent of selector specificity · 4: letting nested grid items align to the outer grid's tracks B. 1: querying a component's own size instead of the viewport · 2: setting a fixed aspect ratio · 3: nesting selectors without a preprocessor · 4: matching an element if it contains a given descendant C. 1: fixing mobile viewport height overflow · 2: styling a parent based on its children · 3: letting nested grid items align to outer tracks · 4: controlling override priority independent of specificity D. 1: nesting selectors without a preprocessor · 2: controlling override priority · 3: matching an element if it contains a descendant · 4: fixing mobile viewport height overflow
A. 1: styling a parent based on its children · 2: fixing mobile viewport height overflow behind browser chrome · 3: controlling override priority independent of selector specificity · 4: letting nested grid items align to the outer grid's tracks — :has() is the 'parent selector' that matches based on descendants; dvh directly addresses the classic mobile 100vh-overflows-behind-the-toolbar problem by tracking the actual visible viewport; @layer lets you set override priority by declaration order, independent of selector specificity; subgrid lets a nested grid inherit and align to its parent grid's row/column tracks.
Write CSS that: (1) styles any.product-card that contains an element with class.sale-badge to have a red border, using:has(); (2) sets a.hero element's height to 100dvh with a 100vh fallback declared first..product-card:has(.sale-badge) { border: 2px solid red; }.hero { height: 100vh; height: 100dvh; }
The fallback declaration must come BEFORE the dvh declaration so browsers that understand dvh override it, while browsers that don't simply ignore the dvh line.