4 min read
On this page

Modern CSS

CSS Has Changed

The CSS you learned in 2015 is not the CSS of today. Native nesting, container queries, the :has() selector, cascade layers, and powerful math functions have arrived in browsers. Many patterns that once required preprocessors or JavaScript are now built into the platform.

This topic covers the modern features that change how you write CSS.

Custom Properties (Variables)

CSS custom properties are variables that cascade, inherit, and can be updated at runtime -- something preprocessor variables cannot do.

:root {
  --color-primary: #3b82f6;
  --color-text: #1a1a2e;
  --spacing-sm: 8px;
  --spacing-md: 16px;
  --spacing-lg: 24px;
  --radius: 6px;
}

.button {
  background: var(--color-primary);
  color: white;
  padding: var(--spacing-sm) var(--spacing-md);
  border-radius: var(--radius);
}

Scoped Variables

Custom properties cascade, so you can override them at any level:

.card {
  --card-padding: 16px;
  padding: var(--card-padding);
}

.card.compact {
  --card-padding: 8px;
  /* padding automatically uses the new value */
}

Fallback Values

.element {
  color: var(--color-accent, #e63946);  /* Uses #e63946 if --color-accent is not defined */
}

Runtime Updates with JavaScript

// Change a variable for the entire page
document.documentElement.style.setProperty('--color-primary', '#e63946');

// Change a variable for a specific element
element.style.setProperty('--card-padding', '24px');

This is impossible with Sass variables. Custom properties exist in the browser, not just at build time.

CSS Math Functions

calc()

Combine different units in a single expression:

.sidebar {
  width: calc(100% - 250px);    /* Full width minus fixed sidebar */
}

.container {
  padding: calc(var(--spacing-md) * 2);
}

clamp()

Sets a value with a minimum, preferred, and maximum:

.container {
  width: clamp(320px, 90%, 1200px);
  /* At least 320px, prefer 90% of parent, at most 1200px */
}

/* Fluid typography (no media queries needed) */
h1 {
  font-size: clamp(1.5rem, 4vw, 3rem);
  /* Minimum 1.5rem, scales with viewport, maximum 3rem */
}

min() & max()

.element {
  width: min(100%, 600px);    /* Whichever is smaller */
  padding: max(16px, 2vw);    /* Whichever is larger */
}

These functions eliminate many media queries. Instead of breakpoint-based sizing, you define a range and let the browser figure it out.

Container Queries

Media queries respond to the viewport size. Container queries respond to the parent container size. This is a fundamental shift for component-based design.

/* Define a containment context */
.card-container {
  container-type: inline-size;
  container-name: card;
}

/* Style based on the container's width, not the viewport */
@container card (min-width: 400px) {
  .card {
    display: grid;
    grid-template-columns: 150px 1fr;
    gap: 16px;
  }
}

@container card (max-width: 399px) {
  .card {
    display: flex;
    flex-direction: column;
  }

  .card img {
    width: 100%;
    aspect-ratio: 16 / 9;
    object-fit: cover;
  }
}

A card component can now adapt to its container, not the viewport. The same component works in a sidebar (narrow), a main content area (medium), or a full-width hero (wide) without any changes to the component itself.

Container query units (cqi, cqb) let you size elements relative to the container, similar to viewport units but scoped to the container.

The :has() Selector

Often called the "parent selector," :has() selects an element based on what it contains. CSS could never do this before.

/* Style a card differently when it contains an image */
.card:has(img) {
  grid-template-columns: 200px 1fr;
}

.card:not(:has(img)) {
  grid-template-columns: 1fr;
}

/* Style a form group when its input is invalid */
.form-group:has(input:invalid) {
  border-color: #ef4444;
}

/* Style a label when its sibling checkbox is checked */
label:has(+ input:checked) {
  font-weight: bold;
}

/* Enable a submit button when the form is valid */
form:has(:invalid) .submit-button {
  opacity: 0.5;
  pointer-events: none;
}

:has() reduces the need for JavaScript to toggle class names based on child state.

Native CSS Nesting

CSS nesting is now built into the language. No preprocessor required.

.card {
  padding: 16px;
  border-radius: 8px;
  background: white;

  .card-title {
    font-size: 1.25rem;
    margin-bottom: 8px;
  }

  .card-body {
    color: #555;
    line-height: 1.6;
  }

  &:hover {
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  }

  @media (min-width: 768px) {
    padding: 24px;
  }
}

The & represents the parent selector, just like in Sass. For element selectors, you can nest directly. Media queries can be nested inside rules.

The & represents the parent selector, required for element selectors (& p {}) and pseudo-classes (&:hover {}). Class selectors can be nested directly (.child {}).

Cascade Layers (@layer)

Layers give you explicit control over the cascade order, regardless of specificity or source order.

/* Define layer order (first = lowest priority) */
@layer reset, base, components, utilities;

@layer reset {
  * { margin: 0; padding: 0; box-sizing: border-box; }
}

@layer base {
  body { font-family: system-ui; line-height: 1.6; }
  a { color: var(--color-primary); }
}

@layer components {
  .button { padding: 8px 16px; border-radius: 4px; }
  .card { padding: 16px; background: white; }
}

@layer utilities {
  .hidden { display: none; }
  .text-center { text-align: center; }
}

Styles in later layers always win over styles in earlier layers, regardless of specificity. A utility class in the utilities layer beats a component style in the components layer, even if the component selector is more specific.

This solves the problem of utility classes being overridden by component styles (a constant pain with CSS frameworks).

Logical Properties

Logical properties replace physical properties (left, right, top, bottom) with flow-relative ones. They work correctly for all writing modes and text directions.

/* Physical (only correct for LTR, horizontal) */
.old-way {
  margin-left: 16px;
  padding-top: 8px;
  border-right: 1px solid #ccc;
}

/* Logical (works for any writing mode or direction) */
.new-way {
  margin-inline-start: 16px;
  padding-block-start: 8px;
  border-inline-end: 1px solid #ccc;
}
Physical Logical Meaning
left / right inline-start / inline-end Along the text direction
top / bottom block-start / block-end Along the stacking direction
width inline-size Size along text direction
height block-size Size along stacking direction

Shorthand margin-inline, margin-block, padding-inline, and padding-block replace directional pairs. Use logical properties for RTL support and future-proof CSS.

Other notable additions: aspect-ratio for intrinsic ratios (aspect-ratio: 16 / 9), accent-color for styling form controls, and color-mix() for blending colors in CSS.

Common Pitfalls

  • Not using custom properties for theming: Hardcoding colors and spacing throughout your CSS makes changes painful. Define a design token system with custom properties.
  • Overusing nesting: Deep nesting creates high-specificity selectors that are hard to override. Keep nesting to 2-3 levels.
  • Ignoring container queries for components: If you are writing media queries inside a reusable component, you probably want container queries instead.
  • Forgetting fallbacks for older browsers: Check browser support on caniuse.com. Features like :has() and container queries are widely supported now but may not be available in older browser versions your users need.
  • Not defining @layer order upfront: Without an explicit order declaration, layers are ordered by first appearance. Declare all layers at the top of your stylesheet.
  • Using calc() where clamp() is cleaner: clamp(min, preferred, max) replaces many calc() + media query combinations.

Key Takeaways

  • Custom properties cascade, inherit, and can be updated at runtime -- use them for design tokens
  • clamp(), min(), and max() reduce the need for media queries
  • Container queries make components respond to their container, not the viewport
  • :has() selects elements based on their contents -- the missing piece CSS always needed
  • Native nesting eliminates the need for Sass/Less in many projects
  • Cascade layers (@layer) give you explicit control over the cascade
  • Logical properties (inline-start, block-end) work correctly for all writing modes
  • Modern CSS is powerful enough that preprocessors are often unnecessary