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);
}
UnitDescription
cqw1% of container width
cqh1% of container height
cqi1% of container inline size
cqb1% 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);
}
ScrollerDescription
nearestNearest scrollable ancestor (default)
rootDocument viewport
selfElement itself
AxisDescription
blockBlock direction (vertical in LTR)
inlineInline 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 */
}
ComponentRangeDescription
Lightness0-100%0 = black, 100 = white
Chroma0-0.4+Color intensity (0 = gray)
Hue0-360Color 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

FeatureUse Case
@layerManage specificity in large codebases
NestingComponent-scoped styles without preprocessors
:has()Parent selection, form validation, empty states
Container queriesComponent-level responsive design
SubgridAlign nested content to parent grid
View transitionsPage/state change animations
Scroll animationsScroll-linked effects without JS
Anchor positioningTooltips, dropdowns, popovers
Popover APIModal-like UI with native behavior
OKLCH / color-mixConsistent color palettes, dynamic theming