4. CSS

HTML says what content is; CSS (Cascading Style Sheets) says how it looks. Every Bootstrap class in Chapter 3 is CSS underneath. You state which elements get which values, and the browser computes the result for every element on every frame.CSS has changed more since 2020 than in the decade before. Nesting, cascade layers, container queries, :has(), subgrid and anchor positioning now work in all three major engines, and many jobs that once needed JavaScript or a preprocessor take a few lines of plain CSS.This chapter teaches that modern language, from syntax and the cascade to animation and responsive design, and ends with the tools around it.What you will learn:
  • How the cascade, inheritance and specificity decide every value

  • Units, colors, custom properties, selectors and at-rules

  • The box model, flexbox, grid and anchor positioning

  • Smooth transitions and animations, and responsive design with media and container queries

  • Sass, Less, PostCSS, frameworks and CSS architecture


  • 4.1 CSS Fundamentals

    Four ideas explain most CSS surprises: how a rule is written, how it reaches the page, how the browser picks one value among competing rules, and what work a change forces the browser to redo.

    4.1.1 Syntax and Rulesets

    A style sheet holds two kinds of statement. A ruleset pairs a selector list with a declaration block in braces; each declaration is a property: value pair ending in a semicolon. An at-rule starts with @ and carries conditions or metadata, such as @media or @font-face (Section 4.4). Comments use /* ... */; CSS has no // comment.

    h1, .lead { /* selector list */

    color: #1565c0; /* declaration: property: value; */

    font: 600 1.3rem system-ui;

    colour: red; /* unknown property: silently skipped */

    }

    @media (width >= 40rem) { /* at-rule wrapping a nested ruleset */

    .lead { font-style: italic; }

    }

    Listing 4.1: Anatomy of a style sheet Parsing is forgiving: an unknown property or invalid value skips only that declaration, so you can put a fallback on the line above new syntax. Warning: If any selector in a list is invalid, the whole rule is dropped. h1, p::unknown { color: red } styles nothing, not even h1. Wrap experimental selectors in the forgiving :is() or :where() (Section 4.3.11).

    4.1.2 Applying CSS

    CSS reaches a page in three ways: an external file linked with <link rel="stylesheet">, an internal <style> element, or an inline style attribute. Below, a data: URL stands in for a file such as /css/site.css.

    <!doctype html>

    <link rel="stylesheet" href="data:text/css,p{color:%232e7d32;font:17px system-ui}">

    <style> p { color: #1565c0; } </style>

    <p>External font; the later internal rule makes it blue.</p>

    <p style="color: #ef6c00">The inline style beats both.</p>

    Listing 4.2: External, internal and inline styles on one page Figure 4.1: Browser output of Listing 4.2Prefer external files: the browser downloads them once and caches them for every page. Reserve <style> for small critical CSS and inline styles for values set by scripts; inline styles cannot use selectors or media queries, and only !important overrides them. Under the hood: A <link> stylesheet is render-blocking: nothing is painted until the CSSOM is built. With a non-matching media attribute (say media="print") it still downloads, at low priority, without blocking. Avoid @import in CSS: the preload scanner cannot see it, so the imported file becomes a late, chained, render-blocking request.

    4.1.3 The Cascade, Inheritance and Specificity: An Overview

    When several declarations set the same property on one element, the cascade picks a winner by asking, in order: which declarations apply (matching selector and media query); which origin and importance ranks highest; which cascade layer (Section 4.3.14); which selector has the higher specificity; which @scope root is nearer; and finally which declaration comes last. A later question is asked only on a tie. Rank Declarations 1 (highest) Running CSS transitions 2 !important: browser, then user, then author 3 Running CSS keyframe animations 4 Normal author styles, inline styles last 5 (lowest) Normal user styles, then browser defaults Table 4.1: Origins and importance in the cascade Author styles are yours; user styles come from the reader's settings or extensions. !important reverses the origins, so a reader who needs large text can always override you.Specificity compares ID-CLASS-TYPE columns from left to right: #intro (1-0-0) beats p.note (0-1-1), which beats p (0-0-1), whatever the source order. Inheritance fills gaps: with no declaration, inherited properties such as color and font take the parent's value, while others such as border take their initial value.

    <!doctype html>

    <style>

    body { font: 16px system-ui; border: 2px solid #90a4ae; padding: 0 8px; }

    #intro { color: #6a1b9a; } /* 1-0-0 */

    p.note { color: #2e7d32; } /* 0-1-1 */

    p { color: #ef6c00; } /* 0-0-1: last, but least specific */

    </style>

    <p id="intro" class="note">ID wins: purple. <em>The em inherits purple.</em></p>

    <p class="note">No ID, so the class rule wins: green.</p>

    Listing 4.3: Specificity picks the color; the child inherits it, but not the border Figure 4.2: Browser output of Listing 4.3 Tip: Hover a selector in the DevTools Styles pane (Section 1.3.1) to see its specificity; overridden declarations are struck through. Section 4.3.12 covers specificity in depth.

    4.1.4 How Browsers Turn CSS into Pixels

    Section 1.1.2 traced the whole rendering pipeline. For CSS work, what matters is what re-runs after a change: a new class, a :hover, an animation frame. The browser redoes work from the earliest stage the change invalidates, so the property you change sets the cost.Figure 4.3: What a CSS change re-runs: geometry changes pay for every stage, transform and opacity only for compositingStyle recalculation recomputes values for elements whose matching rules changed; toggling a class on <body> can touch every element. Layout (reflow) recomputes box sizes and positions, and one width change can move everything after it. Paint re-records drawing commands for the affected area. Composite combines already painted layers, can run off the main thread, and is all a transform or opacity change needs. Tip: Animate transform and opacity rather than top, left or width, and turn on Paint flashing in the DevTools Rendering tab to see repaints flash green. Each promoted layer costs GPU memory, so use will-change sparingly (Section 4.9.6).

    4.2 Values and Units

    Every property accepts only certain value types: width takes a <length> or <percentage>, transition-duration a <time>, background-image an <image>. A value of the wrong type makes the declaration invalid, and the browser silently drops it and keeps whatever the cascade produced before (Section 4.1).Three syntax rules apply everywhere. A unit follows its number with no space (10px, never 10 px). Only a zero length may omit its unit (margin: 0); times, angles and resolutions may not (0s, 0deg). Keywords such as auto and the global keywords of Section 4.2.9 are case-insensitive and never quoted. Under the hood: The specified value comes from the cascade, the computed value converts units such as em to pixels, the used value resolves percentages against the layout, and the actual value is rounded for the device. getComputedStyle() reports the used width in px even when you wrote %.

    4.2.1 Absolute Length Units

    Absolute units have fixed ratios to one another, and CSS anchors them all to the pixel: 1in is defined as exactly 96px, so 3in, 7.62cm, 288px and 216pt are the same length. Unit Name Equals px CSS pixel 1/96 in cm / mm / Q centimeter / millimeter / quarter-millimeter 37.8px / 3.78px / 0.945px in inch 2.54cm = 96px pt / pc point / pica 1/72 in / 12pt (1/6 in) Table 4.2: Absolute length units A CSS pixel is not a device pixel: at a device pixel ratio of 3, one CSS pixel covers 3x3 hardware pixels, and a "3-inch" bar on a monitor rarely measures three inches. Use px or relative units for screens and keep cm, mm, in and pt for print, where @page { margin: 2cm; } really measures two centimeters. Under the hood: The CSS pixel is a reference pixel: the visual angle of one pixel on a 96 dpi display held at arm's length. Browsers multiply CSS pixels by window.devicePixelRatio (which also changes with zoom) to get device pixels, then snap borders and box edges to whole device pixels so lines stay crisp.

    4.2.2 Relative Length Units

    Unit Relative to Baseline %, em, rem parent measurement, element font size, root font size Widely available ex ch cap ic lh (rlh) font metrics, line height (of the root) Widely available rex rch rcap ric root-font metrics Newly available (2026) vw vh vmin vmax, sv* lv* dv* 1% of the viewport (small, large, dynamic) Widely available cqw cqh cqi cqb cqmin cqmax 1% of the query container Widely available Table 4.3: Relative length units An em on font-size multiplies the parent's font size, so three nested levels of 1.2em give 1.728 times the base. rem always refers to the root, the safer unit for type scales; keep em for things that track the element's own text, such as a button's padding.

    <style>

    body { font: 16px sans-serif; margin: 8px; }

    .em { font-size: 1.2em; border-left: 4px solid #ef6c00; padding-left: 6px; }

    .rem { font-size: 1.2rem; border-left: 4px solid #2e7d32; padding-left: 6px; }

    .card { container-type: inline-size; background: #e3f2fd; margin-top: 10px; padding: 6px; }

    .card h2 { font-size: 8cqi; margin: 0; }

    </style>

    <div class="em">1.2em<div class="em">1.2em<div class="em">1.2em (27.6px)</div></div></div>

    <div class="rem">1.2rem<div class="rem">1.2rem<div class="rem">1.2rem (19.2px)</div></div></div>

    <div class="card" style="width: 320px"><h2>8cqi: 320px card</h2></div>

    <div class="card" style="width: 200px"><h2>8cqi: 200px card</h2></div>

    Listing 4.4: em compounds through nesting; rem and cqi do not Figure 4.4: Browser output of Listing 4.4Mobile toolbars slide away as you scroll, so svh uses the small viewport (toolbars shown), lvh the large one and dvh the current state. Plain vh equals lvh, so a height: 100vh hero hides its bottom edge behind the toolbar. Prefer min-height: 100svh; dvh makes content jump as the toolbar moves. Container units measure the nearest ancestor with container-type (Section 4.10.12) and fall back to small viewport units when there is none. Tip: Size text in rem and never lock html { font-size } to a pixel value, so users who raise their browser's default font size get larger text everywhere.

    4.2.3 Angle Units

    Angles appear in rotations, skews, color hues, gradient directions and trigonometric functions (Section 4.2.11). The table also covers the time and resolution units of the next two subsections. Type Units Conversions <angle> deg, grad, rad, turn 1turn = 360deg = 400grad = 2π rad <time> s, ms 1s = 1000ms <resolution> dpi, dpcm, dppx, x 1dppx = 1x = 96dpi ≈ 37.8dpcm Table 4.4: Angle, time and resolution units

    <style>

    div { display: inline-block; width: 50px; height: 50px; margin: 14px 12px;

    background: linear-gradient(90deg, #ef6c00 50%, #e3f2fd 50%); }

    </style>

    <div style="rotate: 90deg"></div>

    <div style="rotate: 100grad"></div>

    <div style="rotate: 1.5708rad"></div>

    <div style="rotate: 0.25turn"></div>

    Listing 4.5: The same quarter turn written four ways Figure 4.5: Browser output of Listing 4.5Positive angles turn clockwise, and gradient directions are compass bearings: 0deg points up and 90deg right, unlike the counter-clockwise angles of mathematics. Each square holds a left-to-right 90deg gradient; after a quarter turn its orange half sits on top.Pick the unit that reads best: turn for whole rotations (rotate: 2.5turn), deg for everything else. rad is mainly useful when you copy numbers from JavaScript, whose Math.sin() expects radians.

    4.2.4 Time Units

    A <time> in seconds (s) or milliseconds (ms) drives transition and animation durations and delays (Section 4.9): transition: opacity 250ms ease-out 0.1s. Durations cannot be negative, but a negative delay starts an animation as if it had already run that long; combined with animation-play-state: paused it freezes an animation at a chosen moment, a trick the demo in Section 4.2.10 uses. Always write the unit on zero (0s): a unitless 0 is an invalid time and, inside a shorthand such as transition, invalidates the whole declaration.

    4.2.5 Resolution Units

    A <resolution> measures pixel density in dpi, dpcm or dppx (dots per CSS pixel, alias x); 1x equals 96dpi. It appears in the density descriptors of image-set() (Section 4.2.7) and in the resolution media feature, whose value matches window.devicePixelRatio and therefore also changes with browser zoom: @media (resolution >= 2x) { .logo { background-image: url("logo@2x.png"); } }. The range syntax (>=) is Baseline widely available (in all engines since 2023). Older stylesheets write (min-resolution: 2dppx) or (-webkit-min-device-pixel-ratio: 2), which Safari needed until version 16 (2022).

    4.2.6 Color Values

    Besides about 150 names (tomato), transparent, currentColor and hex codes (#0f0, #00ff0080), CSS offers color functions. Modern syntax separates channels with spaces and puts alpha after a slash, rgb(21 101 192 / 50%); the comma form and rgba()/hsla() remain valid aliases. Notation Baseline hex, names, rgb(), hsl(), hwb() Widely available lab() lch() oklab() oklch() color() color-mix() Widely available (2023) relative colors, light-dark() Newly available (2024) contrast-color() (black or white for a background) Newly available (2026) Table 4.5: Color notations and their Baseline status rgb(), hsl() and hwb() (hue, whiteness, blackness) describe the sRGB cube, whose numbers do not match perception: at 50% HSL lightness, lime looks far brighter than violet. lab()/lch() (CIE, 1976) and their 2020 refinement oklab()/oklch() are perceptual. oklch(L C H) takes lightness (0-1), chroma (0 upward; 100% = 0.4) and hue, and can reach vivid Display P3 colors that rgb() cannot, which makes it the best space for design tokens.color-mix(in oklch, a, b 25%) blends in the named space. Relative color syntax, oklch(from var(--x) l c h), exposes a color's channels (l, c, h or r, g, b, alpha) for rewriting with calc(). light-dark(a, b) picks by the element's declared color-scheme (Section 4.10.14).

    <style>

    p { display: flex; gap: 3px; margin: 4px; align-items: center; font: bold 12px sans-serif; }

    b { width: 124px; flex: none; }

    i { flex: 1; height: 34px; display: grid; place-items: end center; background: var(--c);

    font: 10px sans-serif; color: #fff; text-shadow: 0 0 2px #000; }

    .ld { --c: light-dark(#e3f2fd, #0d2a4a); color: light-dark(#0d47a1, #bbdefb);

    text-shadow: none; }

    </style>

    <p><b>One blue</b>

    <i style="--c: #1565c0">hex</i><i style="--c: rgb(21 101 192)">rgb</i>

    <i style="--c: hsl(212 80% 42%)">hsl</i><i style="--c: hwb(212 8% 25%)">hwb</i>

    <i style="--c: lab(42.3 4.3 -55.2)">lab</i><i style="--c: lch(42.3 55.3 274.5)">lch</i>

    <i style="--c: oklab(0.51 -0.04 -0.16)">oklab</i><i style="--c: oklch(0.51 0.16 256)">oklch</i>

    </p>

    <p id="hsl"><b>hsl(h 90% 50%)</b></p>

    <p id="okl"><b>oklch(0.7 0.13 h)</b></p>

    <p><b>Derived</b>

    <i style="--c: color-mix(in oklch, #1565c0, #ef6c00)">mix oklch</i>

    <i style="--c: color-mix(in srgb, #1565c0, #ef6c00)">mix srgb</i>

    <i style="--c: oklch(from #1565c0 calc(l + 0.2) c h)">l + 0.2</i>

    <i style="--c: oklch(from #1565c0 l c calc(h + 180))">h + 180</i></p>

    <p><b>light-dark()</b><i class="ld" style="color-scheme: light">light</i>

    <i class="ld" style="color-scheme: dark">dark</i></p>

    <script>

    for (const h of [30, 90, 150, 210, 270, 330]) {

    hsl.innerHTML += `<i style="--c: hsl(${h} 90% 50%)"></i>`;

    okl.innerHTML += `<i style="--c: oklch(0.7 0.13 ${h})"></i>`; }

    </script>

    Listing 4.6: One blue in eight notations, perceptual lightness, and derived colors Figure 4.6: Browser output of Listing 4.6The HSL row jumps from glaring lime to deep violet while the OKLCH row stays even, and the sRGB mix of blue and orange turns muddy where the OKLCH mix stays saturated. Tip: Define one --brand: oklch(0.55 0.16 256) and derive hover, border and tint shades from it with relative colors, so a rebrand changes a single line.

    4.2.7 Image Values

    An <image> fills background-image, border-image, mask-image, list-style-image or content. It can be a file, url("tile.png"), resolved relative to the stylesheet rather than the page; image-set("a.webp" 1x, "a-2x.webp" 2x), which lets the browser pick by pixel density (Baseline widely available since 2026); or a gradient painted at whatever size the box needs. Gradients come in linear-, radial- and conic- forms plus repeating- variants; two stops at one position make a hard edge, and in oklch avoids muddy sRGB midpoints.

    <style>

    body { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 6px; }

    div { height: 70px; padding: 4px; font: 12px monospace; color: #fff; text-shadow: 0 0 3px #000; }

    </style>

    <div style="background: linear-gradient(90deg, #1565c0, #ef6c00)">linear</div>

    <div style="background: linear-gradient(90deg in oklch, #1565c0, #ef6c00)">linear in oklch</div>

    <div style="background: linear-gradient(135deg, #2e7d32 50%, #fff3e0 50%)">hard stop</div>

    <div style="background: radial-gradient(circle at 30% 40%, #fff3e0, #6a1b9a)">radial</div>

    <div style="background: conic-gradient(#1565c0 0 40%, #ef6c00 0 70%, #2e7d32 0)">conic</div>

    <div style="background: repeating-linear-gradient(45deg, #455 0 8px, #9ab 0 16px)">stripes</div>

    Listing 4.7: Six gradient images Figure 4.7: Browser output of Listing 4.7A gradient is an image, not a color: background-color cannot hold one, and a transition between two gradients does not animate unless you drive it with a registered custom property (Section 4.2.10). Section 4.6.5 covers gradients in depth.

    4.2.8 Positioning Values

    A <position> places something inside a box in background-position, object-position, mask-position and the at clause of gradients. One value leaves the other axis center; two give horizontal then vertical (keywords may swap: top left); four measure from chosen edges, as in right 10px bottom 20%. A percentage aligns the same point of image and box, so 100% 100% puts the image's corner on the box's corner, not outside it.

    <style>

    div { display: inline-block; width: 140px; height: 80px; margin: 4px; font: 12px monospace;

    background: radial-gradient(#ef6c00 60%, transparent 62%) no-repeat #e3f2fd;

    background-size: 36px 36px; }

    </style>

    <div style="background-position: center">center</div>

    <div style="background-position: right 10px bottom 5px">right 10px bottom 5px</div>

    <div style="background-position: 25% 75%">25% 75%</div>

    Listing 4.8: One 36px image placed with three position values Figure 4.8: Browser output of Listing 4.8For photos cropped by object-fit: cover, object-position: 50% 20% keeps faces near the top in view.

    4.2.9 Default Values

    Five global keywords, all Baseline widely available, work on every property. initial applies the specification's initial value (display: initial is inline, even on a <div>). inherit copies the parent's computed value. unset means inherit for inherited properties such as color and initial for the rest. revert rolls back to the browser's default stylesheet, usually what you actually want, and revert-layer rolls back only to the previous cascade layer (Section 4.3.14).

    <style>

    @layer base { h3 { color: #2e7d32; } }

    section { font: 13px sans-serif; color: #1565c0; }

    h3 { margin: 3px 0; color: #ef6c00; }

    .rl { color: revert-layer; }

    </style>

    <section>

    <h3>no keyword: default 1.17em, orange rule</h3>

    <h3 style="font-size: initial; color: initial">initial: medium (16px), black</h3>

    <h3 style="font-size: inherit; color: inherit">inherit: 13px, blue</h3>

    <h3 style="font-size: unset; font-weight: unset">unset: inherits size and normal weight</h3>

    <h3 style="font-size: revert; color: revert">revert: default size, color inherited</h3>

    <h3 class="rl">revert-layer: green from @layer base</h3>

    </section>

    Listing 4.9: The global keywords on an h3 inside a blue 13px section Figure 4.9: Browser output of Listing 4.9The all shorthand applies a keyword to every property: all: revert strips your styles from a widget, and all: unset turns a <button> into a bare clickable box.

    4.2.10 CSS Variables (Custom Properties) and @property

    A custom property is any property whose name starts with --. It cascades and inherits, so a value set on :root is visible everywhere and can be overridden for one subtree. var(--name, fallback) reads it, using the fallback only when it is undefined. Names are case-sensitive; scripts set them with el.style.setProperty().An unregistered custom property is a plain token string, checked only after substitution. If --size: red lands in width: var(--size), the declaration is invalid at computed-value time and width falls back to its inherited or initial value, not to an earlier width rule. @property (Baseline newly available, 2024) registers a type, an inheritance flag and an initial value (required unless the syntax is "*"), so the browser validates, computes and interpolates the value. Only a registered property animates smoothly; an unregistered one flips halfway.

    <style>

    @property --p { syntax: "<percentage>"; inherits: false; initial-value: 0%; }

    @keyframes fill { from { --p: 0%; --q: 0%; } to { --p: 100%; --q: 100%; } }

    div { display: inline-grid; place-items: center; width: 100px; height: 100px; margin: 4px;

    vertical-align: top; border-radius: 50%; font: 13px sans-serif;

    animation: fill 2s linear -0.7s paused; }

    </style>

    <div style="background: conic-gradient(#1565c0 var(--p), #eceff1 0)">--p: 35%</div>

    <div style="background: conic-gradient(#1565c0 var(--q), #eceff1 0)">--q: 0%</div>

    Listing 4.10: A registered and an unregistered property, both paused 35% into one animation Figure 4.10: Browser output of Listing 4.10

    4.2.11 Numerical Functions

    Math functions compute values when styles resolve, so they can mix units known only at layout time: width: calc(100% - 2 * 1rem). Surround + and - with spaces (calc(100% -1rem) is invalid). clamp(min, ideal, max) equals max(min, min(ideal, max)) and powers fluid typography (Section 4.10.13). calc(), min(), max(), clamp(), trigonometry and the constants pi and e are Baseline widely available, as are pow(), sqrt(), hypot(), log() and exp() (since 2026); round(), mod() and rem() (2024) and abs() and sign() (2025) are newly available.round(up, 123px, 50px) snaps to a multiple of the interval (nearest, up, down or to-zero). mod() takes the divisor's sign and rem() the dividend's: mod(-7, 3) is 2, rem(-7, 3) is -1. sin(), cos() and tan() take angles (or numbers as radians); asin(), acos(), atan() and atan2() return angles.

    <style>

    .ring { position: relative; display: inline-block; width: 160px; height: 160px; }

    .ring i { position: absolute; inset: 72px; border-radius: 50%; background: #1565c0;

    translate: calc(cos(var(--i) * 45deg) * 64px) calc(sin(var(--i) * 45deg) * 64px); }

    .bars { display: inline-block; vertical-align: top; width: 220px; font: 12px monospace; }

    .bars div { margin: 6px 0; background: #ef6c00; color: #fff; white-space: nowrap; }

    </style>

    <div class="ring">

    <i style="--i: 0"></i><i style="--i: 1"></i><i style="--i: 2"></i><i style="--i: 3"></i>

    <i style="--i: 4"></i><i style="--i: 5"></i><i style="--i: 6"></i><i style="--i: 7"></i></div>

    <div class="bars">

    <div style="width: clamp(60px, 50%, 180px)">clamp: 110px</div>

    <div style="width: round(up, 123px, 50px)">round(up): 150px</div>

    <div style="width: calc(mod(-7, 3) * 60px)">mod(-7,3): 120px</div>

    <div style="width: calc(pow(2, 7) * 1px)">pow(2,7): 128px</div>

    </div>

    Listing 4.11: Dots placed with cos() and sin(), and bars sized by math functions Figure 4.11: Browser output of Listing 4.11Because var(--i) differs per element, one rule places every dot. Guard newer functions with @supports (width: round(1px, 1px)).

    4.3 Selectors

    A selector decides which elements a rule touches. Get selectors right and your stylesheet stays short and predictable; get them wrong and you end up fighting the cascade with !important. Selectors combine freely: nav > a.active:not(:hover) chains a combinator, a type, a class and two pseudo-classes. The same syntax drives document.querySelectorAll() in JavaScript (Chapter 8), so everything here pays off twice.The subsections move from simple selectors to relationships, states and generated boxes, then to the modern tools that control how strongly a selector applies: :is(), :where(), :has(), specificity, nesting, @layer and @scope. Each subsection states the specificity its selectors add, in ID-CLASS-TYPE form (Section 4.1.3).

    4.3.1 Class Naming

    Name classes in lowercase with hyphens (.card-title, not .cardTitle). Hyphens match the style of CSS property names, and they let the [class|="btn"] attribute selector (Section 4.3.4) match hyphen-separated prefixes. Name classes after what a thing is (.price), not how it looks today (.red-bold), so a redesign changes CSS, not HTML.For components, the BEM convention (Block, Element, Modifier) is the most widely used: a block is a standalone component, an element is a part of it joined with two underscores, and a modifier is a variant joined with two hyphens.

    /* <div class="card card--featured"><h3 class="card__title">Pro</h3></div> */

    .card { border: 1px solid #b0bec5; padding: 8px 14px; } /* block */

    .card__title { margin: 0 0 4px; } /* element */

    .card--featured { border-color: #1565c0; } /* modifier */

    Listing 4.12: BEM class names for a card component Every BEM selector is a single class, so all rules share one specificity (0-1-0) and source order alone settles conflicts. The cost is long class names in HTML. Tip: Utility-first frameworks such as Tailwind CSS (Chapter 5) and CSS Modules replace hand-written naming schemes; Section 4.12.3 compares BEM, SMACSS, ITCSS and CUBE CSS.

    4.3.2 Direct Selectors

    Direct (simple) selectors test a single element: its tag name, class, ID, or role as the document root. Write them together with no spaces to form a compound selector that requires every part: button.primary#save matches only a <button> with that class and ID. Selector Matches Specificity * Every element 0-0-0 p / .note Every <p> / class note 0-0-1 / 0-1-0 p.note <p> with class note 0-1-1 #main The element with id="main" 1-0-0 :root The root element, <html> 0-1-0 h1, h2 Both (a selector list) Per item Table 4.6: Direct selectors Prefer classes for styling: an ID's 1-0-0 beats any number of classes, which makes later overrides painful. :root is the conventional home for custom properties (Section 4.2.10). Warning: One invalid selector voids a whole selector list: h1, h2:unknown { } styles nothing. :is() (Section 4.3.11) forgives invalid arguments.

    4.3.3 Associative Selectors (Combinators)

    Combinators relate two selectors by their position in the document tree. They add no specificity; only the selectors on each side count. Selector Matches Specificity nav a <a> anywhere inside <nav> 0-0-2 ul > li <li> whose parent is <ul> 0-0-2 h2 + p <p> right after an <h2> 0-0-2 h2 ~ p Any later sibling <p> 0-0-2 .menu > * Every child of .menu 0-1-0 Table 4.7: Combinators Combinators only look down or forward; :has() (Section 4.3.11) selects parents and previous siblings. In JavaScript, el.querySelectorAll(":scope > li") returns only the direct children of el. Under the hood: Browsers match selectors right to left: for .sidebar ul li a the engine starts at each <a> and walks up the ancestors, rejecting most elements after one cheap test. Bloom filters of ancestor classes and IDs let it skip hopeless walks, so long descendant chains rarely cost much today.

    4.3.4 Attribute Selectors

    Attribute selectors match the presence or value of any attribute, including data-*. Each weighs 0-1-0, like a class. Selector Matches elements whose attribute... [target] / [type="email"] exists / equals email exactly [title~="car"] / [lang|="en"] has the word car / is en or en-* [href^="https:"] / [href$=".pdf"] starts with https: / ends with .pdf [href*="shop"] / [data-state="open" i] contains shop / is open, any case Table 4.8: Attribute selectors

    a[href^="https:"]::after { content: " \2197"; } /* external-link arrow */

    a[href$=".pdf" i]::before { content: "PDF "; font-weight: 700; } /* also .PDF */

    a[href^="mailto:"] { color: #2e7d32; }

    [data-state="open" i] { background: #fff3e0; } /* open, OPEN, Open */

    Listing 4.13: Labeling links by type without a single class The i flag (Baseline widely available) ignores ASCII case. Prefer :lang(en) to [lang|="en"], because it also matches descendants that inherit the language.

    4.3.5 Navigation Selectors

    These pseudo-classes react to links, pointer and keyboard interaction, and the URL fragment. All weigh 0-1-0. Selector Matches :link / :visited Unvisited / visited links that have an href :hover / :active Element under the pointer / being pressed :focus / :focus-visible Focused / focused and the browser would draw a ring :focus-within / :target Contains the focus / id equals the URL fragment Table 4.9: Navigation pseudo-classes

    a:link { color: #1565c0; } a:visited { color: #6a1b9a; }

    a:hover { text-decoration-thickness: 3px; } a:active { color: #c62828; }

    :focus-visible { outline: 3px solid #ef6c00; outline-offset: 2px; }

    .field:focus-within { background: #e3f2fd; } /* whole label + input group */

    section:target { border-left: 6px solid #2e7d32; } /* page.html#terms marks #terms */

    Listing 4.14: Link states in LVHA order, focus rings and targets Keep link states in LVHA order: they tie on specificity, so the later rule wins, and :active must follow :hover to show. Draw focus rings with :focus-visible (Baseline widely available) so keyboard users always get one and mouse users are not shown one after every click. To stop pages sniffing history, browsers honor only color properties under :visited.

    4.3.6 Input Selectors

    Form pseudo-classes expose control state, so you can style validation and toggles without JavaScript. Each weighs 0-1-0. Selector Matches :checked / :indeterminate / :default Ticked / mixed / initially selected :enabled / :disabled Usable / unusable controls :required / :optional With / without required :valid / :invalid Passing / failing validation now :user-valid / :user-invalid Same, after user interaction :in-range / :read-only Within min-max / not editable :placeholder-shown / :autofill Showing placeholder / browser-filled Table 4.10: Input pseudo-classes :invalid flags an empty required field as soon as the page loads. :user-invalid, Baseline widely available since May 2026, waits until the user edits the field or tries to submit, so prefer it for error styling. :autofill is not yet Baseline.

    <!doctype html>

    <style>

    body { font: 15px system-ui; } input { padding: 4px; border: 2px solid #90a4ae; }

    input:invalid { border-color: #c62828; } input:valid:read-write { border-color: #2e7d32; }

    input:placeholder-shown { background: #fffde7; } input:disabled { opacity: 0.5; }

    :checked + span { font-weight: 700; color: #1565c0; }

    </style>

    <p><input type="email" required placeholder="Empty: invalid">

    <input type="email" value="ann@example.com"></p>

    <label><input type="checkbox" checked> <span>Subscribed</span></label> <input value="Off" disabled>

    Listing 4.15: Validation, placeholder, checked and disabled states Figure 4.12: Browser output of Listing 4.15

    4.3.7 Child and Structural Selectors

    Structural pseudo-classes select by position among siblings. In An+B, n runs 0, 1, 2..., so 2n+1 gives 1, 3, 5 and -n+3 the first three. All weigh 0-1-0; :nth-child(An+B of S) also adds the weight of S. Selector Matches :first-child / :last-child / :only-child First / last / sole child of its parent tr:nth-child(odd) / :nth-last-child(2) Rows 1, 3, 5... / second from the end li:nth-child(2 of .done) The second child that has class done p:first-of-type / p:nth-of-type(3) First / third <p>, ignoring other tags p:empty A <p> with no children, not even whitespace Table 4.11: Structural pseudo-classes p:first-child needs a <p> that is literally first; p:first-of-type skips a leading <h3>. The of S form (Baseline widely available since November 2025) counts only siblings matching S, so stripes survive hidden rows.

    <!doctype html>

    <style>

    body { font: 15px system-ui; } p { margin: 0; }

    ul { display: flex; gap: 4px; list-style: none; padding: 0; margin: 0 0 8px; }

    li { padding: 2px 10px; } li:last-child { box-shadow: inset 0 -3px #1565c0; }

    li:nth-child(odd of :not([hidden])) { background: #e3f2fd; }

    div p:first-of-type { font-weight: 700; } div :empty { height: 6px; background: #ef6c00; }

    </style>

    <ul><li>One</li><li hidden>Hidden</li><li>Two</li><li>Three</li><li>Four</li></ul>

    <div><b>Notes:</b><p>First p, bold via :first-of-type</p><p></p></div>

    Listing 4.16: Stripes that skip hidden items, -of-type and :empty Figure 4.13: Browser output of Listing 4.16

    4.3.8 Pseudo-element Selectors

    A pseudo-element styles part of an element that has no tag of its own. It uses a double colon (legacy :before and :after still work), must end its compound selector, and adds 0-0-1. Selector Styles ::before / ::after Generated first / last child (needs content) ::first-letter / ::first-line / ::marker First letter / first line / list bullet ::selection / ::placeholder Highlighted text / placeholder text ::backdrop Layer behind a modal or fullscreen element ::part(name) / ::slotted(sel) Shadow part / slotted light-DOM child Table 4.12: Pseudo-elements ::part() and ::slotted() are the sanctioned holes in a web component's style encapsulation (Chapter 8). ::placeholder and ::backdrop are Baseline widely available; ::marker and ::selection are not yet, because Safari's support is incomplete.

    <!doctype html>

    <style>

    body { font: 15px system-ui; } p, ul { margin: 4px 0; } li::marker { content: "\2713 "; }

    p::first-letter { font-size: 2em; color: #6a1b9a; } x-card::part(title) { color: #1565c0; }

    .price::before { content: "$"; } .price::after { content: " / month"; color: #78909c; }

    </style>

    <p>Pseudo-elements style parts you never wrote as tags.</p>

    <ul><li>Custom check-mark marker</li></ul>

    <x-card><template shadowrootmode="open"><style>::slotted(span) { background: #fff3e0; }</style>

    <b part="title">Shadow DOM title via ::part</b><br><slot></slot></template>

    <span>Slotted text</span> <span class="price">9</span></x-card>

    Listing 4.17: Generated content, markers, first letters and shadow parts Figure 4.14: Browser output of Listing 4.17

    4.3.9 Info-graphic Selectors

    Some selectors target media and overlay layers: dialog::backdrop and :modal style a dialog opened with showModal() (both Baseline widely available); :fullscreen matches an element shown through the Fullscreen API (not yet Baseline); and video::cue styles WebVTT captions from a <track>, with video::cue(b) reaching only their bold spans.

    <!doctype html>

    <style>

    body { font: 15px system-ui; } dialog:modal { border: 3px solid #1565c0; border-radius: 8px; }

    dialog::backdrop { background: rgb(38 50 56 / 0.6); backdrop-filter: blur(2px); }

    </style>

    <p>Page content behind the modal. Page content behind the modal.</p>

    <dialog id="d">Saved. <button>OK</button></dialog>

    <script>document.getElementById("d").showModal();</script>

    Listing 4.18: A modal dialog dims and blurs the page through ::backdrop Figure 4.15: Browser output of Listing 4.18

    4.3.10 Printer Selectors

    Inside @page (Section 4.4.7), the page selectors :first, :left and :right target the first page and the left- and right-hand pages of a double-sided printout; @page :first outranks plain @page. Firefox lacks them, so they are not Baseline, while @media print (Section 4.4.4) works everywhere.

    @page { margin: 0.75in; } @page :first { margin-top: 2in; }

    @page :left { margin-right: 1in; } @page :right { margin-left: 1in; } /* binding edge */

    @media print {

    nav, video { display: none; }

    a[href^="http"]::after { content: " (" attr(href) ")"; } /* print link targets */

    }

    Listing 4.19: Mirror margins for binding and hide screen-only chrome when printing

    4.3.11 Logical Combinators

    These pseudo-classes take a selector list. :is(), :where() and :not() are Baseline widely available since July 2023, :has() since June 2026. Selector Matches Specificity :is(h2, h3) a <a> in <h2> or <h3> Heaviest argument: 0-0-2 :where(ul, ol) li The same, weightless Argument adds 0: 0-0-1 li:not(.done, #x) <li> matching neither Heaviest argument: 1-0-1 .card:has(> img) .card with an <img> child Heaviest argument: 0-1-1 Table 4.13: Logical combinators :is() and :where() are forgiving: an invalid argument is dropped instead of voiding the rule; :has() is not. :has() takes a relative selector, so h2:has(+ p) selects an <h2> followed by a paragraph: parent and previous-sibling selection at last. Avoid broad anchors such as body:has(.x) on fast-changing pages, since any change inside can restyle everything.

    <!doctype html>

    <style>

    body { font: 15px system-ui; } p { margin: 0; }

    .card { border: 1px solid #b0bec5; border-left-width: 6px; padding: 4px 8px; margin: 4px 0; }

    .card:has(img) { display: flex; gap: 8px; border-left-color: #1565c0; }

    .card:not(:has(img)) { font-style: italic; } form:has(:invalid) button { opacity: 0.4; }

    </style>

    <div class="card"><img width="28" height="28" alt="" style="background:#90caf9">

    <p>Card with an image: flex row and blue edge via :has(img)</p></div>

    <div class="card"><p>Text-only card: italic via :not(:has(img))</p></div>

    <form><input required placeholder="Required field"> <button>Send (dimmed)</button></form>

    Listing 4.20: :has() styles parents from what they contain Figure 4.16: Browser output of Listing 4.20

    4.3.12 Specificity

    Count IDs into column A; classes, attributes and pseudo-classes into B; types and pseudo-elements into C. * and combinators count nothing. Compare A, then B, then C; columns never carry over, so eleven classes (0-11-0) still lose to one ID (1-0-0). Selector Counted as Result #nav .item:hover A #nav; B .item :hover 1-2-0 a[href]::before B [href]; C a ::before 0-1-2 li:nth-child(2 of .x) B :nth-child .x; C li 0-2-1 :is(#top, .hero) p A #top (heaviest); C p 1-0-1 :where(#top, .hero) p C p only 0-0-1 Table 4.14: Specificity walk-through A style attribute beats every selector and !important beats both, so keep them as last resorts. Keep most selectors at one or two classes and wrap resets in :where() so any class overrides them. To raise a rule, repeat a class (.btn.btn, 0-2-0) or move the weaker code into a lower cascade layer (Section 4.3.14). A classic interview question, ul#menu li versus .nav .item a, is settled by column A: 1-0-2 beats 0-2-1 whatever the order.

    4.3.13 CSS Nesting

    Native nesting, Baseline widely available since June 2026, puts child rules inside a parent rule as Sass (Section 4.11.2) does, with no build step. & stands for the parent selector; a nested selector without it is a descendant.

    .card {

    padding: 1rem;

    h3 { margin: 0; } /* .card h3 */

    > img { width: 100%; } /* .card > img */

    &:hover { background: #e3f2fd; } /* .card:hover (no space) */

    &.featured { border-color: #1565c0; } /* .card.featured */

    .dark & { background: #263238; } /* .dark .card */

    @media (width >= 40rem) { padding: 2rem; }

    }

    Listing 4.21: Nested rules and what they mean Two differences from Sass catch migrating developers. & is not text substitution, so BEM-style &__title is invalid. And & behaves like :is(): in #main, .side { & p {} } every match weighs 1-0-1, even a <p> inside .side. Nest two or three levels at most, or selectors grow long and hard to override.

    4.3.14 Cascade Layers (@layer) and @scope

    Cascade layers rank groups of rules before specificity is compared. The first layer declared is the weakest and unlayered styles beat every layer, so an ID in a low layer loses to a class in a higher one; !important reverses the order. Layers are Baseline widely available since September 2024. Import third-party CSS into a low layer, @import url("bootstrap.css") layer(vendor);, and your classes override it without specificity battles.@scope (root) to (limit), Baseline newly available since March 2026, applies rules only between root and limit, keeping a component's styles out of nested content. It adds no specificity (:scope weighs 0-1-0), and when scoped rules tie, the nearer root wins before source order.

    <!doctype html>

    <style>

    @layer reset, components; /* reset < components < unlayered */

    @layer components { .btn { background: #2e7d32; color: #fff; } }

    @layer reset { #buy.btn { background: #9e9e9e; } } /* 1-1-0, but a weaker layer */

    @scope (.light) { p { color: #1565c0; } }

    @scope (.dark) { p { color: #ef6c00; } } /* later, but a farther root */

    body { font: 15px system-ui; } div { padding: 1px 8px; background: #263238; }

    .light { background: #eceff1; } p { margin: 4px 0; } button { margin-bottom: 6px; }

    </style>

    <button id="buy" class="btn">Green: the components layer wins</button>

    <div class="dark"><p>Orange: inside .dark</p>

    <div class="light"><p>Blue: .light is nearer</p></div></div>

    Listing 4.22: A low layer loses despite its ID; the nearer scope root wins Figure 4.17: Browser output of Listing 4.22

    4.4 At-rules

    An at-rule is a CSS statement starting with @ that instructs the parser rather than styling an element directly. Statement at-rules end with a semicolon (@import "base.css";). Block at-rules hold either nested style rules (@media, @supports, @container, @layer) or descriptors (@font-face, @page, @property). Under the hood: A parser skips an unknown at-rule up to its matching } or ; and carries on, so brand-new at-rules never break older browsers. That makes the experimental rules in Section 4.4.11 safe to try.

    4.4.1 @font-face

    @font-face registers a font under a name you then use in font-family. Its src list is tried in order (local() for an installed font, url() with an optional format() hint for a download); font-weight, font-style and font-stretch say which variant the file provides; unicode-range limits the characters it covers. The rule is Baseline widely available. Here Lobster applies to the digits 0-9 only.

    <style>

    @font-face {

    font-family: "Digits";

    src: local("Lobster"),

    url("https://cdn.jsdelivr.net/fontsource/fonts/lobster@latest/latin-400-normal.woff2")

    format("woff2");

    unicode-range: U+0030-0039; /* 0-9 only */

    font-display: swap;

    }

    p { font: 34px "Digits", Georgia, serif; margin: 12px; }

    </style>

    <p>Order 2026 ships in 48 hours</p>

    Listing 4.23: A web font limited to digits with unicode-range Figure 4.18: Browser output of Listing 4.23A face downloads only when the page uses a character in its range, which is how Google Fonts splits families per script. font-display sets the loading behavior: block hides text briefly, swap shows the fallback and swaps whenever the font arrives, fallback swaps only within a short window, and optional uses the font only if it is ready almost at once. Prefer WOFF2 with swap or optional; Section 4.5.10 covers variable fonts and subsetting.

    4.4.2 @keyframes

    @keyframes names a sequence of style snapshots at percentages (from = 0%, to = 100%) that the animation property plays. Offsets can share a block, and properties missing from a keyframe interpolate from the element's own value.

    @keyframes pulse {

    50% { transform: scale(1.15); background: gold; }

    }

    .badge { animation: pulse 1.2s ease-in-out infinite; }

    Listing 4.24: Declaring and playing a keyframe animation !important inside a keyframe is ignored. Timing, fill modes and scroll-driven timelines are in Section 4.9.2.

    4.4.3 @import

    @import inserts another stylesheet's rules at that spot. It must precede every rule except @charset and @layer statements, and cannot sit inside @media; conditions follow the URL instead: layer() (Chrome 99, Firefox 97, Safari 15.4) puts the file into a cascade layer, supports() (Chrome 122, Firefox 115, Safari 17.5) requires a feature query to pass, and a media query list restricts devices.

    @layer reset, vendor;

    @import url("reset.css") layer(reset);

    @import "bootstrap.min.css" layer(vendor);

    @import "grid-layout.css" supports(display: grid) screen and (width >= 48rem);

    @import "print.css" print;

    Listing 4.25: Conditional and layered imports Warning: Each import is a request the browser discovers only after parsing the parent sheet, which delays rendering. Let a bundler such as Vite or Lightning CSS (Section 4.11) inline imports in production.

    4.4.4 @media

    @media applies nested rules when a media query is true: an optional type (screen, print) plus features in parentheses, joined by and, or, not, with commas meaning "any of these". The range syntax (width >= 600px) is Baseline widely available, and @media can nest inside a style rule.

    <style>

    p { font: 20px system-ui; padding: 8px; background: #e3f2fd; }

    @media screen and (width < 600px), print and (orientation: portrait) {

    p { background: #fff3e0; }

    }

    .note { @media (width < 600px) { color: #ef6c00; font-weight: bold; } }

    </style>

    <p class="note">Orange background and bold orange text below 600px</p>

    Listing 4.26: Media types, range syntax and nesting Figure 4.19: Browser output of Listing 4.26In legacy queries and binds first and a leading not negates the whole query: not screen and (color), print means "(not (screen and color)) or print". Preference features and breakpoints are in Section 4.10.7.

    4.4.5 @supports

    @supports applies rules only if the browser can parse a (property: value), a selector() (Chrome 83, Firefox 69, Safari 14.1) or a font-tech()/font-format() test (Chrome 108, Firefox 106, Safari 17), combined with and, or, not. JavaScript asks the same with CSS.supports(). Each badge appears only if its test passes.

    <style>

    span { display: none; font: 16px system-ui; padding: 4px 10px; margin: 4px;

    border-radius: 12px; background: #e8f5e9; color: #2e7d32; }

    @supports (display: grid) and (gap: 1rem) { .grid { display: inline-block; } }

    @supports selector(:has(> img)) { .has { display: inline-block; } }

    @supports font-tech(color-COLRv1) { .colr { display: inline-block; } }

    </style>

    <span class="grid">grid + gap</span><span class="has">:has()</span><span class="colr">COLRv1</span>

    Listing 4.27: Feature queries for declarations, selectors and font technology Figure 4.20: Browser output of Listing 4.27A feature query tests parsing, not correct behavior. Write the fallback first and the enhancement inside @supports.

    4.4.6 @charset

    @charset declares the encoding of an external stylesheet. It must be the file's very first bytes (only a byte-order mark may precede it), in the exact form @charset "UTF-8";: one space, double quotes, a semicolon. It does nothing inside <style> or a style attribute.

    @charset "UTF-8";

    .price::before { content: "€"; }

    Listing 4.28: components.css saved as UTF-8 Browsers take the encoding from the first available source: a byte-order mark, the HTTP Content-Type charset, @charset, then UTF-8. Single quotes or a leading space silently invalidate the rule.

    4.4.7 @page

    @page styles the printed page box: size (A4, letter, 210mm 297mm, landscape), margin, and page-orientation (Chromium and Firefox). The selectors :first, :left, :right and :blank target subsets, and named pages apply through the page property. Page setup became Baseline 2024 with Safari 18.2. The 16 margin boxes (@top-center, @bottom-right...) for running headers and page numbers work only in Chrome and Edge 131+ and are experimental.

    @page { size: A4; margin: 20mm 18mm; }

    @page :first { margin-top: 40mm; }

    @page wide { size: A4 landscape; }

    .data-table { page: wide; } /* starts a new landscape page */

    @page {

    @bottom-center { content: "Page " counter(page) " of " counter(pages); } /* Chromium only */

    }

    Listing 4.29: Named pages and a page-number footer for print Browsers still ignore backgrounds, borders and fonts on the page box itself. Pair @page with the printer selectors of Section 4.3.10; for books and invoices, Paged.js (open source) or Prince (commercial) implement far more of the specification.

    4.4.8 @namespace

    @namespace binds a prefix to an XML namespace URI so selectors can tell same-named elements apart. It follows @charset and @import and precedes everything else. Without a prefix it sets the default namespace; selectors use prefix|element, *|element (any) or |element (none).

    @namespace url(http://www.w3.org/1999/xhtml); /* unprefixed selectors = HTML */

    @namespace svg url(http://www.w3.org/2000/svg);

    a { color: #1565c0; } /* HTML <a> only */

    svg|a { fill: #ef6c00; } /* <a> inside inline SVG */

    Listing 4.30: Styling HTML and SVG links separately It matters in HTML because the parser puts inline <svg> and <math> in their own namespaces, and SVG has its own <a>, <title> and <style>. Baseline widely available; it applies only to the declaring stylesheet.

    4.4.9 @container

    @container queries an ancestor instead of the viewport. Declare a container with container: card / inline-size (name and type), then write @container card (width > 360px) { ... }. Size queries are Baseline widely available; style queries such as @container style(--variant: compact) became Baseline 2026 with Firefox 151; scroll-state queries are Chromium-only. The same card adapts to two containers:

    <style>

    body { display: flex; gap: 16px; font: 15px system-ui; margin: 12px; }

    .slot { container: card / inline-size; }

    .card { display: grid; gap: 8px; padding: 8px; background: #e3f2fd; border-radius: 8px; }

    .card i { display: block; aspect-ratio: 3 / 1; background: #1565c0; border-radius: 4px; }

    h3 { margin: 0; font-size: 1rem; }

    @container card (width > 360px) {

    .card { grid-template-columns: 40% 1fr; align-items: center; }

    }

    </style>

    <div class="slot" style="width:230px"><div class="card"><i></i><h3>Stacked (230px)</h3></div></div>

    <div class="slot" style="width:450px"><div class="card"><i></i><h3>Row (450px)</h3></div></div>

    Listing 4.31: One card component in a narrow and a wide container Figure 4.21: Browser output of Listing 4.31Container units (cqi, cqb) are in Section 4.2.2; intrinsic design patterns in Section 4.10.12.

    4.4.10 @layer, @scope, @property, @starting-style, @counter-style, @position-try, @view-transition

    These newer at-rules replace work that once needed JavaScript or naming conventions. Each is detailed in the section listed. At-rule Purpose Status (Sept 2026) See @layer Orders groups of rules in the cascade Baseline widely available 4.3.14 @scope Limits rules to a subtree Baseline 2026 4.3.14 @property Registers a typed, animatable custom property Baseline 2024 4.2.10 @starting-style "Before" styles for entry transitions Baseline 2024 4.9.1 @counter-style Custom list and counter markers Baseline widely available 4.7.1 @position-try Fallback positions for anchored elements Baseline 2026 4.8.10 @view-transition Cross-document view transitions Chromium, Safari; not Firefox 4.9.5 Table 4.15: Modern at-rules at a glance

    <style>

    @counter-style ticks { system: cyclic; symbols: "👍" "✅" "⭐"; suffix: " "; }

    @property --hue { syntax: "<angle>"; inherits: false; initial-value: 210deg; }

    ol { list-style: ticks; font: 17px system-ui; margin: 8px 0 0 260px; }

    button { anchor-name: --btn; position: absolute; top: 12px; left: 20px;

    font: 16px system-ui; background: hsl(var(--hue) 70% 45%); color: #fff; }

    .tip { position: absolute; position-anchor: --btn; position-area: top; margin: 6px;

    position-try-fallbacks: --below; padding: 6px 10px; background: #37474f;

    color: #fff; font: 14px system-ui; border-radius: 6px;

    transition: opacity .3s; @starting-style { opacity: 0; } }

    @position-try --below { position-area: bottom; }

    </style>

    <button>Save</button><div class="tip">No room above, so I moved below</div>

    <ol><li>Semantic HTML</li><li>Modern CSS</li><li>Less JavaScript</li></ol>

    Listing 4.32: @counter-style markers and a tooltip that flips with @position-try Figure 4.22: Browser output of Listing 4.32The tooltip requests the area above its anchor, would overflow the viewport, and so takes the --below fallback; @starting-style fades it in on first render, and the typed --hue could be transitioned. @view-transition { navigation: auto; } in both pages' CSS animates same-origin navigations without script.

    4.4.11 Experimental Proposals

    These features are experimental and not Baseline in September 2026:
  • @function (Chrome and Edge 139+; Safari Technology Preview) defines a custom function with typed parameters, defaults and a result.

  • if() (Chrome and Edge 137+) picks a value inline using style(), media() or supports() conditions.

  • @custom-media --narrow (width < 40rem); names a query for @media (--narrow). No stable browser ships it (Firefox has a flag), but PostCSS Preset Env and Lightning CSS compile it today.

  • @sheet would bundle named stylesheets in one file. The CSS Working Group accepted it; no browser implements it.

  • <style>

    @function --tint(--color <color>, --alpha <number>: 0.25) returns <color> {

    result: oklch(from var(--color) l c h / var(--alpha));

    }

    .chip { --brand: #6a1b9a; font: 18px system-ui; padding: 10px 16px; margin: 8px;

    display: inline-block; border-radius: 20px; color: var(--brand);

    background: --tint(var(--brand));

    border: if(style(--level: high): 3px solid; else: 1px dashed) var(--brand); }

    </style>

    <span class="chip">Default level</span> <span class="chip" style="--level: high">Level high</span>

    Listing 4.33: A custom function and an inline if() (Chromium only) Figure 4.23: Browser output of Listing 4.33Because these declarations contain var(), other browsers accept them while parsing and drop them at computed-value time, which also cancels earlier fallbacks for that property. Use them only for non-essential decoration until support broadens.

    4.5 Text Properties

    Most text properties are inherited: set them once on body or a container and every descendant follows. This section works from the line outward (alignment, spacing, breaking, direction, decoration, fonts, columns, generated content and interaction) and ends with variable fonts and font-loading performance. The @font-face rule itself is in Section 4.4.1.

    4.5.1 Alignment

    text-align (start, end, left, right, center, justify) positions inline content inside its block, not the block itself. Prefer start and end, which flip in right-to-left languages. text-align-last aligns a paragraph's final line separately, and text-justify chooses whether justification stretches word or letter gaps (not in Safari). vertical-align (baseline, middle, top, sub, super, a length) applies only to inline boxes and table cells, not to a div.

    <style>

    body { font: 15px/1.5 Georgia, serif; margin: 10px; }

    .j { text-align: justify; text-align-last: center; width: 330px; float: left; margin: 0 20px 0 0 }

    i { display: inline-block; width: 28px; height: 28px; border-radius: 50%; background: #1565c0;

    vertical-align: middle; } sup { vertical-align: super; font-size: .7em; }

    td { height: 60px; border: 1px solid #90a4ae; vertical-align: bottom; }

    </style>

    <p class="j">Justified lines stretch the spaces between words until both edges line up, while

    text-align-last centers the short final line of the paragraph.</p>

    <p><i></i> Middle, E = mc<sup>2</sup></p><table><tr><td>Cell content at the bottom</td></tr></table>

    Listing 4.34: Justified text, a centered last line and vertical-align Figure 4.24: Browser output of Listing 4.34 Tip: vertical-align: middle aligns a box's midpoint with the parent's baseline plus half its x-height, not the line's visual center. To center a block vertically, use flexbox or grid (Section 4.8.6).

    4.5.2 Spacing

    Browsers collapse white space and wrap lines where they fit; white-space changes both: nowrap stops wrapping, pre preserves everything without wrapping, pre-wrap preserves and wraps, and pre-line keeps only newlines. CSS Text Level 4 made it a shorthand for white-space-collapse (Baseline 2024) and text-wrap-mode.letter-spacing and word-spacing add or remove space, best in em. Give line-height a unitless number such as 1.5: children inherit the factor and apply it to their own font size, whereas 1.5em is computed once and inherited as a fixed length that cramps larger headings. text-indent indents the first line, and tab-size sets the tab width in preserved white space (default 8).

    <style>

    body { font: 15px system-ui; margin: 10px; }

    .code { white-space: pre-wrap; tab-size: 4; font-family: monospace; background: #eceff1; }

    .body { line-height: 1.6; text-indent: 2em; letter-spacing: .02em; margin: 4px 0; }

    </style>

    <div class="code">if (ok) {

    &#9;return "tab-size: 4 keeps this indent narrow";

    }</div>

    <p class="body">A unitless line-height of 1.6 gives body copy room to breathe, and the first line

    is indented by 2em, the book style for paragraphs without a blank line between them.</p>

    Listing 4.35: white-space, tab-size, letter-spacing, line-height and text-indent Figure 4.25: Browser output of Listing 4.35 Warning: white-space: nowrap on user content lets a long name or URL push the layout wider than a phone; pair it with text-overflow (Section 4.5.3).

    4.5.3 Overflow

    When text does not fit, clip it, break words or reshape the lines:
  • text-overflow: ellipsis marks clipped text on a nowrap line with overflow: hidden.

  • overflow-wrap: anywhere breaks a long word or URL only if it would overflow; word-break: break-all breaks anywhere.

  • hyphens: auto hyphenates using the dictionary for the element's lang attribute (Baseline widely available since March 2026).

  • text-wrap: balance evens out line lengths in headings (Baseline 2024); text-wrap: pretty avoids a lone last word in paragraphs (Chrome, Safari 26; ignored elsewhere).

  • Unprefixed line-clamp is not Baseline yet; use the legacy -webkit-line-clamp trio below, which the spec keeps.

  • <style>

    body { font: 16px/1.4 system-ui; display: flex; flex-wrap: wrap; align-items: start; gap: 8px; }

    div, h3 { border: 1px solid #90a4ae; padding: 4px; margin: 0; font-size: 16px; }

    h3 { width: 240px; } .b { text-wrap: balance; } .brk { overflow-wrap: anywhere; width: 9em; }

    .ell { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 285px; }

    .cl { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }

    </style>

    <h3>Balanced wrapping evens out headings</h3><h3 class="b">Balanced wrapping evens out headings</h3>

    <div class="ell">https://example.com/a/very/long/address/that/never/fits.html</div>

    <div class="brk">Pneumonoultramicroscopic</div>

    <div class="cl" style="width: 440px; overflow: hidden">Line clamping cuts this card description

    after two lines, however much text the content management system sends, with an ellipsis.</div>

    Listing 4.36: Balanced headings, ellipsis, emergency word breaks and line clamping Figure 4.26: Browser output of Listing 4.36 Under the hood: normal wrapping is greedy: each line takes as many words as fit. balance and pretty compare break points across lines, which is expensive, so Chromium balances only blocks of up to six lines and Firefox up to ten.

    4.5.4 Direction and Writing Modes

    writing-mode sets how lines stack: horizontal-tb (default), vertical-rl (traditional Chinese and Japanese) or vertical-lr. In vertical text, text-orientation: upright stands Latin letters up, and text-combine-upright: all squeezes a short run such as a two-digit number into one character cell. Once text turns, size boxes with logical properties such as inline-size and block-size (Section 4.8.4).Right-to-left direction belongs in the markup, dir="rtl", rather than CSS direction, because it must survive without styles. Wrap embedded opposite-direction snippets such as user names in <bdi>, which isolates them.

    <style>

    body { display: flex; align-items: start; gap: 24px; font: 20px system-ui; margin: 8px; }

    div, p { border: 1px solid #90a4ae; padding: 4px; margin: 0; }

    .v { writing-mode: vertical-rl; inline-size: 130px; } .v b { text-combine-upright: all; }

    </style>

    <div class="v">第<b>12</b>章 竖排文字从右向左排列</div>

    <p dir="rtl" style="inline-size: 300px">مرحبا <bdi>Ada Lovelace</bdi> ، أهلا بك في الدورة</p>

    Listing 4.37: Vertical Chinese with a combined number, and right-to-left Arabic Figure 4.27: Browser output of Listing 4.37 Tip: Add dir="rtl" to <html> in DevTools: anything placed with left, right or margin-left stays put while the text flips.

    4.5.5 Surrounding

    text-decoration is a shorthand for -line (underline, overline, line-through), -style (solid, double, dotted, dashed, wavy), -color and -thickness; text-underline-offset moves an underline away from the glyphs. The blink value is deprecated and may be ignored. text-emphasis places East Asian emphasis marks over each character (Baseline since 2022), text-transform changes case without touching the DOM text, and text-shadow takes one or more x y blur color shadows.

    <style>

    body { font: 20px Georgia, serif; margin: 8px; } p { margin: 0 0 10px; }

    .link { text-decoration: underline wavy #ef6c00 2px; text-underline-offset: 5px; }

    .em { text-emphasis: filled dot #6a1b9a; padding-top: 12px; }

    .cap { text-transform: capitalize; text-shadow: 1px 1px 0 #fff, 3px 3px 4px #1565c0; }

    </style>

    <p class="link">Spelling mistakes get a wavy, offset underline</p>

    <p class="em">着重号 emphasis</p> <p class="cap">shadows help text over busy images</p>

    Listing 4.38: Decoration longhands, emphasis marks, transform and shadow Figure 4.28: Browser output of Listing 4.38 Tip: Restyle link underlines with offset and thickness instead of removing them: color alone does not identify a link for color-blind readers.

    4.5.6 Font

    The font shorthand, as in font: italic 600 1rem/1.5 Georgia, serif, resets every longhand you omit. End font-family with a generic family such as serif, sans-serif, monospace, math or system-ui (the operating system's interface font). font-weight accepts 1 to 1000 (400 normal, 700 bold), font-synthesis decides whether the browser may fake a missing bold or italic, and font-size-adjust (Baseline 2024) scales fallback fonts to the same x-height so text does not jump when a web font arrives.The font-variant-* longhands switch on optional OpenType glyphs: -caps (small-caps, all-small-caps), -numeric (oldstyle-nums, tabular-nums, diagonal-fractions, slashed-zero), -ligatures and -alternates. The low-level font-feature-settings: "smcp", "tnum" reaches features by four-letter tag; keep it for features that have no property.

    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=EB+Garamond">

    <style>

    body { font: 21px/1.45 "EB Garamond", Georgia, serif; margin: 8px; }

    .sc { font-variant-caps: all-small-caps; letter-spacing: .05em; }

    .frac { font-variant-numeric: diagonal-fractions; } .nolig { font-variant-ligatures: none; }

    </style>

    <div><span class="sc">NASA and HTML</span> in small caps, <span class="frac">3/4</span> cup</div>

    <div>Ligatures on: fifty office files · off: <span class="nolig">fifty office files</span></div>

    Listing 4.39: OpenType features through the font-variant longhands Figure 4.29: Browser output of Listing 4.39 Tip: Use tabular-nums in tables, timers and prices so digits line up and counters do not jitter.

    4.5.7 Multiple Columns

    columns: 11em 3 combines column-width (an ideal minimum) and column-count (a maximum): the browser fits as many 11em columns as there is room for, up to three, so the layout adapts without media queries. column-gap and column-rule (styled like a border) separate the columns, column-span: all lets a heading cross them, and break-inside: avoid keeps a card or paragraph in one piece.

    <style>

    article { columns: 11em 3; column-gap: 24px; column-rule: 1px dotted #1565c0; }

    h3 { column-span: all; margin: 0 0 6px; font-family: system-ui; }

    p { margin: 0 0 6px; break-inside: avoid; font: 14px Georgia; }

    </style>

    <article><h3>Column layout since newspapers</h3>

    <p>Short lines of 45 to 75 characters are easier to read than lines that span a wide screen.</p>

    <p>The browser balances the text so that every column ends at a similar height.</p>

    <p>Reduce the viewport and the article drops to two columns, then to one.</p></article>

    Listing 4.40: A responsive three-column article with a spanning heading Figure 4.30: Browser output of Listing 4.40 Warning: Columns suit short blocks such as link lists and print layouts. Screen-tall columns make readers scroll down and back up; use grid (Section 4.8.7) for page structure.

    4.5.8 Generated Content Properties

    content fills ::before, ::after and ::marker (Section 4.3.8) with strings, url() images, attr(name), open-quote/close-quote and counters. Create a counter with counter-reset, advance it with counter-increment and print it with counter(name), or with counters(name, ".") to join nested levels into outline numbers like 1.2. quotes lists the marks for each nesting level. Screen readers announce generated text, so give decorative content empty alternative text after a slash: content: "★" / "" (Baseline 2024).

    <style>

    body { font: 15px system-ui; margin: 8px; } q { quotes: "«" "»" "‹" "›"; }

    ol { counter-reset: item; list-style: none; padding-left: 18px; margin: 0; }

    li::before { counter-increment: item; content: counters(item, ".") ". "; color: #1565c0; }

    a::after { content: " (" attr(href) ")"; color: #607d8b; }

    </style>

    <ol><li>Text properties<ol><li>Alignment</li><li>Spacing</li></ol></li>

    <li>Graphic properties: <q>Say <q>cheese</q> to the gradient</q></li></ol>

    <p>Details at <a href="https://example.com/details">the details page</a></p>

    Listing 4.41: Nested outline numbering with counters() and custom quotes Figure 4.31: Browser output of Listing 4.41 Warning: Users cannot select or copy generated content, so never put essential information there. Printing link addresses with attr(href) in @media print is a good use.

    4.5.9 Interface

  • cursor: pointer, text, grab, not-allowed, zoom-in, or url(icon.png) 4 4, auto with a required fallback keyword.

  • user-select: none stops selection on controls; all selects the whole element in one click (Safari still needs -webkit-user-select).

  • pointer-events: none passes clicks to whatever lies beneath; keyboard focus still works.

  • caret-color colors the text cursor; accent-color recolors checkboxes, radios, range sliders and progress bars.

  • appearance: none removes the native look of form controls for custom styling.

  • field-sizing: content makes inputs and textareas grow with their content (Baseline since June 2026).

  • <style>

    body { font: 15px system-ui; margin: 8px; accent-color: #2e7d32; }

    textarea { field-sizing: content; caret-color: #ef6c00; } button { cursor: not-allowed; }

    </style>

    <label><input type="checkbox" checked> Subscribe</label> <input type="range" value="70">

    <progress value="0.4"></progress><br><textarea>This textarea grows

    as you type.</textarea> <button disabled>Send</button>

    Listing 4.42: accent-color, caret-color, field-sizing and cursor on a form Figure 4.32: Browser output of Listing 4.42 Warning: user-select: none on article text annoys readers and protects nothing; the text is in the source.

    4.5.10 Web Fonts and Variable Fonts

    A variable font replaces a family of static files with one file exposing continuous axes. The registered axes are lowercase and map to ordinary properties: wght to font-weight, wdth to font-stretch, ital and slnt to font-style, opsz to font-optical-sizing. Custom axes such as GRAD (grade) are uppercase and reachable only through font-variation-settings. Its @font-face rule declares ranges such as font-weight: 100 1000.

    <style>

    @import "https://fonts.googleapis.com/css2?family=Roboto+Flex:\

    wdth,wght,GRAD@25..151,100..1000,-200..150";

    p { font: 30px "Roboto Flex", sans-serif; margin: 0 8px; }

    .a { font-weight: 150; font-stretch: 151%; } .b { font-weight: 900; font-stretch: 25%; }

    .c { font-variation-settings: "GRAD" 150; }

    </style>

    <p><span class="a">Thin and wide</span> · <span class="b">Black and condensed</span></p>

    <p>Grade 0 · <span class="c">Grade 150 bolder, same width</span></p>

    Listing 4.43: One variable font file at different weights, widths and grades Figure 4.33: Browser output of Listing 4.43font-variation-settings values are not merged: a rule that sets only "GRAD" resets every other axis, so feed axes from custom properties and prefer high-level properties. To keep fonts fast:
  • Subset and serve WOFF2. fontTools (github.com/fonttools/fonttools (https://github.com/fonttools/fonttools), pip install fonttools brotli) strips unused glyphs: pyftsubset font.ttf --unicodes="U+0000-00FF" --flavor=woff2. Google Fonts serves per-script files with unicode-range, so Latin pages skip Cyrillic.

  • Preload the one or two faces used above the fold: <link rel="preload" as="font" type="font/woff2" href="..." crossorigin>. crossorigin is required even for same-origin fonts, or the preload goes unused.

  • Pick a font-display strategy (Section 4.4.1) and match the fallback with font-size-adjust or the size-adjust and ascent-override descriptors (not yet in Safari) to reduce layout shift at the swap.

  • Tip: Before adding a web font, try system-ui: zero bytes and instant rendering.

    4.6 Graphic Properties

    Graphic properties decide what a box looks like: the paint that fills it (backgrounds and gradients), the effects applied to its finished pixels (filters and blend modes), the ways it can disappear, and the ways its outline can depart from a rectangle. Borders, radii and shadows are in Section 4.8.5. Under the hood: Filters, blend modes, clips and masks never change layout. The browser lays the box out as a rectangle, paints it, then processes the painted pixels, often on the GPU, so animating opacity or filter does not trigger layout (Section 4.9.6).

    4.6.1 Background

    A background is a stack of layers: background-image takes a comma-separated list painted top-first over one background-color, and each longhand below takes one value per layer:
  • background-position: right 10px bottom 10px, 30% 50%; negative offsets select one icon from a sprite sheet.

  • background-size: cover fills the box (cropping), contain fits the whole image, or 50px auto.

  • background-repeat: repeat, no-repeat, repeat-x, space (whole tiles, gaps between) or round (whole tiles, rescaled).

  • background-attachment: scroll, fixed (pinned to the viewport; Safari on iOS ignores it) or local (scrolls with the element's content).

  • background-origin (default padding-box) is the box 0 0 refers to; background-clip (default border-box) is where painting stops, and text paints only behind the glyphs.

  • background-blend-mode blends the layers (Section 4.6.2).

  • The shorthand writes a layer as image position / size repeat attachment origin clip, with the color in the last layer only. Omitted parts reset, so a later background: red wipes out an earlier background-image.

    .hero {

    background:

    url("logo.svg") right 1rem bottom 1rem / 80px no-repeat, /* top layer */

    linear-gradient(#0008, #0000 60%), /* darkening scrim */

    url("hero.avif") center / cover no-repeat #37474f; /* photo + fallback color */

    }

    Listing 4.44: A hero banner with three background layers The scrim keeps white headlines readable over any photo; the color shows while the photo loads. The gallery in Section 4.6.5 renders layered tiles, background-clip: text and a clipped gradient border. Warning: background-clip: text works in all current engines but is not yet Baseline (Chrome accepted the unprefixed form only in version 120), so declare -webkit-background-clip: text too, with a readable fallback color inside @supports not (background-clip: text). Use <img> for meaningful images: backgrounds have no alt text and usually do not print.

    4.6.2 Imaging

    filter post-processes an element and its descendants as one image, applying functions left to right: blur(), brightness(), contrast(), drop-shadow(), grayscale(), hue-rotate(), invert(), opacity(), saturate(), sepia(), or url(#id) for an SVG <filter>. Unlike box-shadow, drop-shadow() follows the alpha channel, so it hugs a star or a transparent PNG. backdrop-filter (Baseline 2024) filters what lies behind the element, seen through its translucent background.mix-blend-mode blends an element with what lies beneath, using the modes of graphics editors: multiply, screen, overlay, darken, lighten, color-dodge, color-burn, hard-light, soft-light, difference, exclusion, hue, saturation, color, luminosity. isolation: isolate on a parent keeps the blending inside it.For <img> and <video>, object-fit works like background-size (fill, the stretching default, contain, cover, none, scale-down), and object-position picks the visible part. image-rendering: pixelated enlarges pixel art and QR codes with nearest-neighbor sampling.

    <style>

    body { display: grid; grid-template-columns: repeat(5, 120px); gap: 6px 10px; margin: 8px;

    font: 12px system-ui; text-align: center; }

    p { margin: 0; } p::after { content: attr(data-l); display: block; }

    i, img, canvas { display: block; position: relative; width: 120px; height: 80px; }

    .s { background: radial-gradient(circle at 70% 35%, #ffca28 12%, #0000 13%),

    linear-gradient(#0000 60%, #2e7d32 0), linear-gradient(#1565c0, #e3f2fd); }

    .s i { position: absolute; inset: 0 0 0 50%; background: #fff3; backdrop-filter: blur(4px); }

    b { position: absolute; inset: 0; font: 900 36px/80px sans-serif; color: #fff;

    mix-blend-mode: difference; }

    img { background: #eceff1; } canvas { image-rendering: pixelated; }

    </style>

    <p data-l="original"><i class="s"></i></p>

    <p data-l="grayscale(1)"><i class="s" style="filter: grayscale(1)"></i></p>

    <p data-l="hue-rotate(160deg)"><i class="s" style="filter: hue-rotate(160deg)"></i></p>

    <p data-l="blur(3px)"><i class="s" style="filter: blur(3px)"></i></p>

    <p data-l="drop-shadow()"><i style="font: 64px serif; color: #ef6c00;

    filter: drop-shadow(4px 4px 2px #6a1b9a)">&#9733;</i></p>

    <p data-l="backdrop-filter"><i class="s"><i></i></i></p>

    <p data-l="mix-blend-mode"><i class="s"><b>CSS</b></i></p>

    <p data-l="object-fit: cover"><img style="object-fit: cover"></p>

    <p data-l="object-fit: contain"><img style="object-fit: contain"></p>

    <p data-l="pixelated"><canvas width="4" height="4"></canvas></p>

    <script>

    const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="60" height="120"><rect width="60" ' +

    'height="120" fill="#6a1b9a"/><circle cx="30" cy="60" r="26" fill="#ffca28"/></svg>';

    for (const img of document.images) img.src = 'data:image/svg+xml,' + encodeURIComponent(svg);

    const c = document.querySelector('canvas').getContext('2d');

    for (let i = 0; i < 16; i++) c.fillStyle = `hsl(${i*22} 70% 50%)`, c.fillRect(i%4, i>>2, 1, 1);

    </script>

    Listing 4.45: Filters, backdrop filter, blending, object-fit and pixelated scaling Figure 4.34: Browser output of Listing 4.45 Under the hood: Any filter or backdrop-filter other than none creates a stacking context and a containing block for position: fixed descendants, so a "fixed" header inside a blurred wrapper scrolls away with it. For print, print-color-adjust: exact (Baseline 2025) keeps backgrounds the browser would drop to save ink.

    4.6.3 Visibility

    The ways to make an element disappear differ in whether it keeps its space, can still be clicked or focused, and is still announced by screen readers: Technique Keeps space Clickable, focusable Screen readers visibility: hidden Yes No Hidden opacity: 0 Yes Yes Announced display: none No No Hidden content-visibility: hidden Own box only Contents no Contents hidden .visually-hidden clip pattern No Yes Announced Table 4.16: Five ways to hide content visibility is inherited, so a child with visibility: visible reappears inside a hidden parent, which display: none never allows; on a table row or column, visibility: collapse removes it without recomputing column widths. A fully transparent button still intercepts clicks, so end a fade-out with visibility: hidden. The HTML hidden attribute is just display: none in the user-agent stylesheet, so any author display rule overrides it. Bootstrap's .visually-hidden (Section 3.11.10) squeezes screen-reader text into a clipped 1-pixel box. See Section 4.8.11 for display values and Section 4.9.1 for animating to and from display: none.content-visibility (Baseline 2025) is a performance tool. With auto, the browser skips layout and paint for the contents of off-screen elements, yet they stay in the accessibility tree, find-in-page and tab order. Pair it with contain-intrinsic-size: auto 800px, a placeholder height (remembered once the real size is known) that keeps the scrollbar from jumping. Did you know? In web.dev's demo of a long travel blog, content-visibility: auto cut initial rendering time from 232 ms to 30 ms, sevenfold (web.dev/articles/content-visibility (https://web.dev/articles/content-visibility)).

    4.6.4 Shaping

    clip-path shows only the pixels inside a shape: inset(10px round 20px), circle(40%), ellipse(), polygon(50% 0, 100% 100%, 0 100%), path() with SVG path data in pixels, shape() (Baseline 2026), whose path commands accept percentages so the shape scales, or url(#id) for an SVG <clipPath>. The edge is hard, clipped-out areas stop receiving clicks, and layout still sees the full rectangle.mask-image is the soft version: the mask's alpha sets each pixel's opacity, so gradients make fades and images make stencils. The other mask-* longhands mirror the background ones, and mask-composite combines layers with add, subtract, intersect or exclude. Unprefixed masks became Baseline widely available in June 2026; mask-border still works only in Safari.shape-outside changes how inline content wraps around a float, not how the float looks. It takes a basic shape, a box keyword or an image (cut at shape-image-threshold), padded by shape-margin.corner-shape is experimental: only Chrome and Edge 139+ ship it, Safari has it in Technology Preview, and Firefox has not implemented it. It redraws the curve inside an existing border-radius as round (default), squircle, bevel, notch, scoop, square or superellipse(k); other browsers show round corners.

    <style>

    body { display: grid; grid-template-columns: repeat(4, 150px); gap: 10px; margin: 8px;

    font: 12px/1.35 system-ui; }

    .t { height: 90px; background: linear-gradient(135deg, #1565c0, #ef6c00); }

    .poly { clip-path: polygon(50% 0, 100% 40%, 80% 100%, 20% 100%, 0 40%); }

    .wave {

    clip-path: shape(from 0 30%, curve to 100% 30% with 50% -30%, vline to 100%, hline to 0);

    }

    .fade { mask-image: linear-gradient(90deg, #000 30%, #0000); }

    .holes { mask: radial-gradient(#0000 6px, #000 7px) 0 0 / 20px 20px; }

    .wrap { grid-column: span 2; margin: 0; color: #37474f; }

    .wrap i { float: left; width: 110px; height: 100px; background: #6a1b9a;

    clip-path: polygon(0 0, 100% 0, 0 100%); shape-outside: polygon(0 0, 100% 0, 0 100%); }

    .corners { grid-column: span 2; display: flex; gap: 6px; text-align: center; }

    .corners b { flex: 1; padding-top: 30px; height: 40px; border: 3px solid #1b5e20;

    border-radius: 20px; background: #e8f5e9; font-weight: normal; }

    </style>

    <div class="t poly"></div> <div class="t wave"></div>

    <div class="t fade"></div> <div class="t holes"></div>

    <p class="wrap"><i></i>With shape-outside, each line of this paragraph starts where the

    triangular float ends, not at the edge of its box, so the text follows the diagonal.</p>

    <div class="corners"><b>round</b><b style="corner-shape: squircle">squircle</b>

    <b style="corner-shape: bevel">bevel</b><b style="corner-shape: notch">notch</b>

    <b style="corner-shape: scoop">scoop</b></div>

    Listing 4.46: clip-path, shape(), masks, shape-outside and corner-shape Figure 4.35: Browser output of Listing 4.46

    4.6.5 Gradients in Depth

    Beyond the basics in Section 4.2.7, gradients get their power from color stops and tiling:
  • Stops and hints. red 20% 40% gives a solid band; a bare percentage between two colors (red, 30%, blue) is a hint that moves the midpoint; a stop at 0 after another stop makes a hard edge.

  • Sizing and angles. radial-gradient(circle closest-side at 30%, ...) ends at the nearest edge (farthest-corner is the default); conic-gradient(from 90deg, ...) stops run around the center.

  • Tiles. A gradient has no intrinsic size, so background-size turns it into a repeating tile, and layered tiles make patterns.

  • Color space. in oklch longer hue, red, red travels the long way around the hue wheel, a rainbow from one color (Baseline 2024).

  • <style>

    body { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0 10px; margin: 8px;

    font: 12px system-ui; }

    p { position: relative; height: 56px; margin: 0 0 20px; outline: 1px solid #cfd8dc; }

    p::after { content: attr(data-l); position: absolute; top: 100%; left: 0; white-space: nowrap;

    font: 12px system-ui; color: #000; }

    </style>

    <p data-l="hint at 15%" style="background: linear-gradient(90deg, #1565c0, 15%, #ef6c00)"></p>

    <p data-l="closest-side"

    style="background: radial-gradient(circle closest-side at 30%, #fff176, #37474f)"></p>

    <p data-l="conic pie chart" style="width: 56px; border-radius: 50%;

    background: conic-gradient(#1565c0 0 45%, #2e7d32 0 70%, #ef6c00 0)"></p>

    <p data-l="checkerboard tile"

    style="background: repeating-conic-gradient(#37474f 0 25%, #fff 0 50%) 0 0 / 20px 20px"></p>

    <p data-l="grid paper: two layers" style="background: linear-gradient(#90caf9 1px, #0000 0)

    0 0 / 100% 12px, linear-gradient(90deg, #90caf9 1px, #fff 0) 0 0 / 12px 100%"></p>

    <p data-l="background-clip: text" style="font: 900 34px/56px system-ui; color: #0000; outline: 0;

    background: linear-gradient(90deg, #6a1b9a, #ef6c00); background-clip: text">Gradient</p>

    <p data-l="in oklch longer hue"

    style="background: linear-gradient(90deg in oklch longer hue, red, red)"></p>

    <p data-l="gradient border" style="border: 6px solid #0000; border-radius: 12px; outline: 0;

    background: linear-gradient(#fff 0 0) padding-box, linear-gradient(90deg, #6a1b9a, #ef6c00)

    border-box"></p>

    Listing 4.47: A gradient gallery: hints, sizing, conic charts, tiles, text clipping and borders Figure 4.36: Browser output of Listing 4.47The gradient border paints white over the padding box and a gradient under the transparent border; unlike border-image, it respects border-radius. Tip: Gradients themselves do not animate, but a registered custom property does: declare @property --angle { syntax: "<angle>"; inherits: false; initial-value: 0deg; }, use conic-gradient(from var(--angle), ...) and animate --angle (Section 4.4.10).

    4.7 List and Table Properties

    Lists and tables (Sections 2.4.1 and 2.4.3) come with their own CSS layout models: a list item generates a marker box beside its content, and a table sizes rows, columns and cells together with the table layout algorithm defined back in CSS 2. The properties below control those two models; padding, colors and fonts work as on any other box.

    4.7.1 List

    display: list-item, the default for <li>, generates a marker, controlled by three longhands and the list-style shorthand:
  • list-style-type: a keyword (disc, square, decimal, decimal-leading-zero, lower-roman, upper-alpha), a language system (simp-chinese-informal, hiragana, arabic-indic), a quoted string such as "✓ ", a name defined with @counter-style (Section 4.4.10), or none.

  • list-style-position: outside (default) hangs the marker in the padding; inside puts it on the first line, so wrapped lines run under it.

  • list-style-image: url(star.svg) uses an image you cannot size or align, so a ::before icon is usually better.

  • The ::marker pseudo-element styles the marker itself. Chrome and Firefox allow color, font-*, content and a few others; Safari supports only color and font-size, so change the symbol with list-style-type rather than ::marker { content }. For markers with boxes or backgrounds, build your own with a CSS counter.

    <style>

    body { display: flex; gap: 24px; margin: 8px; font: 14px/1.5 system-ui, sans-serif; }

    ol, ul { margin: 0; padding-left: 28px; }

    .cn { list-style: simp-chinese-informal; padding-left: 52px; }

    .done { list-style-type: "\2713 "; } .done li::marker { color: #2e7d32; font-weight: bold; }

    .steps { list-style: none; padding: 0; counter-reset: step; }

    .steps li { counter-increment: step; margin-bottom: 4px; }

    .steps li::before { content: counter(step); display: inline-grid; place-items: center;

    width: 22px; height: 22px; margin-right: 8px; border-radius: 50%; background: #1565c0;

    color: #fff; }

    </style>

    <ol class="cn" start="9"><li>Nine</li><li>Ten</li><li>Eleven</li></ol>

    <ul class="done"><li>Design</li><li>Build</li><li>Test</li></ul>

    <ol class="steps" role="list"><li>Install</li><li>Configure</li><li>Deploy</li></ol>

    Listing 4.48: Language counters, string markers, ::marker and a custom counter Figure 4.37: Browser output of Listing 4.48 Warning: Safari deliberately removes list semantics from a list with list-style: none unless it sits inside <nav>, so VoiceOver stops announcing it as a list. Add role="list" to <ul> and <ol> elements whose item count matters, such as the step list above.

    4.7.2 Table

    Five properties belong to tables alone. border-collapse: collapse merges adjacent cell borders; the default separate keeps them apart by border-spacing: 4px 8px. caption-side: bottom moves the <caption>. empty-cells: hide hides empty cells' borders in separate mode. table-layout: fixed sizes columns from the table width and the first row only, so long content cannot widen a column and large tables render faster; the default auto reads every cell first.

    <style>

    table { width: 280px; border-collapse: collapse; table-layout: fixed; font: 14px system-ui; }

    caption { caption-side: bottom; font-size: 12px; }

    th, td { border-bottom: 1px solid #cfd8dc; padding: 4px 8px; overflow-wrap: anywhere; }

    th { background: #37474f; color: #fff; text-align: left; }

    tr:nth-child(even) { background: #eceff1; }

    td:last-child, th:last-child { width: 60px; text-align: right;

    font-variant-numeric: tabular-nums; }

    </style>

    <table>

    <caption>Scores, fixed layout</caption>

    <thead><tr><th>Name</th><th>Score</th></tr></thead>

    <tbody><tr><td>Mike</td><td>90.5</td></tr><tr><td>Catherine</td><td>82.25</td></tr>

    <tr><td>TengkuAhmadIskandarShahIbrahim</td><td>73</td></tr>

    <tr><td>Alice</td><td>101.75</td></tr></tbody>

    </table>

    Listing 4.49: A collapsed, fixed-layout table with zebra rows and aligned numbers Figure 4.38: Browser output of Listing 4.49The long name wraps inside its column instead of stretching the table, and tabular-nums lines up the digits. For long tables, thead th { position: sticky; top: 0; } keeps the header in view, and wrapping the table in a <div> with overflow-x: auto lets it scroll sideways on phones.

    4.8 Box Model Properties

    Every rendered element becomes one or more rectangular boxes, and layout is the work of sizing those boxes and placing them. The first five subsections treat a single box: its layers, its position and stacking, its size, its spacing and its decoration. The rest arranges many boxes with Flexbox, Grid, transforms, scroll snapping, anchor positioning and formatting contexts.Keep one question in mind throughout: which box is this measuring from? Percentages and offsets resolve against the containing block, an ancestor chosen by the position value, and most "why is it over there?" bugs come from the wrong containing block.

    4.8.1 The Box Model and box-sizing

    Each box has four nested areas: the content; padding, which shows the background; the border; and transparent margin, which pushes neighbors away. DevTools draws the same layers in its Computed tab.Figure 4.39: The four areas of a CSS box and what width measures under each box-sizing valueBy default box-sizing: content-box applies width and height to the content area only, so padding and border are added on top: a width: 100% box with padding overflows its parent. box-sizing: border-box makes width and height include padding and border, which matches how designers measure and is already the browser default for <button>, <select> and <table>.Most projects switch every element to border-box, as Bootstrap's Reboot and Tailwind's Preflight do. The inheriting form below lets a third-party widget opt back out with one rule on its root.

    html { box-sizing: border-box; }

    *, *::before, *::after { box-sizing: inherit; }

    Listing 4.50: An inheritable border-box reset History: Netscape 4 and Internet Explorer 4 measured width from border to border, while the CSS specification measured the content. IE6 adopted the spec model only in standards mode, one reason <!doctype html> still matters; box-sizing later made the old model a per-element choice.

    4.8.2 Positioning

    The position property decides whether a box stays in normal flow and what its offsets top, right, bottom and left are measured from. The inset shorthand sets all four (inset: 0 fills the containing block); inset-inline-start and similar are the writing-mode-aware forms. Value Keeps its space? Offsets measured from Stacking context static Yes Offsets ignored No relative Yes Its normal position With z-index absolute No Nearest non-static ancestor With z-index fixed No Viewport Always sticky Yes Nearest scroll container Always Table 4.17: The five position values A relative parent with an absolute child places badges. A sticky element scrolls until it reaches its offset, then sticks until its parent's end pushes it away (Baseline widely available since 2022). Below, the header needs z-index: 1, or the later positioned cards paint over it.

    <style>

    body { font: 15px system-ui; margin: 8px; }

    .list { height: 130px; width: 300px; overflow: auto; border: 1px solid #90a4ae; }

    h4 { position: sticky; top: 0; z-index: 1; margin: 0; padding: 4px 8px; color: #fff;

    background: #1565c0; }

    p { position: relative; margin: 10px; padding: 8px; background: #e3f2fd; }

    b { position: absolute; top: -6px; right: -6px; padding: 0 6px; border-radius: 9px;

    background: #ef6c00; color: #fff; }

    </style>

    <div class="list"><h4>A</h4><p>Alice</p><p>Anna</p><h4>B</h4><p>Bob <b>3</b></p>

    <p>Bella <b>new</b></p><p>Brian</p></div>

    <script>document.querySelector('.list').scrollTop = 150;</script>

    Listing 4.51: A sticky letter header over scrolled cards with absolute badges Figure 4.40: Browser output of Listing 4.51z-index orders positioned boxes and flex or grid items, but only against siblings in the same stacking context, a group the browser paints as one unit. Besides the root, a context is created by a positioned box with a z-index, fixed and sticky boxes, opacity below 1, transform, filter, clip-path, mask, contain: paint, container-type: inline-size and isolation: isolate. A child's z-index: 9999 never escapes its parent's context.Figure 4.41: The menu's z-index 9999 only competes inside its header's context, so main (z-index 2) covers itisolation: isolate creates a context with no side effect, fencing in a component's z-index values. Modal dialogs and popovers enter the top layer, above every z-index (Section 2.6.1). Warning: a transform, filter, perspective or contain: paint on an ancestor makes it the containing block of fixed descendants, so a "fixed" toolbar scrolls away. A sticky element does nothing without an inset such as top, or when an ancestor between it and the real scroller has overflow: hidden; use overflow: clip there.

    4.8.3 Sizing

    width and height take a length, a percentage of the containing block, or auto. A percentage height needs a parent with a definite height. min-width, max-width, min-height and max-height clamp the result, and the minimum wins a conflict. inline-size and block-size are the writing-mode-aware names (Section 4.5.4).Three intrinsic keywords size a box from its content, all Baseline widely available: min-content is the narrowest width without overflow (the longest word), max-content is the width with no wrapping at all, and fit-content equals min(max-content, max(min-content, available space)). aspect-ratio: 16 / 9 (Baseline since 2021) derives the missing dimension when one of width or height is auto.

    <style>

    body { font: 15px system-ui; margin: 8px; display: flex; gap: 110px; align-items: start; }

    section { width: 220px; flex: none; border: 1px dashed #90a4ae; }

    p { margin: 4px; padding: 2px 6px; background: #e3f2fd; }

    .video { width: 200px; aspect-ratio: 16 / 9; padding: 6px; background: #ef6c00; color: #fff; }

    </style>

    <section><p style="width: min-content">min-content wraps every word</p>

    <p style="width: max-content">max-content never wraps, even past its parent</p>

    <p style="width: fit-content">fit-content shrinks to fit</p></section>

    <div class="video">aspect-ratio: 16 / 9</div>

    Listing 4.52: Intrinsic width keywords and aspect-ratio Figure 4.42: Browser output of Listing 4.52Practical rules: give fluid media max-width: 100%; height: auto, use min-height rather than height for panels that hold text, and keep the width and height attributes on <img> so the browser reserves the right aspect ratio before the file arrives, avoiding layout shift. aspect-ratio is ignored when both dimensions are set.

    4.8.4 Margin, Border and Padding

    margin, padding, border-width, border-style and border-color take one to four values clockwise from the top: padding: 10px 20px means 10px top and bottom, 20px left and right. Percentages in margin and padding resolve against the containing block's width, even vertically. Margins may be negative or auto (margin-inline: auto centers a sized block). A border needs a style to show: border: 1px solid #90a4ae. outline draws a line that takes no space, ideal for focus rings.Margin collapsing trips up everyone once: vertical margins of adjacent blocks merge into the larger of the two, and a first child's top margin can escape through a parent with no border or padding. Margins never collapse in flex and grid containers, on floats or absolutely positioned boxes, or through a parent with display: flow-root.Logical properties replace top, right, bottom and left with block (the direction lines stack) and inline (the direction text runs), so a margin-inline-start indent sits on the left in English and on the right in Arabic, and one stylesheet serves both; prefer them in new components. They have been Baseline widely available since 2024. Physical property (English) Logical equivalent margin-top, margin-bottom margin-block-start, margin-block-end padding-left + padding-right padding-inline width, height inline-size, block-size top, left inset-block-start, inset-inline-start border-top-left-radius border-start-start-radius Table 4.18: Physical and logical box properties

    4.8.5 Radius, Shadow and Wrapping

    border-radius rounds the border box, and background, border and shadow follow the curve. One to four values go clockwise from top-left, 50% turns a square into a circle, a huge length such as 999px makes a pill, and 40px / 20px draws elliptical corners. Children still overflow the corners unless the parent sets overflow: hidden or clip.box-shadow: x y blur spread color paints a shadow outside the border box without affecting layout; inset moves it inside, and a comma-separated list layers several, first on top. A tight dark shadow plus a wide faint one looks natural; animating shadows repaints every frame, so animate the opacity of a shadowed ::after instead. A spread with no blur, such as 0 0 0 100vmax rgb(0 0 0 / .7), dims the whole page around a highlighted element.When an inline box wraps across lines, box-decoration-break: slice (the default) cuts its padding, border, radius and shadow at each break, while clone gives every fragment its own. It is not Baseline: Chromium 130+ and Firefox support it, and Safari needs -webkit-box-decoration-break, so declare both.

    <style>

    body { font: 16px/2.3 system-ui; display: flex; gap: 22px; align-items: center; margin: 14px; }

    .card { width: 80px; height: 64px; border-radius: 12px;

    box-shadow: 0 1px 2px rgb(0 0 0 / .25), 0 8px 20px rgb(0 0 0 / .15); }

    .pill { padding: 4px 14px; border-radius: 999px; box-shadow: inset 0 2px 6px rgb(0 0 0 / .35); }

    p { width: 130px; margin: 0; }

    span { padding: 3px 8px; border-radius: 8px; background: #e3f2fd; box-shadow: 3px 3px #1565c0; }

    .clone { -webkit-box-decoration-break: clone; box-decoration-break: clone; }

    </style>

    <div class="card"></div><div class="pill">inset</div>

    <p><span>slice cuts the box at the break</span></p>

    <p><span class="clone">clone repeats the box per line</span></p>

    Listing 4.53: Layered and inset shadows, and slice versus clone on wrapped text Figure 4.43: Browser output of Listing 4.53

    4.8.6 Flexbox

    display: flex turns an element into a flex container whose children line up along one main axis, chosen by flex-direction (row, the default, column, or a -reverse variant); the perpendicular one is the cross axis. justify-* properties work on the main axis and align-* on the cross axis, so justify-content: center centers horizontally in a row but vertically in a column.Figure 4.44: The two axes of a flex container with flex-direction: rowEach item starts at its flex-basis (auto means its width or content size). Leftover space is shared in proportion to flex-grow; on overflow, items give space back in proportion to flex-shrink times their basis. The flex shorthand sets all three: initial is 0 1 auto (the default), auto is 1 1 auto, none is 0 0 auto, and flex: 1 is 1 1 0, which ignores content size for equal shares. flex-wrap: wrap starts new lines that flex independently, and gap has replaced spacer margins in every major browser since 2021.

    <style>

    body { font: 15px system-ui, sans-serif; margin: 12px; }

    nav, .cards { display: flex; gap: 10px; }

    nav { align-items: center; padding: 8px 12px; background: #37474f; color: #fff; }

    .login { margin-left: auto; } /* an auto margin absorbs the free space */

    .cards { flex-wrap: wrap; margin-top: 10px; }

    .card { flex: 1 1 100px; padding: 8px; border: 2px solid #1565c0; background: #e3f2fd; }

    .wide { flex-grow: 2; } /* twice the share of leftover space */

    .full { flex-basis: 100%; } /* too wide for the row, so it wraps */

    </style>

    <nav><b>Shop</b><a>Deals</a><a>Help</a><span class="login">Log in</span></nav>

    <div class="cards">

    <div class="card">flex: 1 1 100px</div><div class="card wide">flex-grow: 2</div>

    <div class="card">Third</div><div class="card full">flex-basis: 100% wraps</div>

    </div>

    Listing 4.54: A toolbar and a wrapping card row built with Flexbox Figure 4.45: Browser output of Listing 4.54Two pitfalls: flex items default to min-width: auto, so a long URL refuses to shrink and overflows (add min-width: 0), and order or row-reverse change only the visual order, not focus or screen-reader order. Use Flexbox for one-dimensional runs such as toolbars and centering, and Grid when rows and columns must align. Bootstrap's .d-flex utilities and row/column system (Section 3.2) are built on these properties.

    4.8.7 Grid

    display: grid sizes rows and columns together, so items in different rows line up. grid-template-columns and grid-template-rows define tracks separated by numbered lines: three columns have lines 1 to 4, or -4 to -1 counted from the end. Items flow into free cells (grid-auto-flow: dense backfills holes) or sit on lines, as in grid-column: 1 / -1 (full width) or grid-row: 2 / span 3. Tracks accept fr (a share of leftover space), minmax(12rem, 1fr) and repeat(auto-fit, minmax(12rem, 1fr)), which fits as many columns as possible without a media query (auto-fill keeps empty tracks; auto-fit collapses them). grid-template-areas names regions with ASCII art.

    <style>

    body { font: 14px system-ui, sans-serif; margin: 10px; }

    .page { display: grid; gap: 8px; grid-template: auto 1fr auto / 130px 1fr;

    grid-template-areas: "head head" "side main" "foot foot"; }

    .page > * { background: #eceff1; border: 2px solid #37474f; padding: 6px; }

    header { grid-area: head; } aside { grid-area: side; }

    main { grid-area: main; } footer { grid-area: foot; }

    .tiles { display: grid; gap: 6px; grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); }

    .tiles div { background: #e3f2fd; border: 1px solid #1565c0; padding: 6px; }

    .tiles .wide { grid-column: span 2; background: #fff3e0; }

    </style>

    <div class="page"><header>header (grid-area: head)</header><aside>aside: 130px</aside>

    <main><div class="tiles"><div class="wide">span 2</div><div>2</div><div>3</div><div>4</div>

    <div>5</div><div>6</div></div></main><footer>footer spans both columns</footer></div>

    Listing 4.55: Named areas for the page frame and auto-fit tiles inside main Figure 4.46: Browser output of Listing 4.55A nested grid normally has its own tracks, so sibling cards cannot align their titles and buttons. grid-template-rows: subgrid (or -columns) makes a child reuse the parent tracks it spans. Subgrid became Baseline in September 2023 (Chrome 117, Firefox 71, Safari 16) and is now widely available.

    <style>

    body { font: 14px system-ui, sans-serif; margin: 10px; }

    .cards { display: grid; grid-template-columns: repeat(3, 1fr); column-gap: 10px; }

    .card { display: grid; grid-row: span 3; grid-template-rows: subgrid; row-gap: 4px;

    padding: 8px; background: #e8f5e9; border: 2px solid #2e7d32; }

    h4, p { margin: 0; } button { justify-self: start; }

    </style>

    <div class="cards">

    <div class="card"><h4>Starter</h4><p>One site.</p><button>Buy</button></div>

    <div class="card"><h4>Team plan, longer name</h4><p>Five sites, shared billing and priority

    support.</p><button>Buy</button></div>

    <div class="card"><h4>Agency</h4><p>Unlimited sites.</p><button>Buy</button></div>

    </div>

    Listing 4.56: Cards whose rows are borrowed from the parent grid with subgrid Figure 4.47: Browser output of Listing 4.56Masonry (Pinterest-style) layout keeps strict columns but packs items of different heights upward. After years of competing proposals, including grid-template-rows: masonry and display: masonry, the CSS Working Group chose a new display value in CSS Grid Level 3: display: grid-lanes. It reuses the track syntax, so display: grid-lanes; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)) is a complete gallery; each item goes into the lane where it sits closest to the start. MDN marks it experimental: it shipped in Safari 26.4 (March 2026), Chrome and Edge have offered it behind a flag since version 140, and Firefox has not shipped it. Declare an ordinary grid first and switch inside @supports (display: grid-lanes). Tip: Click the grid badge beside a container in Chrome, Edge or Firefox DevTools to overlay its line numbers and area names. Bootstrap's CSS Grid mode (Section 3.2.8) is just repeat(12, 1fr) plus grid-column spans.

    4.8.8 Transformation

    transform moves, turns, scales or skews an element's rendered box without affecting layout, so neighbors stay put. The functions translate(), rotate(), scale(), skew() and matrix() (with X, Y and 3D variants) pivot around transform-origin, and order matters: rotate(45deg) translate(100px) moves along the rotated axis. The individual properties translate, rotate and scale, widely available since 2025, apply in that fixed order before transform, so a hover rule can change scale alone. For 3D, give the parent perspective: 400px (smaller means stronger foreshortening), rotate children with rotateX(), rotateY() or translateZ(), add transform-style: preserve-3d for nested depth, and backface-visibility: hidden for flipping cards.

    <style>

    body { font: 14px system-ui, sans-serif; margin: 30px 40px; display: flex; gap: 40px; }

    .box { width: 110px; height: 60px; display: grid; place-items: center;

    border: 2px solid #1565c0; background: #e3f2fd; }

    .a { transform: rotate(12deg) skewX(-12deg); transform-origin: left top; }

    .b { rotate: -8deg; scale: 1.1; }

    .stage { perspective: 250px; }

    .c { transform: rotateY(55deg); background: #fff3e0; }

    </style>

    <div class="box a">rotate + skew</div><div class="box b">rotate, scale</div>

    <div class="stage"><div class="box c">rotateY(55deg)</div></div>

    Listing 4.57: A 2D transform, individual properties and a 3D rotation Figure 4.48: Browser output of Listing 4.57 Under the hood: Because transforms skip layout, the browser can put the element on its own compositor layer and let the GPU redraw only that texture, which is why animating transform beats animating left or width. A transform also creates a stacking context and a containing block for position: fixed descendants (Section 4.8.2).

    4.8.9 Scrolling and Snapping

    A box with overflow: auto or scroll (Section 4.5.3) is a scroll container, tuned with:
  • scroll-behavior: smooth animates jumps from #fragment links and scrollIntoView(); honor prefers-reduced-motion.

  • scroll-padding-top: 4rem keeps a sticky header from covering a link target.

  • overscroll-behavior: contain stops scroll chaining, where reaching the end of a modal scrolls the page behind it.

  • scrollbar-gutter: stable reserves scrollbar space so content does not shift, and scrollbar-width: thin plus scrollbar-color style the bar. They became Baseline in December 2024 and (scrollbar-color) December 2025, replacing ::-webkit-scrollbar.

  • Scroll snapping makes a scroller settle on defined positions, the CSS-only carousel. Set scroll-snap-type on the container (an axis plus mandatory or proximity) and scroll-snap-align on the children; scroll-snap-stop: always stops a fast swipe from skipping slides.

    .carousel {

    display: flex; gap: 1rem; overflow-x: auto;

    scroll-snap-type: x mandatory;

    scroll-padding-inline: 1rem; /* snap 1rem inside the edge */

    overscroll-behavior-x: contain;

    scrollbar-width: thin;

    }

    .carousel > figure { flex: 0 0 85%; scroll-snap-align: start; scroll-snap-stop: always; }

    Listing 4.58: A horizontal snap carousel For full-page sections of varying height prefer proximity, so the browser never yanks readers away from text they are still reading.

    4.8.10 Anchor Positioning

    Tooltips and dropdowns must sit next to an element that can be anywhere, which used to need JavaScript such as Floating UI (github.com/floating-ui/floating-ui (https://github.com/floating-ui/floating-ui)) measuring boxes on every scroll. CSS anchor positioning does it during layout, and works in Chrome and Edge 125+, Safari 26+ and Firefox 147+ (January 2026). Name the reference with anchor-name: --btn, then give an absolutely or fixed-positioned element position-anchor: --btn and either position-area: top center (one cell of a 3x3 grid around the anchor) or anchor() insets such as top: anchor(bottom). position-try-fallbacks: flip-block flips it when it would overflow; @position-try (Section 4.4.10) defines custom fallbacks. If the anchor is missing or display: none, the element falls back to normal absolute positioning.

    <style>

    body { font: 14px system-ui, sans-serif; margin: 44px 24px; display: flex; gap: 120px; }

    .save { anchor-name: --save; } .file { anchor-name: --file; }

    .tip { position: absolute; position-anchor: --save; position-area: top center;

    margin-bottom: 6px; background: #37474f; color: #fff; padding: 3px 8px; }

    .menu { position: absolute; position-anchor: --file; margin: 0; padding: 4px 10px;

    top: anchor(bottom); left: anchor(left); min-width: anchor-size(width);

    position-try-fallbacks: flip-block; list-style: none;

    box-sizing: border-box; background: #e3f2fd; border: 1px solid #1565c0; }

    </style>

    <button class="save">Save</button><span class="tip">Ctrl+S</span>

    <button class="file">File menu</button><ul class="menu"><li>New</li><li>Open</li></ul>

    Listing 4.59: A tooltip placed with position-area and a menu placed with anchor() Figure 4.49: Browser output of Listing 4.59

    4.8.11 Display Types and Formatting Contexts

    display sets two things: the outer type (block or inline), how the box joins its parent's layout, and the inner type (flow, flow-root, flex, grid, table), how its children are laid out. The two-keyword syntax, widely available since January 2026, makes this explicit: Single keyword Two keywords Children form a block / inline block flow / inline flow Normal flow flow-root / inline-block block flow-root / inline flow-root New block formatting context flex / inline-flex block flex / inline flex Flex formatting context grid / inline-grid block grid / inline grid Grid formatting context Table 4.19: Display keywords and their outer and inner types A block formatting context (BFC) is an isolated layout region: it contains its floats, keeps outside floats from intruding, and stops child margins collapsing through its edges. display: flow-root creates one with no side effects, replacing clearfix and overflow: hidden hacks. Floats, absolutely positioned boxes, inline-block, contain: layout, and flex or grid items start one too, which explains many "my margins stopped collapsing" surprises. display: contents removes an element's own box so its children join the grandparent's grid, but webstatus.dev still rates it Baseline limited, so test it with a screen reader.

    4.9 Animation Properties

    Motion tells users what just changed: a menu slides out of its button, a card grows into a detail view, a bar tracks reading progress. CSS offers four declarative mechanisms, distinguished by what drives the motion: a changing property value (transitions), a clock (keyframe animations), scroll position (scroll-driven animations) and a DOM update or navigation (view transitions). The browser interpolates the frames and can often run them off the main thread, provided you animate cheap properties (Section 4.9.6).

    4.9.1 Transitions

    A transition interpolates a property whenever its computed value changes, through :hover, a toggled class or a script. The shorthand lists property, duration, timing function and delay: transition: width 3s linear 1s, opacity .3s. Declare it on the base state, not only on :hover, so the element animates back as well.The timing function maps elapsed time to progress. ease (the default), ease-in, ease-out and ease-in-out are preset cubic-bezier() curves; steps(n) jumps, as sprite sheets need; and linear() (widely available since June 2026) runs through any list of points, so it can overshoot like a spring.

    <style>

    body { font: 12px system-ui, sans-serif; margin: 10px 16px; }

    .row { display: grid; grid-template-columns: 190px 1fr; align-items: center; height: 36px; }

    .track { position: relative; height: 24px; border-bottom: 2px solid #ccc; margin-right: 90px; }

    i { position: absolute; left: 0; width: 22px; height: 22px; border-radius: 50%;

    background: #1565c0; opacity: calc(.25 + var(--t) * .75);

    animation: move 1s var(--ease) paused both;

    animation-delay: calc(-1s * min(var(--t), .999)); }

    @keyframes move { to { left: 100%; } }

    </style>

    <script>

    for (const e of ['linear', 'ease', 'ease-in-out', 'steps(4)', 'linear(0, 1.2 50%, .9, 1)'])

    document.write(`<div class="row"><code>${e}</code><div class="track">` + [0, .25, .5, .75, 1]

    .map(t => `<i style="--t:${t};--ease:${e}"></i>`).join('') + '</div></div>');

    </script>

    Listing 4.60: Five timing functions frozen at 0%, 25%, 50%, 75% and 100% of their duration Figure 4.50: Browser output of Listing 4.60Each dot is an animation paused at a negative delay; the faintest dot is the start. steps(4) stays at 75% until the duration ends, and the linear() spring overshoots at 50%.display and visibility cannot be interpolated. With transition-behavior: allow-discrete (Baseline 2024) they still transition: display flips to none at the end of the duration and away from none at the start, so the element stays rendered while opacity animates. A newly shown element has no "before" style; @starting-style (Baseline 2024) supplies one for its first style update.

    [popover] {

    opacity: 0; translate: 0 -8px; /* exit (closed) state */

    transition: .25s allow-discrete; transition-property: opacity, translate, display, overlay;

    }

    [popover]:popover-open { opacity: 1; translate: 0 0;

    @starting-style { opacity: 0; translate: 0 8px; } /* where the entry begins */

    }

    Listing 4.61: Entry and exit transitions for a popover in pure CSS overlay keeps a closing popover in the top layer until the fade ends (Chromium only). Pitfalls: height: auto does not interpolate in most browsers (transition grid-template-rows from 0fr to 1fr instead), transition: all animates properties you did not intend, and transitionend fires once per property.

    4.9.2 Keyframe Animations

    An animation plays an @keyframes sequence (Section 4.4.2) on its own clock: animation: slidein .5s ease-out 1s 3 alternate both sets name, duration, timing function (applied between each pair of keyframes, overridable inside one), delay, iteration count (or infinite), direction and fill mode; animation-play-state pauses it. Fill mode backwards applies the first keyframe during the delay, forwards keeps the last one afterwards, and both does both. A negative delay starts part-way through, which staggers elements or, with paused, freezes a frame.

    <style>

    body { margin: 10px; display: flex; gap: 8px; font: 12px system-ui, sans-serif; }

    figure { margin: 0; width: 72px; height: 130px; position: relative; text-align: center;

    border-bottom: 3px solid #37474f; background: #eceff1; }

    .ball { position: absolute; left: 20px; bottom: 0; width: 32px; height: 32px;

    border-radius: 50%; background: #ef6c00; transform-origin: 50% 100%;

    animation: bounce 1s infinite paused; animation-delay: calc(var(--f) * -125ms); }

    @keyframes bounce {

    0%, 100% { translate: 0 0; scale: 1.25 .75; }

    15% { translate: 0 0; scale: .9 1.1; animation-timing-function: ease-out; }

    50% { translate: 0 -75px; scale: 1 1; animation-timing-function: ease-in; }

    90% { scale: .9 1.1; }

    }

    </style>

    <script>

    for (let f = 0; f < 8; f++)

    document.write(`<figure>${f * 125} ms<i class="ball" style="--f:${f}"></i></figure>`);

    </script>

    Listing 4.62: A squash-and-stretch bounce, one frame every 125 ms Figure 4.51: Browser output of Listing 4.62Per-keyframe timing functions slow the ball as it rises and speed it up as it falls; animating only translate and scale keeps it on the compositor (Section 4.9.6). Script gets animationstart, animationiteration and animationend events, and el.getAnimations() returns controllable Animation objects. Wrap large movement in @media (prefers-reduced-motion: no-preference).

    4.9.3 Offset (Motion Path)

    Motion path moves an element along a route. offset-path defines it: an SVG path() string, ray(45deg closest-side), a basic shape such as circle(40%), or url(#id). offset-distance (0% to 100%) places the element on it; animate it to produce movement. offset-rotate sets the heading: auto (the default) follows the path's direction, auto 90deg adds a correction, reverse faces backwards, and 0deg keeps it upright. offset-anchor picks the point that sits on the path.

    <style>

    .stage { position: relative; margin: 14px; height: 120px; }

    svg { position: absolute; inset: 0; overflow: visible; }

    .arrow { position: absolute; top: 0; left: 0; width: 36px; height: 20px; background: #1565c0;

    clip-path: polygon(0 25%, 60% 25%, 60% 0, 100% 50%, 60% 100%, 60% 75%, 0 75%);

    offset-path: path("M 20 100 C 150 -30, 260 150, 400 50 S 560 10, 580 100");

    animation: fly 1s linear paused both; animation-delay: calc(var(--t) * -1s); }

    @keyframes fly { to { offset-distance: 100%; } }

    </style>

    <div class="stage">

    <svg><path d="M 20 100 C 150 -30, 260 150, 400 50 S 560 10, 580 100"

    fill="none" stroke="#90a4ae" stroke-width="2" stroke-dasharray="6 6"/></svg>

    <script>for (const t of [0, .2, .4, .6, .8, .999])

    document.write(`<i class="arrow" style="--t:${t}"></i>`);</script>

    </div>

    Listing 4.63: Six frames of an arrow following a path() with offset-rotate: auto Figure 4.52: Browser output of Listing 4.63Motion path is widely available since 2025, though ray(), shapes and url() arrived later than path() (Chrome 116, Firefox 122). path() coordinates are fixed pixels, so prefer circle() or inset() in responsive layouts. The offset is applied as a transform, so it animates cheaply.

    4.9.4 Scroll-driven Animations

    A scroll-driven animation is a @keyframes animation whose progress comes from scrolling instead of the clock. Set animation-timeline after the animation shorthand, which resets it:
  • scroll(<scroller> <axis>) maps a container's scroll range to 0-100%; defaults are the nearest scroller and the block axis.

  • view() tracks the element crossing its scrollport; animation-range narrows it with entry, exit, contain or cover plus optional percentages.

  • Durations are ignored. To drive an element outside the scroller, name the timeline with scroll-timeline or view-timeline and hoist the name to a common ancestor with timeline-scope.

    <style>

    .app { timeline-scope: --feed; font: 14px system-ui; } /* lets the bar see the name */

    .bar { height: 8px; background: #2e7d32; transform-origin: left;

    animation: grow linear both; animation-timeline: --feed; }

    .feed { height: 170px; overflow-y: auto; border: 1px solid #b0bec5; scroll-timeline: --feed; }

    .card { margin: 10px; padding: 12px; background: #e3f2fd; border-radius: 8px;

    animation: reveal linear both; animation-timeline: view();

    animation-range: entry 0% cover 35%; }

    @keyframes grow { from { scale: 0 1; } }

    @keyframes reveal { from { opacity: 0; translate: -80px 0; } }

    </style>

    <div class="app"><div class="bar"></div><div class="feed"><script>

    for (let i = 1; i <= 12; i++) document.write(`<div class="card">Card ${i}</div>`);

    document.currentScript.parentNode.scrollTop = 200;

    </script></div></div>

    Listing 4.64: A progress bar on a named scroll timeline and cards revealed with view() Figure 4.53: Browser output of Listing 4.64Scrolled 200 px, the bar has grown in proportion and the entering card is still sliding in. Unlike a scroll handler, a scroll timeline animating compositor-only properties (Section 4.9.6) can update off the main thread. Chrome and Edge 115 and Safari 26 support it; Firefox only in Nightly, so wrap the rules in @supports (animation-timeline: view()).

    4.9.5 View Transitions

    A view transition animates between two whole DOM states. Wrap the update in document.startViewTransition(callback): the browser snapshots the page, runs the callback (rendering is paused meanwhile, so fetch data first), and overlays a pseudo-element tree over the updated DOM: ::view-transition > ::view-transition-group(name) > ::view-transition-image-pair(name) > ::view-transition-old(name) and ::view-transition-new(name). By default the page is one root group that cross-fades; an element with view-transition-name: hero gets its own group, whose box animates from old to new. Names must be unique; match-element generates them, and view-transition-class styles many groups at once.

    <style>

    .stage { margin: 12px; width: 440px; height: 200px; outline: 2px dashed #b0bec5; }

    .card { view-transition-name: hero; width: 120px; height: 80px;

    border-radius: 12px; background: #6a1b9a; }

    .open .card { width: 440px; height: 200px; background: #ef6c00; }

    ::view-transition-group(hero) { animation-duration: 1s; }

    ::view-transition-old(hero), ::view-transition-new(hero) { height: 100%; }

    </style>

    <div class="stage"><div class="card"></div></div>

    <script>

    onload = () => setTimeout(() => {

    const vt = document.startViewTransition(() => document.body.classList.add('open'));

    vt.ready.then(() => document.getAnimations().forEach(a => { a.pause(); a.currentTime = 250; }));

    }, 100);

    </script>

    Listing 4.65: A card morphing toward the dashed banner box, paused 250 ms into a 1 s transition Figure 4.54: Browser output of Listing 4.65The group has grown part of the way while the purple old snapshot cross-fades into the orange new one. Snapshots are images that keep their aspect ratio, so height: 100% makes both fill the group; text in a snapshot stretches too, so give text its own name.Cross-document transitions need no script: both same-origin pages opt in with @view-transition { navigation: auto; }, and elements sharing a view-transition-name across them morph. The pageswap and pagereveal events let script assign names for the link actually followed.Same-document transitions are Baseline since October 2025 (Chrome 111, Safari 18, Firefox 144); cross-document ones ship in Chromium 126 and Safari 18.2, and Firefox simply navigates. Feature-detect with if (!document.startViewTransition) update(); and keep transitions short.

    4.9.6 Animation Performance: Compositor-only Properties and will-change

    A smooth animation delivers a frame every 16.7 ms at 60 Hz. The pipeline stages a property re-runs (Section 4.1.4) decide whether the main thread must lay out and paint every frame, or the compositor thread can simply move an already painted texture. Stage re-run each frame Typical properties Verdict Layout, paint, composite width, height, top, left, margin Avoid animating Paint, composite color, background, box-shadow Small areas only Composite only transform, translate, rotate, scale, opacity Preferred Table 4.20: What each animated property costs per frame Figure 4.55: A long script task blocks a left animation but not a transform animation on the compositorFake layout-heavy effects with transforms: scale a pre-sized element instead of changing its width, and fade in a pseudo-element carrying the box-shadow instead of animating the shadow.will-change: transform, opacity lets the browser promote the element to its own compositor layer ahead of time, avoiding a repaint at the first frame. Side effects: those values create a stacking context immediately, and every layer holds a GPU texture, so * { will-change: transform; } can exhaust memory on phones. Browsers promote animating elements anyway, so use it only to fix a measured problem, just before the change:

    .drawer { transition: translate .3s ease-out; }

    .menu-area:hover .drawer, .drawer.is-open { will-change: translate; } /* removed otherwise */

    Listing 4.66: Promoting a drawer only while an interaction is likely In DevTools, the Performance panel shows dropped frames, the Rendering tab's Paint flashing and Layer borders reveal repaints and layers, and the Layers panel reports layer memory.

    4.10 Responsive Web Design

    A responsive page adapts one HTML document to every screen, from a 360-pixel phone to a 2,560-pixel monitor. Ethan Marcotte coined the term in A List Apart in May 2010, naming three ingredients: fluid grids, flexible images and media queries. Modern CSS adds self-wrapping layouts, container queries, fluid type and user-preference queries.Work mobile first: write base rules for the narrowest screen, then add @media (width >= ...) layers that only add columns. Wide-screen rules then extend the design instead of undoing it. Test by dragging the width in the DevTools device toolbar (Section 1.3.5).Figure 4.56: Mobile-first CSS: base rules, then min-width layers that add columns

    4.10.1 Relative Lengths

    Fixed pixel widths are the usual cause of sideways scrolling on phones. Size boxes with percentages and fr, and text, spacing and breakpoints with rem, so the layout follows both the screen and the reader's font setting (Section 4.2.2 defines the units). Keep html { font-size: 100% }: a px value there overrides the reader's choice. Unit Use it for Watch out for %, fr Widths and grid tracks Percentage heights need a sized parent rem, em Type, spacing, breakpoints em compounds when nested ch Line length, max-width: 65ch Varies with the font vw, svh, dvh Full-screen heroes, fluid terms vw-only text ignores zoom Table 4.21: Relative units in a responsive layout

    4.10.2 Min-, Max- Lengths and Float

    min-width, max-width and their height counterparts keep fluid sizes within limits, and min(), max() and clamp() (Section 4.2.11) do the same inside one value:

    img, video { max-width: 100%; height: auto; } /* shrinks with its column */

    .page { width: min(100% - 2rem, 70rem); margin-inline: auto; } /* phone gutter, desktop cap */

    .hero { min-height: 100svh; } /* grows if text wraps */

    .thumb { float: left; width: min(40%, 12rem); margin: 0 1rem 0.5rem 0; }

    Listing 4.67: Limits that keep fluid boxes usable from phone to desktop Floats no longer build layouts, but they still wrap text around an image, which flexbox cannot; a capped percentage width keeps the text beside it readable on a phone. One fixed-width element wider than the screen makes the whole page scroll sideways; find it by running [...document.querySelectorAll('*')].filter(e => e.offsetWidth > innerWidth) in the console.

    4.10.3 Flex, Grid and Columns

    Flexbox, grid and multi-column layout divide whatever width they get, so these patterns reflow without media queries, inside a sidebar as well as on a phone (Sections 4.8.6, 4.8.7 and 4.5.7):

    .cluster { display: flex; flex-wrap: wrap; gap: 1rem; }

    .cluster > * { flex: 1 1 15rem; } /* ideal 15rem, grow to fill, wrap when too narrow */

    .cards { display: grid; gap: 1rem;

    grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr)); }

    .prose { columns: 20rem; column-gap: 2rem; } /* as many 20rem columns as fit */

    Listing 4.68: Three layouts that reflow without media queries A wrapped flex line sizes its items on its own, so a lone last item stretches full width; grid keeps every row on the same tracks; columns suits flowing text, not cards. min(15rem, 100%) stops a container narrower than 15rem from overflowing.

    4.10.4 Breakpoints

    A breakpoint is the width at which a media query changes the layout. Place it where the content breaks, not at device widths. Write it in rem: in media queries rem means the initial font size, so a reader with a larger default font gets the sidebar only when it has room. Bootstrap uses 576-1,400px (Section 3.2.1), Tailwind CSS 40-96rem.

    <style>

    body { font: 15px system-ui; display: grid; gap: 6px; }

    body > * { background: #e3f2fd; padding: 8px; margin: 0; }

    @media (width >= 34rem) { body { grid-template-columns: 1fr 12rem } header { grid-column: 1/3 } }

    </style>

    <header>Header</header><main>Main: one column on a phone, the wide one from 34rem</main>

    <aside>Aside: below main, then beside it</aside>

    Listing 4.69: A mobile-first page with one breakpoint at 34rem, at 380px Figure 4.57: Browser output of Listing 4.69

    <style>

    body { font: 15px system-ui; display: grid; gap: 6px; }

    body > * { background: #e3f2fd; padding: 8px; margin: 0; }

    @media (width >= 34rem) { body { grid-template-columns: 1fr 12rem } header { grid-column: 1/3 } }

    </style>

    <header>Header</header><main>Main: one column on a phone, the wide one from 34rem</main>

    <aside>Aside: below main, then beside it</aside>

    Listing 4.70: The same page at 740px Figure 4.58: Browser output of Listing 4.70Use range syntax: (width >= 34rem) and (width < 34rem) split cleanly, whereas max-width: 543px with min-width: 544px leaves fractional widths, common under zoom, matching neither. Three or four breakpoints usually suffice; components use container queries (Section 4.10.12).

    4.10.5 Separate Stylesheets

    The media attribute of <link> (Section 2.9.8) and the media list of @import (Section 4.4.3) apply a whole file only while a query matches:

    <link rel="stylesheet" href="base.css">

    <link rel="stylesheet" href="wide.css" media="(width >= 64rem)">

    <link rel="stylesheet" href="print.css" media="print">

    Listing 4.71: Stylesheets applied per media condition This saves no bytes: browsers download every stylesheet, but fetch non-matching ones at low priority without letting them block the first render. The cost is maintenance, as one component's rules spread across files, so prefer @media blocks beside each component and keep separate files for print.

    4.10.6 The Viewport <meta> Tag

    Every responsive page needs <meta name="viewport" content="width=device-width, initial-scale=1"> in its <head>. Without it, mobile browsers lay the page out in a virtual viewport, typically 980px wide, and shrink it to fit: media queries see 980px and text is tiny. width=device-width sets the layout viewport to the screen width in CSS pixels; initial-scale=1 starts at 100% zoom. Never add user-scalable=no or maximum-scale=1, which block the pinch zoom low-vision readers need (WCAG 1.4.4). viewport-fit=cover extends the page under a notch; then pad content with env(safe-area-inset-left) and its siblings. Section 2.2.2 lists the other keys.

    4.10.7 Media Queries

    Section 4.4.4 covers @media syntax and Section 2.9.8 the features. Beyond width, these widely available features adapt to how a person uses the device:

    @media (40rem <= width < 64rem) { .gallery { grid-template-columns: 1fr 1fr; } }

    @media (hover: hover) and (pointer: fine) { /* mouse or trackpad as the main input */

    .card .actions { opacity: 0; }

    .card:hover .actions, .card:focus-within .actions { opacity: 1; }

    }

    @media (pointer: coarse) { .toolbar button { min-block-size: 44px; } } /* fingers */

    @media (prefers-reduced-motion: reduce) {

    *, ::before, ::after { animation-duration: 0.01ms !important;

    transition-duration: 0.01ms !important; scroll-behavior: auto !important; }

    }

    @media (forced-colors: active) { .badge { border: 1px solid CanvasText; } }

    Listing 4.72: Range, input and preference queries in practice
  • hover and pointer test the primary input; any-hover and any-pointer test every device. Never hide a control behind hover alone, and make targets at least 24 by 24 CSS pixels (WCAG 2.2, level AA).

  • prefers-reduced-motion follows the operating system setting. A near-zero duration, unlike animation: none, still fires animationend and transitionend.

  • forced-colors: active matches Windows contrast themes, which replace your colors and remove box-shadow; add borders in system colors such as CanvasText.

  • prefers-color-scheme is in Section 4.10.14; matchMedia() runs queries from JavaScript (Section 8.2.9).

  • 4.10.8 <source> and <picture>

    Images are usually the heaviest part of a page. Section 2.4.6 diagrams how the browser selects among candidates: srcset with sizes picks a resolution of the same picture, while <picture> with <source media> picks a different crop per layout (art direction):

    <picture>

    <source media="(width >= 48rem)" sizes="(width >= 75rem) 70rem, 100vw"

    srcset="harbor-wide-1200.jpg 1200w, harbor-wide-2400.jpg 2400w">

    <img src="harbor-tall-600.jpg" srcset="harbor-tall-600.jpg 600w, harbor-tall-1200.jpg 1200w"

    sizes="100vw" width="600" height="750" alt="Fishing boats in the harbor at dawn">

    </picture>

    Listing 4.73: A wide crop from 48rem, a tall crop below it, each in two resolutions The first <source> whose media and type match wins; the <img> is the fallback and the element you style. Keep width and height on fluid images so the browser reserves the aspect ratio before the file arrives, and match <source> breakpoints and sizes to the layout your CSS creates.

    4.10.9 SVG in Responsive Design

    One SVG stays sharp at any size and pixel density, replacing 1x/2x/3x PNG icon sets. To make it fluid, keep its viewBox, drop fixed width and height (or set width: 100%; height: auto), and let preserveAspectRatio fit the drawing. Inline SVG with fill="currentColor" follows the text color, including in dark mode.An SVG loaded as an image is a separate document, so media queries inside it test the image's width, not the page's. This logo drops its wordmark when small:

    <div style="display: flex; align-items: center; gap: 16px">

    <img width="110" alt="Acme"><img width="330" alt="Acme"></div>

    <script>

    const logo = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 100">

    <style>text { font: bold 58px Arial, sans-serif; fill: #1565c0; }

    @media (width &lt; 200px) { text { display: none; } }</style>

    <circle cx="50" cy="50" r="38" fill="#ef6c00"/><text x="104" y="71">Acme</text></svg>`;

    for (const img of document.images) img.src = 'data:image/svg+xml,' + encodeURIComponent(logo);

    </script>

    Listing 4.74: One SVG logo with an internal media query, drawn 110px and 330px wide Figure 4.59: Browser output of Listing 4.74In production the markup lives in logo.svg; the data URL only keeps the example self-contained. Optimize icons with SVGO (github.com/svg/svgo (https://github.com/svg/svgo), npx svgo icon.svg), but keep its removeViewBox plugin disabled so graphics can still scale.

    4.10.10 Web Fonts

    On a slow connection a web font delays text and shifts layout when it replaces the fallback. Sections 4.4.1 and 4.5.10 cover @font-face, font-display and variable fonts. Budget one or two WOFF2 families (system-ui costs nothing), use font-display: swap, and match the fallback's metrics so the swap moves nothing. size-adjust is widely available; ascent-override is not yet Baseline and is simply ignored where unsupported:

    @font-face { font-family: "Inter Fallback"; src: local("Arial");

    size-adjust: 107%; ascent-override: 90%; }

    body { font-family: "Inter", "Inter Fallback", system-ui, sans-serif; }

    Listing 4.75: A metric-matched local fallback for a web font The percentages differ per font pair; fontaine (github.com/unjs/fontaine (https://github.com/unjs/fontaine), npm install -D fontaine, MIT) computes them and generates the fallback faces as a Vite, webpack or PostCSS plugin.

    4.10.11 Responsive Frameworks

    Frameworks ship a tested breakpoint scale and classes that switch on at each breakpoint. All four below are MIT-licensed and mobile first: an unprefixed class applies at every width, a breakpoint class from that width up. Framework Responsive syntax Breakpoints Character Bootstrap 5.3.8 col-md-8, d-lg-none 576-1,400px Components, 12-column grid Tailwind CSS 4.3 md:grid-cols-3, @md:flex 40-96rem Utilities, container variants Bulma 1.0.4 is-half-tablet 769-1,408px Flexbox columns, no JavaScript Pico CSS 2.1.1 Class-less HTML Six widths Font size grows with screen Table 4.22: Responsive features of popular open-source CSS frameworks The names do not line up: Bootstrap's md is 768px, Tailwind's md is 48rem, and Bulma calls 769px "tablet". Reuse your framework's breakpoints in custom CSS so components change together (Chapters 3 and 5).

    4.10.12 Container Queries and Intrinsic Design

    A media query knows the viewport, not the slot a component lands in: a desktop sidebar is as narrow as a phone. In intrinsic design, viewport breakpoints shape the page skeleton, auto-fit grids decide how many items fit, and container queries (Section 4.4.9) with cqi units (Section 4.2.2) adapt each component to the width it receives. One card component, no media query:

    <style>

    body { font: 14px system-ui; display: grid; grid-template-columns: 1fr 170px; gap: 8px; }

    .cards { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)) }

    .wide { grid-column: 1 / -1; }

    .card { container-type: inline-size; }

    .card div { display: grid; gap: 6px; padding: 8px; background: #e3f2fd; }

    .card i { aspect-ratio: 3; background: #1565c0; }

    .card b { font-size: clamp(0.9rem, 0.6rem + 3cqi, 1.4rem); }

    @container (width > 22rem) { .card div { grid-template-columns: 12rem 1fr; align-items: center } }

    </style>

    <main class="cards"><div class="card wide"><div><i></i><b>Featured: side by side</b></div></div>

    <div class="card"><div><i></i><b>Grid card: stacked</b></div></div>

    <div class="card"><div><i></i><b>Grid card: stacked</b></div></div></main>

    <aside class="card"><div><i></i><b>Sidebar card: smaller title</b></div></aside>

    Listing 4.76: One card component in a featured slot, a grid and a sidebar Figure 4.60: Browser output of Listing 4.76A container cannot query itself, hence the inner div. container-type: inline-size applies size containment, so the container's width must come from outside: in a shrink-to-fit context such as width: fit-content it collapses to zero. Outside any container, cqi falls back to small viewport units.

    4.10.13 Fluid Typography with clamp()

    Fluid type grows steadily from a minimum size at a small viewport to a maximum at a large one. clamp(min, preferred, max) (Section 4.2.11) holds the limits; the preferred value is the line through the end points: slope = (max size − min size) / (max viewport − min viewport); intercept = min size − slope × min viewport; preferred = intercept in rem + slope × 100 in vw. For an h1 from 2rem at 20rem (320px) to 3rem at 80rem (1,280px), the slope is 1/60 and the intercept 1.6667rem; at 800px that gives 26.67px + 13.33px = 40px, exactly halfway. Role At 320px At 1,280px Value Body 1rem (16px) 1.125rem (18px) clamp(1rem, 0.9583rem + 0.2083vw, 1.125rem) h2 1.5rem (24px) 2rem (32px) clamp(1.5rem, 1.3333rem + 0.8333vw, 2rem) h1 2rem (32px) 3rem (48px) clamp(2rem, 1.6667rem + 1.6667vw, 3rem) Table 4.23: A fluid type scale between 320px and 1,280px viewports The free Utopia calculator (utopia.fyi (https://utopia.fyi/type/calculator/)) generates whole scales. Always keep the rem term: zoom shrinks the viewport in CSS pixels, so vw-only text never grows, a WCAG 1.4.4 failure. Test at 200% zoom, and inside components use cqi instead of vw (Section 4.10.12).

    4.10.14 Dark Mode with color-scheme and light-dark()

    Dark mode takes three layers. <meta name="color-scheme" content="light dark">, applied before CSS loads, or color-scheme: light dark lets the browser follow the operating system for the page background, form controls, scrollbars and system colors. light-dark(light, dark) (Section 4.2.6, Baseline since May 2024) gives your own colors both values, chosen by the element's used scheme. @media (prefers-color-scheme: dark) handles the rest, such as dimming photos.

    <style>

    :root { color-scheme: light dark; --bg: light-dark(#fff, #151b26);

    --text: light-dark(#1b1b1b, #e3e7ee); --accent: light-dark(#1565c0, #90caf9); }

    body { font: 15px system-ui; display: flex; gap: 12px; }

    section { flex: 1; padding: 0 14px 14px; background: var(--bg); color: var(--text);

    border: 1px solid #90a4ae; border-radius: 8px; }

    h3 { color: var(--accent); }

    </style>

    <section style="color-scheme: light"><h3>color-scheme: light</h3>

    <input value="Text field"> <input type="checkbox" checked> <button>Button</button></section>

    <section style="color-scheme: dark"><h3>color-scheme: dark</h3>

    <input value="Text field"> <input type="checkbox" checked> <button>Button</button></section>

    Listing 4.77: One light-dark() palette under color-scheme: light and dark Figure 4.61: Browser output of Listing 4.77Custom properties keep light-dark() unresolved until used, so each section resolves it against its own scheme. A theme switch therefore needs only :root[data-theme="dark"] { color-scheme: dark } and its light twin, plus a script that stores the choice (Section 3.11.1). Avoid pure black and saturated accents, and check contrast in both schemes.

    4.11 CSS Preprocessors and Postprocessors

    A preprocessor compiles a richer language (Sass, Less or Stylus) into plain CSS. A postprocessor rewrites finished CSS: adding vendor prefixes, lowering new syntax for old browsers, minifying. Most production stylesheets pass through both inside a bundler such as Vite (Section 1.8).Native CSS has absorbed custom properties (Section 4.2.10) and nesting (Section 4.3.13), but preprocessors still earn their place for loops, mixins, modules and token maps, which is why Bootstrap ships Sass sources. Every "Compiled CSS" listing below is real output from Dart Sass 1.104.1, Less 4.9.1, Stylus 0.64.0, PostCSS 8.5.28 or Lightning CSS 1.33.0.

    4.11.1 Why Preprocess?

    A large stylesheet repeats itself: one brand color in forty rules, one breakpoint in twenty media queries. A preprocessor removes that repetition at build time with variables, mixins, functions, loops and modules, then emits ordinary CSS. The cost is a build step, plus source maps (.css.map) so DevTools can show your source lines.Figure 4.62: Where preprocessors and postprocessors sit in a CSS buildInstall compilers per project (npm install --save-dev sass less) so CI uses the same version, then run npx sass --watch scss/main.scss:dist/main.css. In Vite, import "./main.scss" works once sass is installed. History: Sass appeared in 2006. Ruby Sass is retired, and LibSass (the node-sass package) was deprecated in October 2020, leaving Dart Sass, the sass npm package. Tutorials that use node-sass are obsolete. Reach for a preprocessor for compile-time logic or to customize a Sass-based library; for small sites, native custom properties and nesting plus a postprocessor are often enough.

    4.11.2 Sass: Basic Syntax

    Sass has two syntaxes. SCSS (.scss), used in this book, is a superset of CSS: any valid stylesheet is valid SCSS. The older indented syntax (.sass) drops braces and semicolons. A file whose name starts with an underscore (_tokens.scss) is a partial: other files load it, and it is never compiled on its own.

    @use "sass:math";

    // Silent comment: never reaches the CSS

    $gap: 1rem;

    $block: card;

    .#{$block} {

    width: math.div(100%, 3);

    padding: $gap * 1.5 $gap;

    &__title { margin-top: -$gap; }

    }

    Listing 4.78: card.scss: comments, variables, interpolation, nesting, & and math

    .card {

    width: 33.3333333333%;

    padding: 1.5rem 1rem;

    }

    .card__title {

    margin-top: -1rem;

    }

    Listing 4.79: Compiled CSS: npx sass card.scss
  • Comments: // comments vanish; /* */ comments stay in expanded output.

  • Variables hold numbers, strings, colors, booleans, null, lists (1px 2px) and maps ((sm: 576px)). They are block-scoped unless marked !global; $x: 5px !default assigns only if $x is unset.

  • Interpolation #{...} inserts values into selectors, property names and custom properties.

  • The parent selector & is pasted in as text, so unlike native nesting (Section 4.3.13) it can build BEM names such as &__title; .dark & yields .dark .card.

  • Operations track units: 24px / 16px is 1.5, while 1rem + 2px fails with incompatible units (write calc(1rem + 2px) instead).

  • Warning: A bare slash no longer divides. $w / 3 prints DEPRECATION WARNING [slash-div] and will stop working in Dart Sass 2.0.0. Use math.div($w, 3); npx sass-migrator division rewrites old code.

    4.11.3 Sass: Directives

    Sass at-rules organize files, reuse styles and add compile-time logic. One small theme package shows most.

    // theme/_tokens.scss

    $primary: #1565c0 !default;

    $-secret: 1; // private: names starting with - or _

    @mixin rounded($r: 4px) { border-radius: $r; }

    // theme/_index.scss

    @forward "tokens";

    // main.scss

    @use "sass:math";

    @use "theme" as t with ($primary: #6a1b9a);

    @function rem($px) {

    @if math.unit($px) != "px" { @error "rem() expects px, got #{$px}"; }

    @return math.div($px, 16px) * 1rem;

    }

    %btn { padding: rem(8px) rem(16px); @include t.rounded; }

    @each $name, $color in (primary: t.$primary, danger: #c62828) {

    .btn-#{$name} { @extend %btn; background: $color; }

    }

    Listing 4.80: theme/_tokens.scss, theme/_index.scss and main.scss

    .btn-danger, .btn-primary {

    padding: 0.5rem 1rem;

    border-radius: 4px;

    }

    .btn-primary {

    background: #6a1b9a;

    }

    .btn-danger {

    background: #c62828;

    }

    Listing 4.81: Compiled CSS: npx sass main.scss (blank lines removed) @use loads a file once and namespaces its members (t. here; as * removes the prefix). Reading t.$-secret fails with Private members can't be accessed from outside their modules, and with (...) overrides !default variables. @forward re-exports members, making _index.scss a folder's public API.@import shared one global scope and re-emitted CSS on every import. Dart Sass 1.80.0 (October 2024) deprecated it: each use prints DEPRECATION WARNING [import]: Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0, a release promised no sooner than two years after 1.80.0 (1.104.1 is current in September 2026). Convert with npx sass-migrator module --migrate-deps main.scss. Bootstrap 5.3 still uses @import, so Section 3.12.1 silences its warnings.A mixin copies declarations into each including rule, and @content receives a block passed to @include: @mixin respond($min) { @media (width >= $min) { @content; } } wraps any rules in a breakpoint. @extend adds the selector to the extended rule; a %placeholder prints only when extended. Nested @media bubbles up, @at-root escapes nesting, and @for and @while join @each. @debug and @warn print to the terminal; @error stops the build: rem(2em) fails with Error: "rem() expects px, got 2em".

    4.11.4 Sass: Built-in Modules and Functions

    Sass ships seven built-in modules, loaded with @use "sass:<name>": color (adjust, scale, mix, channel), string (index, slice, unquote), math (div, round, clamp, percentage), list (nth, append, join), map (get, merge, deep-merge, keys), selector (nest, append, unify) and meta (type-of, call, load-css). The old global names such as lighten() and map-get() print DEPRECATION WARNING [global-builtin] with a replacement, for example Use map.keys instead.

    @use "sass:color"; @use "sass:list"; @use "sass:map"; @use "sass:math";

    @use "sass:meta"; @use "sass:selector"; @use "sass:string";

    $brand: #1565c0;

    .demo {

    --tint: #{color.scale($brand, $lightness: 40%)};

    --label: #{string.to-upper-case("note")};

    --third: #{math.percentage(math.div(1, 3))};

    --size: #{list.nth(12px 16px 20px, 2)};

    --z: #{map.get((modal: 1050, toast: 1090), toast)};

    --type: #{meta.type-of(1rem)};

    --sel: #{selector.nest(".nav", "&:hover a")};

    }

    Listing 4.82: modules.scss: one call from each built-in module

    .demo {

    --tint: rgb(37.0074565037%, 63.2543496272%, 93.1101905551%);

    --label: NOTE;

    --third: 33.3333333333%;

    --size: 16px;

    --z: 1090;

    --type: number;

    --sel: .nav:hover a;

    }

    Listing 4.83: Compiled CSS: npx sass modules.scss The --tint line surprises people upgrading old code: current Dart Sass supports CSS Color 4 spaces and no longer rounds channels, so a scaled color prints as valid fractional percentages instead of hex. Lists and strings are 1-indexed, and Sass values are immutable: map.merge() returns a new map that you must assign back. For colors that should follow runtime theme changes, prefer native color-mix() (Section 4.2.6).

    4.11.5 Less: Basic Syntax

    Less (current release 4.9.1) is written in JavaScript and can even compile in the browser, which made it the language of Bootstrap 3. Comments, nesting, & and nested @media work as in Sass (Section 4.11.2), so this example shows only what differs: variables start with @, and a mixin is any rule you call.

    @import (reference) "tokens.less"; // provides @brand; prints no CSS

    .card {

    padding: @gap (@gap * 2); // lazy: @gap is defined below

    &:extend(.shadow);

    .rounded();

    .contrast(@brand);

    }

    @gap: 1rem;

    .rounded(@r: 4px) { border-radius: @r; }

    .shadow { box-shadow: 0 1px 3px #0003; }

    .contrast(@bg) when (lightness(@bg) >= 50%) { color: black; }

    .contrast(@bg) when (default()) { color: white; background: @bg; }

    @desktop: { .nav { display: flex; } };

    .respond(@rules) { @media (width >= 60rem) { @rules(); } }

    .respond(@desktop);

    Listing 4.84: card.less: import, lazy variables, extend, mixins, guards, detached rulesets

    .card {

    padding: 1rem 2rem;

    border-radius: 4px;

    color: white;

    background: #1565c0;

    }

    .shadow,

    .card {

    box-shadow: 0 1px 3px #0003;

    }

    @media (width >= 60rem) {

    .nav {

    display: flex;

    }

    }

    Listing 4.85: Compiled CSS: npx lessc card.less The last definition of a variable in a scope wins, and @{name} interpolates. Less 4 divides only inside parentheses: (10px / 2) is 5px. A mixin defined with () prints nothing until called. Guards (when, and, not, default()) replace @if, recursion or each() replace loops, and a detached ruleset called as @rules() does the job of @content. Since Less 3.5 a ruleset also works as a map: @z[toast]. Warning: Backtick JavaScript such as ` "hi".toUpperCase() fails with Inline JavaScript is not enabled unless you pass --js, and Less 4.9.1 warns that it "is deprecated and will be removed in Less 5.x" (5.0 is in alpha). Load a JavaScript plugin with @plugin "my-functions";` instead.

    4.11.6 Less: Functions

    Less has no module system: its built-in functions are global names, grouped in the documentation as logical, string, list, math, type, miscellaneous and four color groups. The property names below label the groups: c color, s string, m math, l list, x logical.

    @brand: #1565c0;

    .fn {

    c1: lighten(@brand, 20%);

    c2: contrast(@brand); // black or white, whichever is more legible

    s1: %("%d-%s", 3, "col");

    m1: percentage(0.25);

    l1: extract(12px 16px 20px, 2);

    x1: if(iscolor(@brand), yes, no);

    }

    Listing 4.86: fn.less: functions from each group

    .fn {

    c1: #4f98ec;

    c2: #ffffff;

    s1: "3-col";

    m1: 25%;

    l1: 16px;

    x1: yes;

    }

    Listing 4.87: Compiled CSS: npx lessc fn.less Less color functions such as lighten() work in HSL, so they give different results from Sass's color.scale(). Type functions (iscolor(), isunit(4rem, rem)) feed guards, and miscellaneous functions include unit(), data-uri() to inline an image and svg-gradient(). Functions Less does not recognize pass through untouched, which helps with clamp() but hides typos: lighen(@brand, 20%) compiles to lighen(#1565c0, 20%), an invalid value the browser silently discards.

    4.11.7 Sass vs Less Differences

    Older comparisons are out of date: Sass no longer needs Ruby and has maps, trigonometry and modules, while Less has gained maps and each(). The differences that still matter: Feature Sass (SCSS) Less Compiler Dart Sass (sass on npm) less.js (less on npm) Variables $name, defined before use @name, lazy; last definition wins Code sharing @use/@forward namespaces Global @import with options Mixins @mixin + @include Any rule, called as .name() Passing a block @content Detached ruleset @rules() Logic @if, @each, @for, @while Guards, recursion, each() Custom functions @function JavaScript @plugin 2px + 3em Error: incompatible units 5px (first unit wins) Only in one @at-root, !default, %placeholder HSV and blend functions, browser build Table 4.24: Sass (SCSS) and Less compared For new work Sass is the safer default: a larger ecosystem (Bootstrap left Less for Sass in version 4), strict unit math and namespaced modules. Choose Less to maintain a Less code base or theme a library shipping Less. Tip: Interviewers often ask "@extend or a mixin?" @extend prints shared declarations once but regroups selectors, which can reorder the cascade; a mixin copies declarations predictably. Prefer mixins.

    4.11.8 Stylus

    Stylus, published on npm in 2011 by TJ Holowaychuk (the author of Express), makes braces, colons and semicolons optional; variables need no sigil and a mixin call looks like a property. It also accepts ordinary CSS syntax.

    brand = #1565c0

    rounded(r = 4px)

    border-radius r

    .card

    padding 1rem 2rem

    rounded(8px)

    &:hover

    border-color lighten(brand, 20%)

    Listing 4.88: card.styl: a variable, a mixin and nesting

    .card {

    padding: 1rem 2rem;

    border-radius: 8px;

    }

    .card:hover {

    border-color: #2982e8;

    }

    Listing 4.89: Compiled CSS: npx stylus -p card.styl Indentation carries meaning, so a stray space changes the output. Stylus is maintained but slow-moving (0.64.0, October 2024 is the latest release). Vite still compiles .styl files, so you will meet it in older Vue projects; start new ones with Sass or native CSS. History: On 23 July 2025 npm briefly swapped stylus for an empty security placeholder over a flagged maintainer, breaking builds that installed it.

    4.11.9 PostCSS and Autoprefixer

    PostCSS (https://github.com/postcss/postcss), created by Andrey Sitnik, is not a language but a CSS parser with a plugin API. It turns a stylesheet into a tree of Root, Rule, AtRule, Declaration and Comment nodes, lets plugins edit the tree, then prints it with a source map. Plugins include Autoprefixer (https://github.com/postcss/autoprefixer), cssnano and @tailwindcss/postcss (Chapter 5).Autoprefixer compares your target browsers with Can I Use data (the caniuse-lite package) and adds only the vendor prefixes they need, removing outdated ones. postcss-preset-env (https://github.com/csstools/postcss-plugins) bundles Autoprefixer with plugins that rewrite newer syntax for older targets (stage: 2, the default, means reasonably stable features). Both read .browserslistrc. Install with npm install --save-dev postcss postcss-cli postcss-preset-env.

    module.exports = {

    plugins: [

    require("postcss-preset-env")({ stage: 2 }),

    { postcssPlugin: "px-to-rem", // a custom plugin: visits every declaration

    Declaration(d) { d.value = d.value.replace(/(\d*\.?\d+)px/g, (_, n) => `${n / 16}rem`); } },

    ],

    };

    Listing 4.90: postcss.config.js (.browserslistrc contains: safari 13, chrome 90, firefox 100)

    @custom-media --wide (width >= 40rem);

    .card {

    color: oklch(50% 0.15 255);

    padding: 24px;

    user-select: none;

    & .title { text-wrap: balance; }

    }

    @media (--wide) { .card { display: grid; } }

    Listing 4.91: in.css: modern CSS written once

    .card {

    color: rgb(23, 98, 182);

    padding: 1.5rem;

    -webkit-user-select: none;

    user-select: none;

    }

    .card .title { text-wrap: balance; }

    @media (min-width: 40rem) { .card { display: grid; } }

    Listing 4.92: Compiled CSS: npx postcss in.css -o out.css Safari 13 lacks oklch(), nesting, custom and range media queries and unprefixed user-select, so each was rewritten; text-wrap passed through because no plugin emulates it, and the custom plugin converted 24px. Warning: Never hand-write prefixes or run Autoprefixer twice (standalone and inside postcss-preset-env). Refresh the browser data with npx update-browserslist-db@latest; stale data produces stale prefixes.

    4.11.10 Lightning CSS and the Modern CSS Pipeline

    Lightning CSS (https://github.com/parcel-bundler/lightningcss), from the team behind the Parcel bundler, does in one Rust program what a PostCSS setup does with several plugins: parsing, vendor prefixing, syntax lowering for browserslist targets, @import bundling, CSS modules and minification. Its published benchmark minifies Bootstrap 4 in 4.16 ms against 544.81 ms for cssnano. Given the same in.css and .browserslistrc as Section 4.11.9:

    .card {

    color: #1762b6;

    color: color(display-p3 .185203 .379069 .690161);

    color: lab(40.8907% 2.76372 -51.5247);

    -webkit-user-select: none;

    user-select: none;

    padding: 24px;

    }

    .card .title {

    text-wrap: balance;

    }

    @media (min-width: 40rem) {

    .card {

    display: grid;

    }

    }

    Listing 4.93: Compiled CSS: npx lightningcss --browserslist --custom-media in.css (blank lines removed) Under the hood: Lightning CSS parses values into typed data rather than token strings, so it converts oklch() exactly and emits a fallback cascade: each browser keeps the last color it can parse. PostCSS chose a single rgb(). Add --minify for production. The trade-off is extensibility: PostCSS runs any JavaScript plugin, while Lightning CSS has a fixed feature set. Vite 8 minifies CSS with Lightning CSS by default (build.cssMinify) but transforms with PostCSS unless you opt into the experimental css.transformer: "lightningcss", and Tailwind CSS 4 uses Lightning CSS internally. A sound 2026 pipeline: native CSS or Sass modules, compiled by Vite, lowered for your browserslist targets by postcss-preset-env or Lightning CSS (not both), then minified by Lightning CSS.

    4.12 CSS Frameworks, Libraries and Architecture

    Every CSS feature in this chapter can be written by hand, but few teams start from an empty stylesheet. A framework hands you a design system: a grid, components and utility classes. A library solves one narrower problem such as a reset, a set of animations or an icon font. An architecture is a naming and layering discipline that keeps your own CSS maintainable when it grows past a few thousand lines, and a scoping tool (CSS Modules, CSS-in-JS) enforces that discipline with a compiler instead of code review.These choices interact. Utility-first frameworks make most naming conventions unnecessary; component frameworks such as React push you toward CSS Modules or a compile-time styling library; and native cascade layers and @scope (Section 4.3.14) now cover part of what methodologies were invented to solve. Bootstrap is taught in Chapter 3 and Tailwind CSS in Chapter 5, so here they appear only for comparison.

    4.12.1 Frameworks Compared

    CSS frameworks fall into three families. Component frameworks (Bootstrap, Bulma, Foundation) ship prebuilt classes such as .btn and .card. Utility engines (Tailwind CSS, UnoCSS) scan your source files and generate one small class per declaration you actually use. Minimal layers (Pico, Open Props) style bare semantic HTML or supply design tokens, and leave components to you. Framework Approach Licence Size (min + gzip) GitHub stars Bootstrap 5.3.8 Components, utilities, JS plugins MIT 30.8 KB CSS + 23.7 KB JS 174.8k Tailwind CSS 4.3 Utility-first, generated on build MIT Only classes you use 97.6k Bulma 1.0.4 Components, Sass, no JavaScript MIT 65.2 KB 50.1k Foundation 6.9.0 Components, Sass, JS plugins MIT 17.6 KB CSS — Pico CSS 2.1.1 Styles semantic HTML MIT 11.6 KB 16.9k Open Props 1.7 Tokens as custom properties MIT 7.7 KB 5.5k UnoCSS 66.10 Atomic engine with presets MIT Only classes you use 19.0k Table 4.25: CSS frameworks in September 2026 (sizes measured from the jsDelivr builds) A component framework's full file is the ceiling you ship if you never prune it; PurgeCSS (Section 4.13) cuts Bootstrap's CSS by more than 90% on a small page. Utility engines have no fixed size, and their output grows slowly because every page reuses the same atoms.
  • Bootstrap (Chapter 3) suits dashboards, internal tools and teams that want accessible JavaScript widgets out of the box.

  • Tailwind CSS (Chapter 5) suits custom designs in component-based apps. UnoCSS, by Anthony Fu, has no utilities in its core: presets such as preset-wind4 add Tailwind-compatible classes and preset-icons turns any Iconify icon into a class.

  • Bulma (no JavaScript) and Foundation (with JavaScript) are Sass-based component kits; import only the modules you need. Foundation's last release, 6.9.0, dates from September 2024.

  • Pico fits documentation sites and prototypes: good typography and forms without class names.

  • Open Props provides 500+ tokens (--size-3, --radius-2, --shadow-2) and no selectors, so it combines with any approach.

  • <link rel="stylesheet"

    href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.classless.min.css">

    <main>

    <h3>Join the newsletter</h3>

    <form>

    <label>Email <input type="email" placeholder="you@example.com"></label>

    <label><input type="checkbox" role="switch" checked> Weekly digest</label>

    <button type="submit">Subscribe</button>

    </form>

    </main>

    Listing 4.94: Pico's class-less build styling plain semantic HTML Figure 4.63: Browser output of Listing 4.94No element carries a class, yet the form gets spacing, a switch, focus rings and automatic dark mode. Judge a framework by the CSS you will override: if most of your rules undo its defaults, tokens or utilities will cost less.

    4.12.2 Miscellaneous Libraries

    Small single-purpose libraries drop into any project, framework or not. Library What it gives you Licence Size (gzip) normalize.css 8.0.1 Consistent defaults, keeps useful ones MIT 1.7 KB Animate.css 4.1.1 90+ ready-made @keyframes Hippocratic 2.1 5.1 KB Hover.css 2.3.2 Hover effects for links and buttons MIT or paid commercial 6.7 KB Font Awesome Free 7.3 Icon font and SVG icons CC BY 4.0, OFL, MIT 22.1 KB CSS + fonts Bootstrap Icons, Heroicons, Lucide SVG icons (Bootstrap Icons also as a font) MIT, MIT, ISC Per icon Table 4.26: Popular single-purpose CSS libraries normalize.css (53k stars) predates today's evergreen browsers, and Bootstrap's Reboot (Section 3.1.3) already includes it, so add it only to hand-written stylesheets. Animate.css needs two classes, class="animate__animated animate__bounce", or you can reference its keyframes directly with animation: fadeInUp 0.4s. Its licence changed from MIT to the Hippocratic License, which adds human-rights conditions, so check it with your legal team before shipping commercial software. Hover.css is free under MIT only for personal and open-source work. Bootstrap Icons is covered in Section 3.1.5.

    <link rel="stylesheet"

    href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@7.3.1/css/all.min.css">

    <link rel="stylesheet"

    href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">

    <p style="font: 28px system-ui; color: #1565c0; display: flex; gap: 24px">

    <i class="fa-solid fa-house"></i> <i class="fa-regular fa-bell"></i>

    <i class="fa-brands fa-github"></i> <i class="bi bi-cart3"></i>

    <i class="bi bi-gear"></i> <i class="bi bi-heart-fill" style="color: #c62828"></i>

    </p>

    Listing 4.95: Font Awesome and Bootstrap Icons loaded as icon fonts Figure 4.64: Browser output of Listing 4.95 Under the hood: An icon font maps each icon to a Private Use Area code point, and a ::before rule inserts it (Font Awesome 7 keeps the code point in a custom property: .fa-house { --fa: "\f015" }). The browser must download the whole font before any icon appears, and screen readers may announce the glyph, so add aria-hidden="true" to decorative icons. Inline SVG (Heroicons, Lucide, or Font Awesome's SVG mode) ships only the icons you import, inherits currentColor and scales without hinting artifacts, which is why most modern projects prefer it.

    4.12.3 CSS Architecture

    CSS has one global namespace and a cascade that rewards whoever wrote the last, most specific rule. Architectures fight the two failure modes this causes: class names that collide, and specificity wars that end in !important. Method Origin Core rule BEM Yandex, 2000s block__element--modifier, one class per selector OOCSS Nicole Sullivan Separate structure from skin, container from content SMACSS Jonathan Snook Base, Layout, Module, State (is-open), Theme ITCSS Harry Roberts Layers from generic to specific: Settings to Utilities CUBE CSS Andy Bell Composition, Utility, Block, Exception; embrace the cascade Utility-first Atomic CSS, Tailwind One declaration per class, composed in markup Table 4.27: Six CSS architectures ITCSS orders files so specificity only rises: Settings and Tools (variables, mixins, no output), Generic (resets), Elements (bare h1, a), Objects (layout patterns), Components, Utilities. Native @layer (Section 4.3.14) now enforces that order in the browser, whatever order the files load in. CUBE CSS leans on global styles and layout "compositions" and writes block CSS only for what remains.Here is one pricing card written in BEM. Every selector is a single class, so specificity stays at 0-1-0 and a modifier wins simply by coming later in the file.

    <style>

    .card { width: 240px; padding: 16px; border: 1px solid #cfd8dc; border-radius: 8px;

    font-family: system-ui; }

    .card--featured { border-color: #1565c0; box-shadow: 0 4px 12px rgb(21 101 192 / 0.25); }

    .card__title { margin: 0 0 4px; font-size: 1.125rem; }

    .card__price { margin: 0; color: #546e7a; }

    .card__button { margin-top: 12px; padding: 6px 14px; border: 0; border-radius: 6px;

    background: #1565c0; color: #fff; font: inherit; }

    </style>

    <div style="display: flex; gap: 16px">

    <article class="card">

    <h3 class="card__title">Basic</h3><p class="card__price">Free</p>

    <button class="card__button">Choose</button>

    </article>

    <article class="card card--featured">

    <h3 class="card__title">Pro</h3><p class="card__price">$12 per month</p>

    <button class="card__button">Choose</button>

    </article>

    </div>

    Listing 4.96: A pricing card in BEM Figure 4.65: Browser output of Listing 4.96The utility-first version moves every decision into the markup and needs no stylesheet of its own; Tailwind CSS generates the classes (Chapter 5).

    <article class="w-60 rounded-lg border border-blue-700 p-4 shadow-lg shadow-blue-700/25">

    <h3 class="mb-1 text-lg font-semibold">Pro</h3>

    <p class="text-slate-600">$12 per month</p>

    <button class="mt-3 rounded-md bg-blue-700 px-3.5 py-1.5 text-white">Choose</button>

    </article>

    Listing 4.97: The featured card with Tailwind CSS utilities BEM keeps HTML readable, but naming takes discipline and dead CSS accumulates. Utilities never collide or go dead, but markup gets long, so extract repeated patterns into components. Most teams mix them: tokens and a few BEM or CUBE blocks for complex widgets, utilities for spacing and layout. Tip: A common interview question is "Why avoid ID selectors and nesting in BEM?" Both raise specificity (Section 4.3.12), so a later modifier class can no longer override the base rule without escalating.

    4.12.4 CSS Modules and CSS-in-JS

    Naming conventions rely on discipline; scoping tools make collisions impossible. A CSS Module is an ordinary stylesheet whose class names the build tool renames to unique hashes and exports to JavaScript. Vite, Next.js and webpack's css-loader treat any file ending in .module.css this way, so the card from Section 4.12.3 needs no BEM prefixes:

    .card { width: 240px; padding: 16px; border: 1px solid #cfd8dc; border-radius: 8px; }

    .featured { composes: card; border-color: #1565c0; }

    .title { margin: 0 0 4px; }

    Listing 4.98: Card.module.css: short local class names

    import styles from './Card.module.css';

    export const Card = ({ title, featured }) => (

    <article className={featured ? styles.featured : styles.card}>

    <h3 className={styles.title}>{title}</h3>

    </article>

    );

    Listing 4.99: Card.jsx: importing the generated class names Running the stylesheet through Lightning CSS (lightningcss --css-modules), the Rust compiler Vite uses for minification, shows what reaches the browser. composes emits no CSS; the exported name for featured simply contains both classes.

    .V5PT5q_card { border: 1px solid #cfd8dc; border-radius: 8px; width: 240px; padding: 16px; }

    .V5PT5q_featured { border-color: #1565c0; }

    .V5PT5q_title { margin: 0 0 4px; }

    exports: featured -> "V5PT5q_featured V5PT5q_card", title -> "V5PT5q_title"

    Output of Listing 4.99 CSS-in-JS goes further and writes styles inside JavaScript, so they can read props and theme values. The libraries split on when the CSS is produced.Figure 4.66: Runtime CSS-in-JS generates styles in the browser; zero-runtime libraries extract a static file at build time Library CSS generated Version (Sept 2026) GitHub stars styled-components Runtime, tagged templates 6.5.3 41.1k Emotion Runtime, css prop or styled 11.14.0 18.0k vanilla-extract Build time, .css.ts files 1.21.2 10.4k StyleX (Meta) Build time, atomic classes 0.19.0 10.3k Panda CSS (Chakra team) Build time, atomic classes and recipes 1.12.1 6.2k Table 4.28: CSS-in-JS libraries, all MIT licensed Runtime libraries hash styles during rendering, ship their engine in your bundle and fit poorly with React Server Components; in March 2025 the styled-components maintainer put it into maintenance mode and advised against runtime CSS-in-JS for new projects. Zero-runtime tools keep the authoring model but emit static CSS: StyleX's atomic classes shrank facebook.com's CSS from tens of megabytes to a couple of hundred kilobytes. Tip: For a new React app, start with CSS Modules or Tailwind CSS; choose vanilla-extract or StyleX when you want type-checked styles.

    4.13 CSS Debugging and Tooling

    CSS rarely throws errors: an invalid declaration is silently dropped and a losing one is silently overridden. Tooling makes those silent failures visible at three points: in the browser while you debug, in your editor and CI before you commit, and in the build before you ship. Stage Tool What it catches or does Debug DevTools Styles and Computed Overridden, invalid and inactive declarations (Section 1.3.1) Debug Coverage panel, CSS Overview Unused bytes per file; colors, fonts, unused declarations Lint Stylelint 17 Typos, bad patterns, naming rules, unsupported features Build PurgeCSS 8 Removes selectors your markup never uses Build cssnano 9, Lightning CSS Minify; Lightning CSS also adds prefixes Check caniuse.com, Baseline Which browsers support a feature Table 4.29: CSS tooling by stage (all tools MIT licensed except Lightning CSS, MPL-2.0) In Chrome, open Coverage from the Command Menu (Ctrl+Shift+P, "Show Coverage") and reload: each stylesheet's bar shows unused bytes in gray, and clicking a row marks unused lines in Sources. CSS Overview lists every color, font and media query and flags low-contrast text.Section 1.9 installs Stylelint. Add a selector-class-pattern that enforces your architecture (Section 4.12.3) and stylelint-plugin-use-baseline, which reads the web-features data set to flag features that are not yet Baseline.

    export default {

    extends: ['stylelint-config-standard'],

    plugins: ['stylelint-plugin-use-baseline'],

    rules: {

    'selector-class-pattern': [

    '^[a-z][a-z0-9]*(-[a-z0-9]+)*(__[a-z0-9]+(-[a-z0-9]+)*)?(--[a-z0-9]+(-[a-z0-9]+)*)?$',

    { message: 'Use BEM: block__element--modifier' },

    ],

    'plugin/use-baseline': [true, { available: 'widely', severity: 'warning' }],

    },

    };

    Listing 4.100: stylelint.config.mjs enforcing BEM names and Baseline features

    > npx stylelint styles.css

    styles.css

    2:12 ✖ Disallowed unit

    length-zero-no-unit

    3:3 âš  Property "text-wrap" is not a widely available baseline feature.

    plugin/use-baseline

    7:3 âš  Property "field-sizing" is not a widely available baseline feature.

    plugin/use-baseline

    8:3 ✖ Unknown property "colr"

    property-no-unknown

    ✖ 4 problems (2 errors, 2 warnings)

    Output of Listing 4.100 Baseline labels a feature newly available once it works in the current Chrome, Edge, Firefox and Safari (desktop and mobile), and widely available 30 months later. MDN and caniuse.com show the badge; caniuse adds per-version tables and known bugs. Wrap anything newer in @supports (Section 4.4.5).At build time, purge first, then minify. PurgeCSS deletes rules whose selectors match no word in your HTML, templates and scripts; cssnano, a set of PostCSS plugins, strips whitespace, merges rules and shortens values. On a three-element page using Bootstrap 5.3.8, PurgeCSS cut bootstrap.css from 33,075 to 2,928 bytes gzipped, and cssnano brought it to 2,711. Warning: PurgeCSS cannot see class names built at runtime, such as 'alert-' + type or classes a plugin adds when a modal opens. Keep them with the safelist option (strings or regular expressions), and test the purged build, not only the development build (see Section 3.12.4).