Home Module 08 Modern CSS Features

1. Introduction

CSS has evolved dramatically. Features that once required JavaScript, preprocessors, or complex hacks are now native in the browser. This lesson surveys four groups of modern CSS that every working frontend developer should know:

  1. Math functionsclamp(), min(), max() for intrinsic responsive values
  2. Container queries — style a component based on its container's size, not the viewport
  3. Logical selectors:is(), :where(), :has() for cleaner, more powerful selectors
  4. Cascade layers@layer for predictable specificity management

All four features have strong browser support (2022–2024) and are safe to use in new projects today.

2. Theory

2.1 CSS Math Functions

clamp(min, preferred, max)

Returns a value clamped between a minimum and maximum. Already covered in Lesson 07-04 for fluid typography — here is the full picture:

/* Font size */
h1 { font-size: clamp(1.5rem, 5vw, 3rem); }

/* Padding */
.section { padding: clamp(1rem, 5vw, 4rem); }

/* Width */
.prose { width: clamp(45ch, 60%, 75ch); }
/* ch = width of the "0" glyph — ideal for limiting line length */

min() and max()

/* min() returns the SMALLEST of its arguments */
.sidebar { width: min(300px, 100%); }
/* On small screens: 100% (fits). On large: 300px (doesn't overflow) */

/* max() returns the LARGEST of its arguments */
.content { padding: max(1rem, 4vw); }
/* Always at least 1rem, grows with viewport */

/* Combine */
.card { width: min(max(200px, 30%), 400px); }
/* At least 200px, at most 400px, tries for 30% */

2.2 Container Queries

Media queries respond to the viewport. Container queries respond to the element's container. This makes components truly portable — the same card component can have a different layout whether it is in a wide sidebar or a narrow modal.

Step 1 — Define a containment context

.card-wrapper {
  container-type: inline-size; /* enables width-based container queries */
  container-name: card;         /* optional name */
}

Step 2 — Query the container

/* Default (narrow): stacked layout */
.card { display: flex; flex-direction: column; }

/* When the container is at least 400px wide: side-by-side */
@container (min-width: 400px) {
  .card { flex-direction: row; }
  .card-image { width: 40%; }
}

/* Named container query */
@container card (min-width: 500px) {
  .card-title { font-size: 1.5rem; }
}

Container query units

UnitRelative to
cqw1% of the container's width
cqh1% of the container's height
cqi1% of the container's inline size
cqb1% of the container's block size
.card-title { font-size: clamp(1rem, 5cqi, 1.5rem); }

2.3 Logical Selectors

:is() — match any of a list

:is() takes a selector list and matches any element that matches any selector in the list. It reduces repetition and its specificity is the highest-specificity item in the list.

/* Without :is() — verbose */
h1 a, h2 a, h3 a, h4 a, h5 a, h6 a { color: inherit; }

/* With :is() — clean */
:is(h1, h2, h3, h4, h5, h6) a { color: inherit; }

/* Nesting selector lists */
:is(article, section, aside) :is(h1, h2, h3) {
  margin-top: 0;
}

:where() — zero specificity

:where() works like :is() but its specificity is always zero. Use it for default / reset styles that should be easy to override.

/* Resets with :where() are easy to override with any selector */
:where(h1, h2, h3) { font-weight: 700; line-height: 1.2; }

/* Override with minimal specificity */
.blog-post h1 { font-weight: 900; } /* wins easily */

:has() — the "parent selector"

:has() selects an element if it contains a child matching the argument. This was impossible in CSS before 2023 and is one of the most powerful additions to the language.

/* Card that contains an image gets different padding */
.card:has(img) { padding: 0; }
.card:has(img) .card-body { padding: 1rem; }

/* Form row that has an invalid input gets a red border */
.form-row:has(input:invalid) { border-color: crimson; }

/* Navigation with an open dropdown */
nav:has(.dropdown.open) { box-shadow: 0 4px 12px rgba(0,0,0,.15); }

/* Figure with a caption — add extra bottom padding */
figure:has(figcaption) { padding-bottom: 1.5rem; }

/* Select li that is followed by a sibling li with .active */
li:has(+ li.active) { font-weight: bold; }

2.4 Cascade Layers (@layer)

Cascade layers let you define explicit priority groups for your CSS. Styles in a later layer override styles in an earlier layer, regardless of specificity. This makes specificity battles much easier to manage in large codebases.

Declaring layers

/* Order declaration — later layers win */
@layer reset, base, components, utilities;

@layer reset {
  *, *::before, *::after { box-sizing: border-box; margin: 0; }
}

@layer base {
  body { font-family: system-ui, sans-serif; }
  a { color: var(--color-primary); }
}

@layer components {
  .btn {
    padding: 0.5rem 1rem;
    background: var(--color-primary);
    color: white;
    border-radius: 4px;
  }
}

@layer utilities {
  .mt-4 { margin-top: 1rem; }
  .text-center { text-align: center; }
}

Layer specificity rules

  • Styles inside layers are lower priority than styles outside layers (unlayered styles always win).
  • Among layered styles, later layers win regardless of specificity.
  • Within the same layer, normal specificity rules apply.
/* Unlayered styles always beat layered — useful for critical overrides */
.btn { color: red; } /* wins over any @layer rule */

Importing layers

/* Third-party library in its own layer — can't pollute your specificity */
@import url("bootstrap.css") layer(bootstrap);

@layer bootstrap, base, components;

2.5 Native CSS Nesting

As of 2023, all major browsers support native CSS nesting — no SASS needed for this feature:

.card {
  background: white;
  padding: 1rem;

  /* Nested rule — equivalent to .card:hover */
  &:hover { box-shadow: 0 4px 12px rgba(0,0,0,.1); }

  /* Equivalent to .card .card-title */
  .card-title {
    font-size: 1.25rem;
    font-weight: 700;

    /* Equivalent to .card .card-title a */
    a { color: inherit; }
  }

  /* Equivalent to .card + .card */
  & + & { margin-top: 1rem; }
}

2.6 :focus-visible vs :focus

/* Only show focus ring when keyboard navigating (not on mouse click) */
.btn:focus-visible {
  outline: 2px solid royalblue;
  outline-offset: 3px;
}
.btn:focus:not(:focus-visible) {
  outline: none;
}

3. Real World Example

A modern component library might combine all these features:

  • @layerreset → tokens → base → components → utilities — third-party CSS imported into its own layer so it never overrides component styles.
  • Container queries — a card component that becomes a horizontal media object when its container is wider than 450 px. The same component works in a narrow widget sidebar and a full-width article feed.
  • :has() — form row highlights red when it :has(input:invalid); label turns bold when the input is focused :has(input:focus).
  • clamp() — all font sizes, paddings, and gaps use clamp() rather than media-query breakpoints, reducing the CSS by hundreds of lines.
  • Native nesting — component CSS files read like SASS, with no build step.

4. Code Example

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Modern CSS Demo</title>
  <style>
    /* ---- Cascade Layers ---- */
    @layer reset, base, components;

    @layer reset {
      *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
      img { max-width: 100%; display: block; }
    }

    @layer base {
      :root {
        --primary: royalblue;
        --surface: white;
        --bg: #f0f4f8;
        --text: #1a202c;
        --radius: 10px;
        --gap: clamp(0.75rem, 2vw, 1.5rem);
      }
      body {
        font-family: system-ui, sans-serif;
        background: var(--bg);
        color: var(--text);
        padding: var(--gap);
      }
    }

    @layer components {
      /* ---- Container Query Card ---- */
      .card-wrapper {
        container-type: inline-size;
        container-name: card;
      }

      .media-card {
        background: var(--surface);
        border-radius: var(--radius);
        overflow: hidden;
        box-shadow: 0 2px 8px rgba(0,0,0,.08);
        display: flex;
        flex-direction: column;
      }

      /* When card container >= 400px: horizontal layout */
      @container card (min-width: 400px) {
        .media-card {
          flex-direction: row;
          align-items: stretch;
        }
        .media-card .card-image {
          width: 40%;
          flex-shrink: 0;
        }
      }

      .card-image {
        background: linear-gradient(135deg, royalblue, mediumpurple);
        min-height: 120px;
      }

      .card-body {
        padding: clamp(0.75rem, 3cqi, 1.5rem);
      }

      .card-title {
        font-size: clamp(1rem, 4cqi, 1.4rem);
        font-weight: 700;
        margin-bottom: 0.5rem;
      }

      /* ---- :is() selector ---- */
      :is(h1, h2, h3) { line-height: 1.2; }

      /* ---- :has() — highlight form row on invalid input ---- */
      .form-row {
        margin-bottom: 1rem;
      }
      .form-row label {
        display: block;
        font-size: 0.9rem;
        margin-bottom: 0.25rem;
        color: var(--text);
        transition: color 0.2s;
      }
      .form-row input {
        width: 100%;
        padding: 0.5rem 0.75rem;
        border: 2px solid #ccc;
        border-radius: 6px;
        font-size: 1rem;
        outline: none;
        transition: border-color 0.2s;
      }
      /* :has() — parent responds to child state */
      .form-row:has(input:focus) label { color: royalblue; }
      .form-row:has(input:focus) input { border-color: royalblue; }
      .form-row:has(input:invalid:not(:placeholder-shown)) label { color: crimson; }
      .form-row:has(input:invalid:not(:placeholder-shown)) input { border-color: crimson; }

      /* ---- Native Nesting ---- */
      .btn {
        display: inline-block;
        padding: 0.5rem 1.25rem;
        background: var(--primary);
        color: white;
        border: none;
        border-radius: 6px;
        cursor: pointer;
        font-size: 1rem;
        transition: background 0.2s, transform 0.15s;

        &:hover { background: #1a3dbf; transform: translateY(-1px); }
        &:active { transform: translateY(0); }
        &:focus-visible {
          outline: 3px solid var(--primary);
          outline-offset: 3px;
        }
      }

      /* ---- Layout grid ---- */
      .demo-grid {
        display: grid;
        grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
        gap: var(--gap);
        margin-bottom: var(--gap);
      }
    }
  </style>
</head>
<body>

<h1 style="margin-bottom:1rem;font-size:clamp(1.5rem,4vw,2.5rem)">Modern CSS Demo</h1>

<!-- Container query cards -->
<div class="demo-grid">
  <div class="card-wrapper">
    <div class="media-card">
      <div class="card-image"></div>
      <div class="card-body">
        <p class="card-title">Narrow Card</p>
        <p>Stacked layout. Resize the window to see it go horizontal when wide enough.</p>
      </div>
    </div>
  </div>

  <div class="card-wrapper" style="grid-column: span 2; max-width: 600px;">
    <div class="media-card">
      <div class="card-image"></div>
      <div class="card-body">
        <p class="card-title">Wide Card — Horizontal</p>
        <p>This container is wider so the card switches to a row layout automatically.</p>
      </div>
    </div>
  </div>
</div>

<!-- :has() form demo -->
<div style="max-width:400px;background:white;padding:1.5rem;border-radius:10px;margin-bottom:1rem">
  <h2 style="margin-bottom:1rem">:has() Form Demo</h2>
  <div class="form-row">
    <label for="email">Email</label>
    <input type="email" id="email" placeholder="you@example.com" required>
  </div>
  <div class="form-row">
    <label for="name">Name</label>
    <input type="text" id="name" placeholder="Your name" minlength="2" required>
  </div>
  <button class="btn">Submit</button>
</div>

</body>
</html>

5. Code Breakdown

@layer (lines 5–7)

Declaring all layers upfront in order ensures their priority. Even if you write @layer reset code after @layer components code in the file, the declared order takes precedence. Styles in components always win over base which wins over reset.

Container query (lines 30–41)

container-type: inline-size makes .card-wrapper a containment context. The @container card (min-width: 400px) rule applies only when that specific container is 400 px or wider — not the viewport. This means the same card can go horizontal inside a wide main column while staying vertical inside a narrow sidebar, on the same page.

clamp() with container units (lines 48–53)

font-size: clamp(1rem, 4cqi, 1.4rem)cqi is 1% of the container's inline (width) size. The title font scales with the card width rather than the viewport, which is semantically correct.

:has() form highlighting (lines 68–75)

.form-row:has(input:focus) label selects the label that is a sibling of a focused input — something that was previously impossible in pure CSS. The :not(:placeholder-shown) guard ensures the invalid style only appears after the user has started typing (the placeholder is hidden), preventing the input from showing as invalid immediately on page load.

Native nesting on .btn (lines 78–88)

The & represents the parent selector (.btn). &:hover is equivalent to .btn:hover. This is identical to SASS nesting syntax and requires no build step in 2024.

min() inside minmax() (line 94)

minmax(min(100%, 300px), 1fr) — each column is at least min(100%, 300px): on small screens where 300 px would overflow, it uses 100%; on large screens it uses 300 px. This is a cleaner alternative to the common minmax(300px, 1fr) which causes horizontal overflow on screens narrower than 300 px.

6. Common Mistakes

Mistake 1 — Querying a container without setting container-type

/* Bad — @container has no context to measure */
.card { /* no container-type set */ }
@container (min-width: 400px) { /* never fires */ }

/* Good */
.card-wrapper { container-type: inline-size; }
@container (min-width: 400px) { .card { ... } }

Mistake 2 — Applying container-type to the element being styled

/* Bad — card queries itself, causing circular dependency */
.card { container-type: inline-size; }
@container (min-width: 400px) { .card { flex-direction: row; } }

/* Good — wrapper is the container, card is the queried element */
.card-wrapper { container-type: inline-size; }
@container (min-width: 400px) { .card { flex-direction: row; } }

Mistake 3 — Confusing :is() and :where() specificity

/* :is() takes specificity of its most specific argument */
:is(#header, .nav) a { color: red; } /* specificity: 1,0,1 (from #header) */

/* :where() always has 0 specificity */
:where(#header, .nav) a { color: red; } /* specificity: 0,0,1 */

/* Use :where() for base styles you want to be easy to override */

Mistake 4 — Using :has() as a general any-ancestor selector

:has() always selects the element that contains the argument, not an arbitrary ancestor. You cannot use it to select a grandparent.

/* :has() selects .form-row if it contains input:invalid */
.form-row:has(input:invalid) { border: 2px solid red; }
/* This works ✓ */

/* You cannot select the form this way */
form:has(.form-row input:invalid) { /* this also works, selects the form */ }

Mistake 5 — Forgetting that unlayered styles win over all layers

/* This unlayered rule will ALWAYS beat your @layer components styles */
.btn { background: green; }

/* Solution: put all your styles in layers */
@layer components { .btn { background: royalblue; } }
/* Now they are equal-level: later one in source order wins */

Mistake 6 — Using native nesting without checking target browser support

Native CSS nesting has excellent support since late 2023 but is not available in very old browsers. If you must support IE 11 or very old mobile browsers, use a PostCSS plugin or SASS as a fallback.

7. Best Practices

  1. Declare all @layer names upfront in a single line to make layer priority explicit and easy to understand.
  2. Use container queries for components, not just page sections — they make components truly portable.
  3. Name your containers (container-name) when you have nested containment contexts to avoid ambiguity.
  4. Use :where() for resets and defaults so consumer code can override them with minimal specificity.
  5. Use :is() for repetitive selector lists to reduce duplication and improve readability.
  6. Use :has() for parent-responds-to-child patterns — form row highlighting, card layout differences, conditional navigation styles.
  7. Replace media query font sizes with clamp() — one rule is more maintainable than three breakpoints.
  8. Use min() in grid minmax()minmax(min(100%, 300px), 1fr) prevents overflow on small screens.
  9. Leverage native nesting for component files when targeting modern browsers — it reduces selectors and improves co-location of related styles.
  10. Check caniuse.com before using any feature in production — know your target browser baseline.

8. Practice Exercise

Build a self-contained card component using modern CSS.

Requirements

  1. Set up @layer reset, base, components at the top of your stylesheet.
  2. Build a .media-card component:
    • Stacked (image above text) by default.
    • Horizontal (image beside text) when the container is wider than 450 px — use a container query.
  3. Use clamp() for the card title's font size and for section padding.
  4. Add a contact form with email, name, and message fields. Use :has(input:invalid:not(:placeholder-shown)) to highlight form rows with errors.
  5. Use :is() to apply the same heading styles to h1, h2, and h3 in one rule.
  6. Write button styles using native CSS nesting.

Bonus

  • Use :has(img) to give cards that contain images zero top padding (letting the image touch the edge).
  • Add a container-query-based grid: 1 column <400 px, 2 columns >400 px, 3 columns >700 px.

9. Assignment

Modernise a blog or portfolio page using the techniques from this lesson.

  1. Introduce @layer into the stylesheet — assign all existing rules to appropriate layers.
  2. Replace at least 3 sets of media-query font sizes with clamp().
  3. Convert the main article card to respond to its container (not the viewport) using a container query.
  4. Use :has() in at least two meaningful ways (e.g., navigation highlighting, form validation, or content-conditional layout).
  5. Replace any repetitive selector lists with :is() or :where().
  6. Convert one component's styles to use native CSS nesting.
  7. Test the page in Chrome DevTools — confirm no layout shift occurs on load and all container queries fire at the correct widths.

Deliverable: One HTML file with embedded CSS. Leave a comment block at the top explaining which modern feature you used and why.

10. Interview Questions

  1. What is the difference between a media query and a container query?
    A media query responds to the viewport size. A container query responds to the size of a designated parent element. Container queries make components portable — the same component can adapt to its surroundings regardless of screen size.
  2. How do you set up a container query?
    Set container-type (inline-size or size) on the parent element to create a containment context. Then use @container (min-width: N) { } to style children when the container reaches that width.
  3. What is the difference between :is() and :where()?
    Both match elements against a selector list. :is() has the specificity of its most specific argument. :where() always has zero specificity — it is ideal for resets and defaults that should be easy to override.
  4. What does :has() do and why is it called the "parent selector"?
    :has() selects an element if it contains a descendant matching the argument. E.g., .card:has(img) selects cards that contain an image. It was historically impossible to select a parent in CSS, which is why it was called the "parent selector."
  5. What are cascade layers and why are they useful?
    @layer groups CSS rules into priority buckets. Later-declared layers override earlier ones, regardless of specificity. This eliminates specificity wars in large codebases and lets you safely import third-party CSS into its own layer without it conflicting with your own styles.
  6. What does clamp(1rem, 4vw, 2rem) mean?
    The value is at least 1rem, at most 2rem, and prefers 4vw. The result scales fluidly between 1rem and 2rem as the viewport grows, eliminating the need for multiple breakpoints for font sizing.
  7. Is native CSS nesting supported in browsers today?
    Yes — native CSS nesting (using & as the parent reference) is supported in all major browsers as of 2023/2024. No build tool is required for modern browser targets.

11. Additional Resources

  • MDN — CSS container queries — full specification and examples
  • MDN — :has() pseudo-class
  • MDN — @layer — cascade layers reference
  • MDN — :is() and :where()
  • web.dev — New CSS features in 2023 — container queries, :has(), cascade layers, nesting
  • caniuse.com — check browser support for any feature
  • CSS Tricks — A Complete Guide to CSS Container Queries
  • Ahmad Shadeed's blog — deep dives into :has() practical use cases