Modern CSS Features
A guide about modern CSS features
Modern CSS Features Guide
A practical guide to CSS features shipped 2020-2025. These enable patterns previously requiring JavaScript or preprocessors, making stylesheets more powerful and declarative.
1. Cascade Layers (@layer)
Control specificity without fighting selector wars. Layers let you define explicit priority order regardless of selector specificity or source order.
/* Define layer order - last wins */
@layer reset, base, components, utilities;
@layer reset {
* {
margin: 0;
box-sizing: border-box;
}
}
@layer base {
a {
color: blue;
}
}
@layer components {
/* Even with lower specificity, this beats @layer base */
.card a {
color: inherit;
}
}
@layer utilities {
/* Always wins due to layer order */
.text-red {
color: red;
}
}
Importing into layers
@import url("reset.css") layer(reset);
@import url("framework.css") layer(vendor);
Anonymous & nested layers
/* Anonymous layer - lowest priority */
@layer {
body {
line-height: 1.5;
}
}
/* Nested layers */
@layer components {
@layer cards {
.card {
border-radius: 8px;
}
}
@layer buttons {
.btn {
padding: 0.5em 1em;
}
}
}
Key insight: Unlayered styles beat all layered styles. Use layers for everything or nothing.
2. CSS Nesting
Native nesting without Sass/Less. Write component styles in a single block.
.card {
padding: 1rem;
background: white;
border-radius: 8px;
/* Nested element - requires & for element selectors */
& h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
/* Class selectors work with or without & */
.card__body {
color: #333;
}
/* Pseudo-classes */
&:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 0.1);
}
/* Pseudo-elements */
&::before {
content: "";
position: absolute;
}
/* Nested media queries */
@media (width < 40rem) {
padding: 0.5rem;
& h2 {
font-size: 1rem;
}
}
}
Compound selectors with &
.btn {
background: gray;
/* .btn.btn--primary */
&.btn--primary {
background: blue;
}
/* .btn:not(:disabled) */
&:not(:disabled) {
cursor: pointer;
}
}
Gotcha: Bare element selectors require & prefix: & p { } not p { }.
3. The :has() Selector
The “parent selector” CSS never had. Select elements based on their descendants, siblings, or state.
Parent selection
/* Card that contains an image */
.card:has(img) {
grid-template-rows: 200px 1fr;
}
/* Form with invalid inputs */
form:has(:invalid) {
border-color: red;
}
/* Article with more than 3 paragraphs */
article:has(p:nth-child(4)) {
columns: 2;
}
Sibling relationships
/* Label when its adjacent input is focused */
label:has(+ input:focus) {
color: blue;
}
/* Image followed by a caption */
img:has(+ figcaption) {
margin-bottom: 0.5rem;
}
Practical patterns
/* Show/hide based on checkbox state */
.filters:has(#show-advanced:checked) .advanced-options {
display: block;
}
/* Style body when dialog is open */
body:has(dialog[open]) {
overflow: hidden;
}
/* Empty state */
ul:has(li) {
padding: 1rem;
}
ul:not(:has(li))::before {
content: "No items";
color: gray;
}
Performance note: :has() is fast in modern browsers. Avoid deeply nested or overly complex selectors.
4. Container Queries
Responsive design based on component size, not viewport. Components adapt to their container.
Setup
/* Define a container */
.card-grid {
container-type: inline-size;
container-name: cards;
}
/* Shorthand */
.sidebar {
container: sidebar / inline-size;
}
Query the container
.card {
display: grid;
gap: 1rem;
}
/* When container is at least 400px wide */
@container (width >= 400px) {
.card {
grid-template-columns: 150px 1fr;
}
}
/* Named container query */
@container cards (width >= 600px) {
.card {
grid-template-columns: 200px 1fr auto;
}
}
Container query units
.card__title {
/* 5% of container width */
font-size: clamp(1rem, 5cqi, 2rem);
}
| Unit | Description |
|---|---|
cqw | 1% of container width |
cqh | 1% of container height |
cqi | 1% of container inline size |
cqb | 1% of container block size |
vs Media Queries
/* Media query: viewport-based (global) */
@media (width >= 768px) {
.card {
flex-direction: row;
}
}
/* Container query: container-based (local) */
@container (width >= 300px) {
.card {
flex-direction: row;
}
}
Use case: Same component in sidebar (narrow) and main content (wide) adapts automatically.
5. Subgrid
Nested elements align to the parent grid. Solves the “card content alignment” problem.
The problem
<ul class="grid">
<li class="card">
<h2>Short</h2>
<p>Description</p>
</li>
<li class="card">
<h2>Much Longer Title Here</h2>
<p>Description</p>
</li>
</ul>
Without subgrid, card contents don’t align across cards.
The solution
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
/* Define row template for card contents */
grid-template-rows: subgrid;
}
.card {
display: grid;
/* Inherit parent's column tracks */
grid-template-rows: subgrid;
/* Span 2 rows in parent */
grid-row: span 2;
gap: 0.5rem;
}
Subgrid in one dimension
.card {
display: grid;
/* Only rows from parent, columns are local */
grid-template-rows: subgrid;
grid-template-columns: 1fr 1fr;
}
Named lines pass through
.layout {
display: grid;
grid-template-columns:
[full-start] 1fr [content-start] 2fr [content-end] 1fr [full-end];
}
.layout > section {
display: grid;
grid-template-columns: subgrid;
& h2 {
/* Uses parent's named line */
grid-column: content-start / content-end;
}
}
6. View Transitions
Animate between DOM states or page navigations with minimal code.
Same-document transitions (SPA)
document.startViewTransition(() => {
// Update DOM here
updateContent();
});
/* Default crossfade */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 300ms;
}
/* Custom animation */
::view-transition-old(root) {
animation: fade-out 200ms ease-out;
}
::view-transition-new(root) {
animation: fade-in 200ms ease-in;
}
@keyframes fade-out {
to {
opacity: 0;
transform: scale(0.95);
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: scale(1.05);
}
}
Named transitions for specific elements
.card {
view-transition-name: card;
}
/* Animate only the card */
::view-transition-old(card),
::view-transition-new(card) {
animation-duration: 400ms;
animation-timing-function: ease-in-out;
}
Cross-document transitions (MPA)
/* Enable on both pages */
@view-transition {
navigation: auto;
}
/* Shared element between pages */
.hero-image {
view-transition-name: hero;
}
Transition types
document.startViewTransition({
update: () => navigateToPage(),
types: ["slide-left"],
});
/* Style based on type */
html:active-view-transition-type(slide-left) {
&::view-transition-old(root) {
animation: slide-out-left 300ms;
}
&::view-transition-new(root) {
animation: slide-in-right 300ms;
}
}
7. Scroll-Driven Animations
Animate elements based on scroll position without JavaScript.
Scroll progress animation
/* Progress bar that fills as you scroll */
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
background: blue;
transform-origin: left;
/* Link to scroll position */
animation: grow-progress linear;
animation-timeline: scroll();
}
@keyframes grow-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
Scroll timeline options
.element {
animation: fade-in linear;
/* scroll(scroller axis) */
animation-timeline: scroll(root block);
}
| Scroller | Description |
|---|---|
nearest | Nearest scrollable ancestor (default) |
root | Document viewport |
self | Element itself |
| Axis | Description |
|---|---|
block | Block direction (vertical in LTR) |
inline | Inline direction (horizontal in LTR) |
View progress (element enters/exits viewport)
.card {
animation: slide-up ease-out;
animation-timeline: view();
/* Animate only while entering */
animation-range: entry 0% entry 100%;
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
Animation range values
.element {
animation-timeline: view();
/* entry: element entering viewport */
/* exit: element leaving viewport */
/* contain: element fully visible */
/* cover: from first pixel entering to last pixel leaving */
animation-range: entry 25% cover 50%;
}
Named scroll timelines
.scroller {
overflow-y: scroll;
scroll-timeline: --my-scroller block;
}
.scroller .animated {
animation: rotate linear;
animation-timeline: --my-scroller;
}
8. Anchor Positioning
Position elements relative to other elements without JavaScript. Perfect for tooltips, popovers, and dropdowns.
Basic setup
/* The anchor element */
.button {
anchor-name: --my-button;
}
/* The positioned element */
.tooltip {
position: absolute;
position-anchor: --my-button;
/* Position below the anchor */
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
}
Anchor function values
.dropdown {
position: absolute;
position-anchor: --trigger;
/* anchor(side, fallback) */
top: anchor(bottom, 0);
left: anchor(left);
right: anchor(right);
/* Percentage along anchor edge */
left: anchor(50%); /* center */
}
Inset shorthand
.popover {
position: absolute;
position-anchor: --target;
/* Position all edges relative to anchor */
inset-area: bottom center;
}
Auto-flip with position-try
.tooltip {
position: absolute;
position-anchor: --button;
/* Default: below */
top: anchor(bottom);
left: anchor(center);
/* Fallback positions if no space */
position-try-fallbacks:
top,
/* above */ left,
/* to left */ right; /* to right */
}
/* Or define custom fallbacks */
@position-try --above {
bottom: anchor(top);
top: auto;
}
Anchor with popover
[popovertarget="menu"] {
anchor-name: --menu-button;
}
#menu {
position-anchor: --menu-button;
top: anchor(bottom);
left: anchor(left);
}
9. Popover API
Native popovers with light dismiss, focus management, and top-layer rendering. No JavaScript required for basic usage.
Basic popover
<button popovertarget="my-popover">Open</button>
<div id="my-popover" popover>
<p>Popover content</p>
</div>
[popover] {
padding: 1rem;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 4px 20px rgb(0 0 0 / 0.15);
}
/* Entry animation */
[popover]:popover-open {
animation: fade-in 200ms ease-out;
}
@keyframes fade-in {
from {
opacity: 0;
transform: scale(0.95);
}
}
Popover types
<!-- Auto (default): light dismiss, closes other auto popovers -->
<div popover="auto">...</div>
<!-- Manual: no light dismiss, stays open -->
<div popover="manual">...</div>
Backdrop styling
[popover]::backdrop {
background: rgb(0 0 0 / 0.3);
backdrop-filter: blur(2px);
}
Show/hide actions
<!-- Toggle (default) -->
<button popovertarget="popup">Toggle</button>
<!-- Explicit actions -->
<button popovertarget="popup" popovertargetaction="show">Open</button>
<button popovertarget="popup" popovertargetaction="hide">Close</button>
JavaScript API
const popover = document.querySelector("[popover]");
popover.showPopover();
popover.hidePopover();
popover.togglePopover();
// Events
popover.addEventListener("toggle", (e) => {
console.log(e.newState); // 'open' or 'closed'
});
Nested popovers
<div id="menu" popover>
<button popovertarget="submenu">Submenu</button>
<div id="submenu" popover>
Nested content
</div>
</div>
Light dismiss on parent closes children. Children don’t close parent.
10. color-mix() & OKLCH
Modern color functions for better color manipulation and perceptually uniform color spaces.
OKLCH color space
:root {
/* oklch(lightness chroma hue) */
--primary: oklch(55% 0.25 250); /* Blue */
--success: oklch(65% 0.2 145); /* Green */
--warning: oklch(75% 0.18 85); /* Orange */
--danger: oklch(55% 0.22 25); /* Red */
}
| Component | Range | Description |
|---|---|---|
| Lightness | 0-100% | 0 = black, 100 = white |
| Chroma | 0-0.4+ | Color intensity (0 = gray) |
| Hue | 0-360 | Color wheel angle |
Why OKLCH over HSL?
- Perceptually uniform: Same lightness = same perceived brightness
- Predictable: Changing hue doesn’t affect perceived lightness
- Wider gamut: Access to P3 display colors
color-mix()
.button {
background: var(--primary);
&:hover {
/* Mix with white for lighter shade */
background: color-mix(in oklch, var(--primary), white 20%);
}
&:active {
/* Mix with black for darker shade */
background: color-mix(in oklch, var(--primary), black 20%);
}
}
Syntax
/* color-mix(in colorspace, color1 percent, color2 percent) */
/* 50/50 mix (default) */
color-mix(in oklch, red, blue)
/* Custom ratio */
color-mix(in oklch, red 75%, blue 25%)
/* Omit second percentage (calculated automatically) */
color-mix(in oklch, red 75%, blue)
Dynamic theming
:root {
--brand: oklch(55% 0.25 250);
/* Generate palette from single color */
--brand-light: color-mix(in oklch, var(--brand), white 40%);
--brand-dark: color-mix(in oklch, var(--brand), black 30%);
--brand-subtle: color-mix(in oklch, var(--brand), transparent 80%);
}
/* Semantic aliases */
:root {
--surface: oklch(98% 0.01 var(--hue));
--text: oklch(20% 0.02 var(--hue));
--border: oklch(85% 0.02 var(--hue));
}
Alpha channel
.overlay {
/* OKLCH with alpha */
background: oklch(0% 0 0 / 0.5);
/* Mix toward transparent */
background: color-mix(in oklch, var(--primary), transparent 50%);
}
Relative color syntax
.element {
--base: oklch(50% 0.2 250);
/* Modify components of existing color */
background: oklch(from var(--base) calc(l + 20%) c h);
border-color: oklch(from var(--base) l calc(c * 0.5) h);
}
Putting It Together
A real-world component using multiple modern features:
@layer components {
.card-grid {
container: cards / inline-size;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
gap: 1.5rem;
}
.card {
--card-color: oklch(55% 0.2 250);
display: grid;
grid-template-rows: subgrid;
grid-row: span 3;
gap: 0.75rem;
padding: 1.5rem;
background: color-mix(in oklch, var(--card-color), white 90%);
border: 1px solid color-mix(in oklch, var(--card-color), transparent 70%);
border-radius: 12px;
view-transition-name: var(--card-id);
&:has(img) {
grid-template-rows: 180px subgrid;
grid-row: span 4;
}
&:hover {
border-color: color-mix(in oklch, var(--card-color), transparent 30%);
}
& h2 {
font-size: clamp(1rem, 3cqi, 1.5rem);
}
& img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
@container cards (width < 400px) {
padding: 1rem;
}
}
/* Tooltip with anchor positioning */
.card [popovertarget] {
anchor-name: --card-info;
}
.card [popover] {
position-anchor: --card-info;
inset-area: top center;
margin-bottom: 8px;
&::backdrop {
background: transparent;
}
}
}
Quick Reference
| Feature | Use Case |
|---|---|
@layer | Manage specificity in large codebases |
| Nesting | Component-scoped styles without preprocessors |
:has() | Parent selection, form validation, empty states |
| Container queries | Component-level responsive design |
| Subgrid | Align nested content to parent grid |
| View transitions | Page/state change animations |
| Scroll animations | Scroll-linked effects without JS |
| Anchor positioning | Tooltips, dropdowns, popovers |
| Popover API | Modal-like UI with native behavior |
| OKLCH / color-mix | Consistent color palettes, dynamic theming |