MENU
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; }
}
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>
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.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.<!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>
Figure 4.2: Browser output of Listing 4.34.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.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.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.4.2.2 Relative Length Units
<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>
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.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.<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>
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.<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>
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.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>
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>
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>
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>
Figure 4.10: Browser output of Listing 4.104.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>
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 */
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.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.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.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 */
4.3.5 Navigation Selectors
These pseudo-classes react to links, pointer and keyboard interaction, and the URL fragment. All weigh 0-1-0.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 */
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.<!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>
Figure 4.12: Browser output of Listing 4.154.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.<!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>
Figure 4.13: Browser output of Listing 4.164.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.<!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>
Figure 4.14: Browser output of Listing 4.174.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>
Figure 4.15: Browser output of Listing 4.184.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 */
}
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.<!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>
Figure 4.16: Browser output of Listing 4.204.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).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; }
}
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>
Figure 4.17: Browser output of Listing 4.224.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).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>
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; }
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;
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>
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>
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: "â¬"; }
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 */
}
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 */
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>
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.<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>
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>
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>
Figure 4.24: Browser output of Listing 4.344.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) {
	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>
Figure 4.25: Browser output of Listing 4.354.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>
Figure 4.26: Browser output of Listing 4.364.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>
Figure 4.27: Browser output of Listing 4.374.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>
Figure 4.28: Browser output of Listing 4.384.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>
Figure 4.29: Browser output of Listing 4.394.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>
Figure 4.30: Browser output of Listing 4.404.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>
Figure 4.31: Browser output of Listing 4.414.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>
Figure 4.32: Browser output of Listing 4.424.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>
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.
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.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).
.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 */
}
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)">★</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>
Figure 4.34: Browser output of Listing 4.454.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: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>
Figure 4.35: Browser output of Listing 4.464.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>
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.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.
<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>
Figure 4.37: Browser output of Listing 4.484.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>
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; }
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.<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>
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).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>
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.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>
Figure 4.43: Browser output of Listing 4.534.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>
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>
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>
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).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>
Figure 4.48: Browser output of Listing 4.574.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.
.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; }
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>
Figure 4.49: Browser output of Listing 4.594.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: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>
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 */
}
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>
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>
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.
<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>
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>
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.
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 */
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 columns4.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.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; }
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 */
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>
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>
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">
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; } }
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>
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 < 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>
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; }
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.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>
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.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>
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.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; }
}
.card {
width: 33.3333333333%;
padding: 1.5rem 1rem;
}
.card__title {
margin-top: -1rem;
}
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).
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; }
}
.btn-danger, .btn-primary {
padding: 0.5rem 1rem;
border-radius: 4px;
}
.btn-primary {
background: #6a1b9a;
}
.btn-danger {
background: #c62828;
}
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")};
}
.demo {
--tint: rgb(37.0074565037%, 63.2543496272%, 93.1101905551%);
--label: NOTE;
--third: 33.3333333333%;
--size: 16px;
--z: 1090;
--type: number;
--sel: .nav:hover a;
}
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);
.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;
}
}
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);
}
.fn {
c1: #4f98ec;
c2: #ffffff;
s1: "3-col";
m1: 25%;
l1: 16px;
x1: yes;
}
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: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%)
.card {
padding: 1rem 2rem;
border-radius: 8px;
}
.card:hover {
border-color: #2982e8;
}
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`); } },
],
};
@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; } }
.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; } }
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;
}
}
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.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>
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.<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>
Figure 4.64: Browser output of Listing 4.954.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.<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>
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>
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; }
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>
);
.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"
Figure 4.66: Runtime CSS-in-JS generates styles in the browser;
zero-runtime libraries extract a static file at build time4.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.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' }],
},
};
> 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)