On this page

Modern CSS in 2026

15 min read TextCh. 5 — Modern CSS

The state of CSS in 2026

CSS has evolved dramatically in recent years. Features that previously required preprocessors or JavaScript are now native to the browser. This lesson covers the most important additions.

Native CSS Nesting

You no longer need Sass or Less to nest selectors. Native CSS supports nesting:

.nav {
  display: flex;
  gap: 1rem;

  & a {
    color: inherit;
    text-decoration: none;

    &:hover {
      color: #b056ff;
    }
  }

  @media (width < 768px) {
    flex-direction: column;
  }
}

The & represents the parent selector. For pseudo-classes and pseudo-elements, the & is optional. For type selectors (p, h1), it is required.

The :has() selector

:has() is the "parent selector" that CSS never had. It lets you style an element based on what it contains:

/* Card that contains an image: different layout */
.card:has(img) {
  display: grid;
  grid-template-rows: auto 1fr;
}

/* Form with invalid fields: disabled button */
form:has(:invalid) button[type="submit"] {
  opacity: 0.5;
  pointer-events: none;
}

/* Sidebar open: adjust main */
body:has(.sidebar.open) .main {
  margin-inline-start: 280px;
}

Common use cases for :has()

  • Visual form validation without JavaScript
  • Conditional layouts based on content
  • Global states (sidebar open, modal visible)
  • Sibling styles (.item:has(+ .item-active))

The light-dark() function

Greatly simplifies light/dark theming:

:root {
  color-scheme: light dark;
}

body {
  background: light-dark(#fff, #0a0a0f);
  color: light-dark(#1a1a2e, #e8e8e8);
}

You no longer need to duplicate blocks with @media (prefers-color-scheme: dark) for each property.

@scope: style encapsulation

@scope limits the reach of styles to a specific DOM tree, avoiding collisions:

@scope (.panel) {
  h2 { font-size: 1.5rem; }
  p { color: #555; }
}

With the lower boundary (to), you can exclude parts of the tree:

@scope (.panel) to (.panel__slot) {
  /* Styles that do NOT affect the slot content */
  p { margin: 0; }
}

Anchor positioning

Position tooltips, popovers, and dropdown menus relative to an anchor element, without JavaScript:

.trigger {
  anchor-name: --my-button;
}

.popover {
  position: fixed;
  position-anchor: --my-button;
  top: anchor(bottom);
  left: anchor(center);
  translate: -50% 0.5rem;
}

Advanced math functions

CSS now includes full math functions:

Function Use
round() Round values
mod() Modulo (remainder)
rem() Remainder with dividend sign
abs() Absolute value
sign() Sign of the value (-1, 0, 1)
pow(), sqrt(), log() Advanced operations
sin(), cos(), tan() Trigonometric functions
.element {
  /* Round to the nearest multiple of 4px */
  padding: round(nearest, 1.3rem, 4px);
}

Scroll-driven animations

Animate elements based on the scroll position, without JavaScript:

@keyframes appear {
  from { opacity: 0; scale: 0.9; }
  to   { opacity: 1; scale: 1; }
}

.section {
  animation: appear linear both;
  animation-timeline: view();
  animation-range: entry 10% entry 40%;
}

view() creates a timeline based on when the element enters and leaves the viewport.

View Transitions API

Animated transitions between views in a SPA or between pages:

@view-transition {
  navigation: auto;
}

::view-transition-old(root) {
  animation: fade-out 300ms ease;
}

::view-transition-new(root) {
  animation: fade-in 300ms ease;
}

Styling Forms

Forms are one of the hardest parts of CSS to style. New modern pseudo-classes and properties change this entirely.

Validation states: `:valid`, `:invalid`, `:user-invalid`

CSS can react to the validation state of form fields:

/* Applied ONLY after user interaction */
.field:user-invalid {
  border-color: #e74c3c;
  box-shadow: 0 0 0 3px rgb(231 76 60 / 20%);
}

.field:valid {
  border-color: #2ecc71;
}

[!TIP] Prefer :user-invalid over :invalid. The :invalid pseudo-class marks fields as invalid immediately on page load, while :user-invalid waits until the user has interacted with the field.

Floating labels with `:placeholder-shown`

The "floating label" pattern can be achieved without JavaScript, using :placeholder-shown and the adjacent sibling combinator ~:

.field-group {
  position: relative;
}

.field-group .field::placeholder {
  color: transparent; /* Hide the real placeholder */
}

.field-group .label {
  position: absolute;
  top: 50%;
  left: 0.75rem;
  translate: 0 -50%;
  transition: all 200ms ease;
  color: #888;
  pointer-events: none;
}

/* When the field has text or is focused, the label moves up */
.field-group .field:not(:placeholder-shown) ~ .label,
.field-group .field:focus ~ .label {
  top: 0;
  translate: 0 -50%;
  font-size: 0.75rem;
  background: white;
  padding-inline: 0.25rem;
  color: #b056ff;
}

`accent-color` and `caret-color`

Theming native browser controls (checkboxes, radios, range sliders) is now trivial:

input[type="checkbox"],
input[type="radio"],
input[type="range"] {
  accent-color: #b056ff;
}

input, textarea {
  caret-color: #b056ff; /* Text cursor color */
}

accent-color tints the primary color of native controls. The browser handles contrast automatically.

`field-sizing: content` for textareas

A new property that allows <textarea> elements to grow automatically based on their content:

.textarea-auto {
  field-sizing: content;
  min-height: 3lh;  /* Minimum 3 lines */
  max-height: 10lh; /* Maximum 10 lines */
}

[!INFO] The lh unit equals the computed line-height of the element. It is ideal for defining heights based on lines of text.

Customizing `

Nesting vs Sass
Native CSS nesting works just like Sass, with one difference: for type selectors (h1, p) you must use & or :is(). Example: .card { & h1 { } } or .card { :is(h1) { } }. With classes and pseudo-classes, the & is not needed.
Support for :has()
The :has() selector has been supported in all modern browsers since 2023. It is one of the most important additions to CSS in the last decade and eliminates many cases where JavaScript was previously needed.
:user-invalid vs :invalid
The :invalid pseudo-class marks a field as invalid from the start, before the user interacts with it. :user-invalid only activates after the user has interacted with the field, providing a much less aggressive experience.
/* Native CSS Nesting (no preprocessors) */
.card {
  background: white;
  border-radius: 12px;
  padding: 1.5rem;
  border: 1px solid #e0e0e0;
  transition: border-color 200ms ease;

  &:hover {
    border-color: #b056ff;
  }

  & .title {
    font-size: 1.25rem;
    font-weight: 600;
    margin-block-end: 0.5rem;
  }

  & .description {
    color: #666;
    line-height: 1.6;
  }

  /* Nested media queries */
  @media (width >= 768px) {
    padding: 2rem;
  }
}

/* Scope: encapsulate styles */
@scope (.panel) to (.panel__content) {
  p { color: #333; font-size: 0.9rem; }
  a { color: #b056ff; }
}

/* Anchor positioning */
.tooltip-trigger {
  anchor-name: --my-trigger;
}

.tooltip {
  position: fixed;
  position-anchor: --my-trigger;
  top: anchor(bottom);
  left: anchor(center);
  translate: -50% 8px;
  background: #1a1a2e;
  color: white;
  padding: 0.5rem 1rem;
  border-radius: 6px;
}
/* :has() - the missing parent selector */
.form:has(:invalid) .submit-button {
  opacity: 0.5;
  pointer-events: none;
}

/* Card with image vs without image */
.card:has(> img) {
  grid-template-rows: 200px 1fr;
}

.card:not(:has(> img)) {
  padding-block-start: 2rem;
}

/* Styles based on checkbox state */
.filter:has(input:checked) {
  background: oklch(90% 0.1 260);
  border-color: #b056ff;
}

/* Advanced math functions */
.grid-items {
  --cols: 3;
  display: grid;
  grid-template-columns: repeat(var(--cols), 1fr);
  gap: round(nearest, 1.5rem, 0.25rem);
}

/* light-dark() for theming */
:root {
  color-scheme: light dark;
}

.surface {
  background: light-dark(#ffffff, #1a1a2e);
  color: light-dark(#1a1a2e, #e8e8e8);
  border: 1px solid light-dark(
    rgb(0 0 0 / 10%),
    rgb(255 255 255 / 10%)
  );
}
/* Validation states */
.field:user-invalid {
  border-color: #e74c3c;
  box-shadow: 0 0 0 3px rgb(231 76 60 / 20%);
}

.field:valid {
  border-color: #2ecc71;
}

/* Floating labels with :placeholder-shown */
.field-group {
  position: relative;
}

.field-group .field::placeholder {
  color: transparent;
}

.field-group .label {
  position: absolute;
  top: 50%;
  left: 0.75rem;
  translate: 0 -50%;
  transition: all 200ms ease;
  color: #888;
  pointer-events: none;
}

.field-group .field:not(:placeholder-shown) ~ .label,
.field-group .field:focus ~ .label {
  top: 0;
  translate: 0 -50%;
  font-size: 0.75rem;
  background: white;
  padding-inline: 0.25rem;
  color: #b056ff;
}

/* Theme native controls */
.checkbox-field,
.radio-field,
.range-field {
  accent-color: #b056ff;
}

.field {
  caret-color: #b056ff;
}

/* Auto-sizing textarea */
.textarea-auto {
  field-sizing: content;
  min-height: 3lh;
  max-height: 10lh;
}

/* Custom select */
.custom-select {
  appearance: none;
  background: white url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E") no-repeat right 0.75rem center;
  padding: 0.5rem 2rem 0.5rem 0.75rem;
  border: 1px solid #ccc;
  border-radius: 6px;
}

/* :focus-visible vs :focus */
.field:focus-visible {
  outline: 2px solid #b056ff;
  outline-offset: 2px;
}

.button:focus-visible {
  outline: 2px solid #b056ff;
  outline-offset: 2px;
}

/* :focus without :focus-visible — mouse only */
.button:focus:not(:focus-visible) {
  outline: none;
}