2. Document Structure and Presentation
Every web page is built from two separate layers: HTML describes what the content is (its structure and meaning), and CSS describes how that content looks (its presentation and layout). This chapter covers the HTML document model, including semantics, forms, media and accessibility. It then covers the core mechanics of CSS, such as the cascade and the box model, and the layout systems used to build interfaces that adapt to any screen. These foundations make pages more accessible, easier to maintain and easier to script, so everything that follows on the DOM, JavaScript and front-end frameworks depends on them.
<a id="21-html-document-model"></a>
2.1 HTML Document Model
This subchapter covers how an HTML document is put together: its skeleton and metadata, the elements that carry meaning, collect input and embed media, and the practices that make content usable by everyone.
Document Structure and Metadata
When a browser receives an HTML file, it has to answer several questions before it can show anything. Which set of rendering rules should it follow? How should the bytes be decoded into characters? What language is the text in? How wide is the screen it should lay out for? What title should appear in the tab? An HTML document answers these questions in a fixed skeleton at its very top. If the skeleton is wrong, you get garbled characters, pages that appear zoomed out on phones, or layouts that follow rules from 1999.
Elements, tags and attributes. HTML is a markup language. You wrap content in tags to create elements, and you configure elements with attributes written as name="value" pairs inside the start tag.
<a href="/about" class="nav-link">About us</a>
\________________________________/\______/\__/
start tag content end tag
\___________/ \______________/
attribute attribute
Some elements, called void elements, cannot have content and so have no end tag: <meta>, <link>, <img>, <input>, <br>, <hr>, <source>. A trailing slash (<br />) is allowed but ignored in HTML.
Elements nest inside each other, and this nesting forms a tree. The browser parses the markup into that tree, called the DOM (Document Object Model, covered in depth in Chapter 6), and everything else (CSS styling, JavaScript, accessibility) works on that tree rather than on the raw text.
The document skeleton. Every HTML document has the same top-level shape:
Document
+-- <!DOCTYPE html> -> rendering mode switch, not an element
+-- html (lang="en") -> root element; declares the language
+-- head -> metadata: not rendered on the page
| +-- meta charset
| +-- meta viewport
| +-- title
| +-- link (stylesheet, icon, ...)
| +-- script
+-- body -> everything the user sees
+-- header
+-- main
+-- footer
The DOCTYPE. <!DOCTYPE html> is not an HTML element. It is a switch that tells the browser to use standards mode. Without it, browsers fall back to quirks mode, which deliberately copies bugs from browsers of the late 1990s so that very old pages keep working.
| Mode | Triggered by | Effect |
|---|---|---|
| No-quirks (standards) | <!DOCTYPE html> | Follows the current CSS and HTML specifications |
| Limited-quirks | Some legacy transitional DOCTYPEs | Standards mode, except for a few table-cell image layout rules |
| Quirks | Missing or very old DOCTYPE | Emulates old bugs, for example a different box model in some contexts and fonts not inheriting into tables |
You can check the mode of any page from the console with document.compatMode. "CSS1Compat" means standards mode and "BackCompat" means quirks mode.
Essential metadata. The <head> holds information about the document:
| Element | Purpose | Notes |
|---|---|---|
<meta charset="utf-8"> | Character encoding | Must appear within the first 1024 bytes. A byte order mark or the HTTP Content-Type header overrides it. |
<meta name="viewport" content="width=device-width, initial-scale=1"> | Tells mobile browsers to lay out at the device's width | Without it, phones render a virtual page about 980px wide and shrink it |
<title> | Tab text, bookmark name, search result headline | Required. Make each page's title unique. |
<meta name="description"> | Summary often shown in search results | Does not affect ranking directly |
<link rel="stylesheet" href="..."> | Loads CSS | Blocks rendering until it has loaded |
<link rel="icon" href="..."> | Favicon | SVG and PNG are both widely supported |
<link rel="canonical" href="..."> | Preferred URL when the same content has several URLs | Avoids duplicate-content problems in search engines |
<link rel="preload"> / rel="preconnect" | Resource hints | Start critical downloads or connections early |
<meta property="og:..."> | Open Graph data for link previews on social media and chat apps | Title, description, image, URL |
<meta name="robots" content="noindex"> | Instructions for search crawlers | Use it on staging and private pages |
Loading scripts. A plain <script> stops the HTML parser: the browser must download and run the script before it can continue, because the script might change the document. The defer and async attributes avoid that pause.
<script> parse ======| |====== parse continues
|-fetch-|run|
<script defer> parse ====================================|run (in order)
|---- fetch ----| before DOMContentLoaded
<script async> parse =============|run|==================
|- fetch -| runs as soon as it arrives, any order
| Attribute | Blocks parsing? | Execution order | Typical use |
|---|---|---|---|
| none | Yes | Document order | Rare today. Small inline scripts that must run immediately. |
defer | No | Document order, after parsing | Application code that needs the DOM |
async | No | Whenever each script finishes downloading | Independent scripts such as analytics |
type="module" | No (deferred by default) | Document order, after parsing | Modern ES module code (Chapter 3) |
A forgiving parser. HTML parsers never reject a document. If you leave out <html>, <head> or <body>, the parser creates them. If you leave a <p> unclosed, it closes it for you. This forgiveness means invalid markup often looks fine but produces a different tree from the one you intended. Use the W3C Nu Html Checker (validator.w3.org/nu) to find these mistakes.
Character references. Characters that have a special meaning in markup must be escaped when you mean them as text: < for <, > for >, & for &, " for ". Others are conveniences, such as (non-breaking space) and ©.
Example: a complete, production-ready skeleton
<!DOCTYPE html> <!-- standards mode -->
<html lang="en"> <!-- language for screen readers, hyphenation, translation -->
<head>
<meta charset="utf-8"> <!-- first, so everything after it decodes correctly -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pricing | Acme Cloud</title> <!-- specific first, brand last -->
<meta name="description" content="Simple monthly plans for teams of any size.">
<link rel="canonical" href="https://acme.example/pricing">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.example.net"> <!-- open the connection early -->
<link rel="stylesheet" href="/css/main.css">
<!-- Link preview metadata -->
<meta property="og:title" content="Acme Cloud Pricing">
<meta property="og:image" content="https://acme.example/og/pricing.png">
<script src="/js/app.js" defer></script> <!-- doesn't block parsing, runs after the DOM is built -->
</head>
<body>
<!-- Visible content goes here -->
<p>Prices start at €9 per user & month.</p> <!-- character references -->
</body>
</html>
Global attributes. Some attributes are valid on every element:
| Attribute | Purpose |
|---|---|
id | Unique identifier in the document (for links, labels, scripts) |
class | Space-separated list of classes for CSS and JavaScript |
lang / dir | Language and text direction (ltr, rtl, auto) for that subtree |
hidden | Hides the element (equivalent to display: none unless CSS overrides it) |
tabindex | Takes part in keyboard focus order (see Accessibility and ARIA Basics) |
title | Advisory tooltip text. It is not a reliable way to label anything for accessibility. |
data-* | Custom data for scripts (see HTML5 Feature Overview) |
Key Takeaways
- Always start with
<!DOCTYPE html>. Without it, the browser renders in quirks mode. - Declare
<meta charset="utf-8">first,langon<html>, and the viewport meta tag for mobile. <head>holds metadata that is not rendered.<body>holds visible content.- Use
defer(ortype="module") for application scripts andasyncfor independent ones. - The HTML parser silently repairs errors, so validate your markup instead of trusting how it looks.
Practice
- (Beginner) From memory, write a valid skeleton for an Italian-language page called "Ricette" with a description and an SVG favicon. Run it through the Nu Html Checker until it reports no errors.
- (Intermediate) A page displays "Café" instead of "Café". Explain which bytes were decoded with which encoding, list the three places where a document's encoding can be declared, and give their order of precedence.
- (Interview) "What is the difference between
deferandasyncon a script tag, and which would you use for an analytics snippet compared with your main application bundle?" Hint: think about whether execution order matters and whether the script needs the full DOM.
Semantic Elements
A page built only from <div> and <span> can look exactly like a well-structured one. Software that reads the page sees something very different, though. Screen readers, search engine crawlers, browser reader modes, translation tools and the next developer to open the file see only an undifferentiated pile of boxes. Semantic elements say what a piece of content is: navigation, the main content, a self-contained article, a date. Machines and people can then act on that meaning.
Analogy: two copies of the same book. One has a cover, a table of contents, chapter titles and footnotes. The other is a stack of unlabelled photocopies. The words are identical, but only the first can be navigated, indexed or summarised.
Landmark (sectioning) elements. These elements describe the major regions of a page. Assistive technologies expose them as landmarks, so a screen reader user can jump straight to the navigation or the main content.
| Element | Meaning | Implicit ARIA role |
|---|---|---|
<header> | Introductory content for the page or for a section | banner (only when it is not inside article, aside, main, nav or section) |
<nav> | A major block of navigation links | navigation |
<main> | The dominant, unique content of the page. Use only one visible <main>. | main |
<article> | Self-contained content that would make sense on its own (a post, a comment, a product card) | article |
<section> | A thematic group of content, normally with its own heading | region (only when it has an accessible name) |
<aside> | Content only loosely related to its surroundings (sidebars, call-outs) | complementary |
<footer> | Closing information for the page or a section | contentinfo (only at page level) |
<search> | A search or filtering form | search |
+---------------------------------------------------+
| <header> logo, site title |
| <nav> Home | Blog | About |
+-----------------------------------+---------------+
| <main> | <aside> |
| <h1>Latest posts</h1> | related |
| <article> | links, |
| <h2>Post title</h2> | newsletter |
| <p>...</p> | |
| <footer>by Ada, <time></footer> |
| </article> | |
| <article>...</article> | |
+-----------------------------------+---------------+
| <footer> copyright, contact links |
+---------------------------------------------------+
Choosing between article, section and div.
Could this content stand on its own (be shared, syndicated in a feed, reused)?
yes --> <article>
no --> Is it a thematic group of content with its own heading?
yes --> <section>
no --> Is it only a hook for styling or scripting?
yes --> <div> (no semantic meaning, and that is fine)
Headings. <h1> to <h6> form the document outline that screen reader users browse, much like a table of contents. Choose the heading level for its place in the hierarchy, not for its default size (CSS controls size), and don't skip levels (for example h2 straight to h4). The HTML specification once described an "outline algorithm" in which every section could restart at <h1>. No browser ever implemented it, and it was removed from the specification in 2022. Use explicit levels.
Text-level semantics. Inline elements carry meaning too:
| Element | Meaning | Not to be confused with |
|---|---|---|
<strong> | Strong importance, seriousness or urgency | <b>: draws attention without extra importance (keywords, product names) |
<em> | Stress emphasis that changes the meaning of a sentence | <i>: alternate voice (technical terms, foreign words, thoughts) |
<mark> | Highlighted because of relevance (for example search matches) | Styling for decoration only |
<time datetime="2026-10-02"> | A date or time in machine-readable form | Plain text dates |
<abbr title="..."> | Abbreviation, with its expansion | |
<code>, <kbd>, <samp>, <var> | Code, keyboard input, program output, variable | |
<blockquote>, <q>, <cite> | Block quote, inline quote, title of a cited work | <cite> is for the work, not the person |
<figure> + <figcaption> | Self-contained illustration, diagram or code listing with a caption | |
<address> | Contact information for the nearest article or the page | Any postal address |
<ul>, <ol>, <dl> | Unordered, ordered and description lists | Paragraphs separated by <br> |
Example: from "div soup" to semantic markup
<!-- Before: correct visually, meaningless to software -->
<div class="header">
<div class="menu">
<div class="item"><a href="/">Home</a></div>
<div class="item"><a href="/blog">Blog</a></div>
</div>
</div>
<div class="content">
<div class="post">
<div class="title">Understanding the Cascade</div>
<div class="date">2 October 2026</div>
<div class="text">The cascade decides which rule wins...</div>
</div>
</div>
<!-- After: the same layout, now with meaning -->
<header>
<nav aria-label="Primary"> <!-- the label tells apart several nav landmarks -->
<ul> <!-- screen readers announce "list, 2 items" -->
<li><a href="/">Home</a></li>
<li><a href="/blog" aria-current="page">Blog</a></li>
</ul>
</nav>
</header>
<main>
<article>
<h1>Understanding the Cascade</h1>
<p><time datetime="2026-10-02">2 October 2026</time></p>
<p>The cascade decides which rule <em>wins</em>...</p>
</article>
</main>
The benefits of semantic markup add up quickly:
- Accessibility: landmark and heading navigation, list item counts, correct announcements.
- Search engines and previews: crawlers can identify the main content and dates.
- Built-in behaviour: semantic elements such as
<button>and<a>come with keyboard support for free. - Maintainability:
<nav>explains itself, while<div class="x7">does not.
Key Takeaways
- Semantic elements describe meaning. CSS controls appearance.
- Landmarks (
header,nav,main,aside,footer,search) let assistive technology users jump around the page. - Use
<article>for self-contained content,<section>for a themed group with a heading, and<div>when there is no meaning to express. - Pick heading levels by hierarchy, use one
h1for the page topic, and don't skip levels. <strong>/<em>express importance and emphasis.<b>/<i>express a different kind of attention without changing importance.
Practice
- (Beginner) Rewrite a recipe page made of
divs (title, ingredient list, numbered steps, author, publication date) using semantic elements only, with no extra classes.- (Intermediate) Open a news website, enable the Accessibility pane in DevTools (or the full accessibility tree view), and list its landmarks and heading outline. Identify at least one semantic problem and propose a fix.
- (Interview) "When would you use
<section>rather than<article>or<div>?" Hint: consider whether the content makes sense on its own, whether it has a heading, and what a screen reader would announce in each case.
Forms and Input Controls
Forms are the Web's original way of sending data from a user to a server. Login pages, search boxes, checkouts and settings screens are all forms. A native HTML form works without any JavaScript: the browser collects the values, encodes them, sends an HTTP request and shows the response. Understanding this built-in behaviour lets you add JavaScript as an enhancement rather than rebuilding, badly, what the browser already does.
How submission works. When a form is submitted, the browser:
- Collects every successful control. That means it has a
name, is not disabled, and, for checkboxes and radio buttons, is checked. - Encodes the values as
name=valuepairs. - Sends them to the URL in
actionusing the HTTP method inmethod.
| Attribute | Values | Effect |
|---|---|---|
action | URL | Where to send the data (defaults to the current URL) |
method | get (default), post | GET appends data to the URL query string. POST sends it in the request body. |
enctype | application/x-www-form-urlencoded (default), multipart/form-data, text/plain | How the body is encoded for POST. multipart is required for file uploads. |
novalidate | boolean | Disables the browser's built-in validation |
autocomplete | on, off | Default autofill behaviour for the form |
Use GET for safe, repeatable queries such as searches and filters, because the URL can be bookmarked and shared. Use POST for actions that change data or send sensitive values, which should not end up in URLs, server logs or browser history.
Labels are not optional. Every control needs a <label>. It gives the control an accessible name, and clicking the label focuses or toggles the control, which makes small checkboxes much easier to hit. Group related controls with <fieldset> and describe the group with <legend>.
Input types. The type attribute changes validation, the on-screen keyboard on mobile, and the native UI:
| Type | Purpose | Built-in behaviour |
|---|---|---|
text, search | Free text | search may show a clear button |
email, url | Addresses | Format validation and a matching mobile keyboard |
password | Secret text | Masked input. Password managers recognise it. |
tel | Phone numbers | Numeric keypad. No format validation, because formats vary by country. |
number, range | Numbers | min, max, step. range shows a slider. |
date, time, datetime-local, month, week | Dates and times | Native picker. The value is always ISO format, such as 2026-10-02. |
checkbox, radio | Choices | Radios that share a name form a group with a single selection |
file | File upload | accept, multiple. Requires multipart/form-data. |
color | Colour picker | Value such as #ff8800 |
hidden | Data the user doesn't see | Not secret. It is visible in the page source and can be edited. |
submit, reset, button | Buttons | Prefer the <button> element |
Other controls are <select> (with <option> and <optgroup>), <textarea> for multi-line text, <output> for calculated results, and <datalist> for autocomplete suggestions on a text input.
The <button> trap. A <button> inside a form defaults to type="submit". A "Show password" button without type="button" will submit the form.
Built-in validation. Browsers check common constraints before submitting and show localised error messages:
| Attribute | Constraint |
|---|---|
required | Must not be empty (or must be checked) |
minlength, maxlength | Text length limits |
min, max, step | Numeric and date ranges |
pattern | Must match a regular expression (the whole value, implicitly anchored) |
type="email" / "url" | Must be a well-formed address |
CSS can react to validity with :valid, :invalid, :required, :checked and :disabled. :user-invalid matches only after the user has interacted with the field, so empty required fields don't show errors as soon as the page loads.
Example: a registration form
<form action="/signup" method="post">
<fieldset>
<legend>Account</legend>
<label for="username">Username</label>
<input id="username" name="username" required minlength="3"
pattern="[a-z0-9_]+" autocomplete="username"
aria-describedby="username-hint"> <!-- links the hint below to the field -->
<p id="username-hint">Lowercase letters, digits and underscores.</p>
<label for="email">Email</label>
<input id="email" name="email" type="email" required autocomplete="email">
<label for="password">Password</label>
<input id="password" name="password" type="password" required minlength="12"
autocomplete="new-password"> <!-- prompts the password manager to suggest one -->
<label for="confirm">Confirm password</label>
<input id="confirm" name="confirm" type="password" required>
</fieldset>
<fieldset>
<legend>Plan</legend>
<label><input type="radio" name="plan" value="free" checked> Free</label> <!-- same name = one group -->
<label><input type="radio" name="plan" value="pro"> Pro</label>
</fieldset>
<label><input type="checkbox" name="newsletter"> Send me product news</label>
<button type="submit">Create account</button>
</form>
When the user submits with the Pro plan and the newsletter checked, the browser sends:
POST /signup HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=ada_l&email=ada%40example.com&password=...&confirm=...&plan=pro&newsletter=on
Note that @ is percent-encoded as %40, the checkbox sends on because it has no value, and an unchecked checkbox would send nothing at all.
Example: custom validation with the Constraint Validation API
const password = document.querySelector('#password');
const confirm = document.querySelector('#confirm');
function checkMatch() {
// A non-empty message marks the field invalid; an empty string clears the error
confirm.setCustomValidity(
confirm.value === password.value ? '' : 'Passwords do not match.'
);
}
password.addEventListener('input', checkMatch);
confirm.addEventListener('input', checkMatch);
// form.checkValidity() returns true/false; form.reportValidity() also shows the messages
/* Show error styling only after the user has interacted with a field */
input:user-invalid {
border-color: #c62828;
outline: 2px solid #c62828;
}
Client-side validation is not security. It exists for a better user experience. Anyone can skip it with DevTools or by sending the request directly with curl. The server must always validate everything again (Chapters 12 and 17).
Key Takeaways
- Only controls with a
nameare submitted. An unchecked checkbox sends nothing. - Use GET for repeatable queries and POST for changes or sensitive data. File uploads need
multipart/form-data. - Every control needs a
<label>. Group related controls with<fieldset>and<legend>. - Pick the most specific
typeto get validation, mobile keyboards and native pickers for free. <button>defaults totype="submit"inside forms. Settype="button"for other actions.- Built-in and custom client-side validation improve the experience. The server must still validate.
Practice
- (Beginner) Build a contact form with name, email, a topic
<select>, a message<textarea>and a required consent checkbox. Setaction="https://httpbin.org/post"and inspect the data that comes back.- (Intermediate) Extend the registration form so that the error styling uses
:user-invalid, the password field has a "Show" toggle button that does not submit the form, and the browser's own validation bubbles are replaced with messages written into an element linked througharia-describedby.- (Interview) "What changes in the HTTP request when a form's
enctypeismultipart/form-data, and why is it required for file uploads?" Hint: compare how binary file bytes would fit into a percent-encoded string with how a multipart body uses boundaries and per-part headers.
Tables, Media and Embedded Content
Text alone rarely tells the whole story. Pages need grids of data, images, audio, video and sometimes whole documents from other sites. HTML has dedicated elements for each, and each comes with its own accessibility, performance and security concerns.
Tables are for tabular data. In the early Web, tables were used to lay out whole pages. That practice is long obsolete (use Flexbox and Grid, covered in 2.3). Today <table> should be used only for data with real rows and columns, where a screen reader can announce "row 3, Price column, 12 euros".
| Element | Role |
|---|---|
<table> | The table |
<caption> | Title of the table (its accessible name) |
<thead>, <tbody>, <tfoot> | Header, body and footer row groups |
<tr> | A row |
<th scope="col"> / <th scope="row"> | Header cell for a column or a row |
<td> | Data cell |
colspan / rowspan | Merges a cell across columns or rows |
Example: an accessible data table
<table>
<caption>Monthly plans</caption>
<thead>
<tr>
<th scope="col">Plan</th>
<th scope="col">Users</th>
<th scope="col">Price</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Starter</th> <!-- row header: each cell in this row is "about" Starter -->
<td>5</td>
<td>€9</td>
</tr>
<tr>
<th scope="row">Team</th>
<td colspan="2">Contact sales</td> <!-- spans the Users and Price columns -->
</tr>
</tbody>
</table>
Monthly plans
+----------+-------+-------+
| Plan | Users | Price | <- thead, th scope="col"
+----------+-------+-------+
| Starter | 5 | €9 | <- th scope="row" + td
| Team | Contact sales | <- colspan="2"
+----------+---------------+
Images. <img> needs two things in every case:
alttext that describes the image's purpose in context. A purely decorative image gets an emptyalt=""so screen readers skip it. A missingaltmakes many screen readers read the file name aloud.widthandheightattributes, so the browser can reserve space before the file arrives. This prevents the content from jumping around while the page loads, which is measured as Cumulative Layout Shift.
For performance, add loading="lazy" to images below the fold, and fetchpriority="high" to the main hero image.
Responsive images. A phone should not download a 2400px-wide photo meant for a 4K monitor. HTML provides two mechanisms:
| Need | Solution |
|---|---|
| Same image at different resolutions (resolution switching) | srcset with width descriptors and sizes on <img>. The browser chooses. |
| Different crops or formats (art direction, AVIF/WebP with a fallback) | <picture> with <source> children. The first match wins. |
<!-- Resolution switching: the browser chooses the best file for the screen -->
<img src="hero-800.jpg"
srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1600.jpg 1600w"
sizes="(min-width: 60em) 50vw, 100vw"
alt="Team working around a whiteboard"
width="1600" height="900"
fetchpriority="high">
<!-- sizes: "the image will be half the viewport wide on large screens, full width otherwise" -->
<!-- Format fallback and art direction -->
<picture>
<source type="image/avif" srcset="chart.avif"> <!-- smallest, used if supported -->
<source type="image/webp" srcset="chart.webp">
<source media="(max-width: 40em)" srcset="chart-cropped.png">
<img src="chart.png" alt="Revenue grew 40% from Q1 to Q4" width="1200" height="600" loading="lazy">
</picture>
The <img> inside <picture> is required: it holds alt, the size and the final fallback.
Audio and video. <audio> and <video> play media natively, with no plugins.
<video controls width="1280" height="720" poster="intro-poster.jpg" preload="metadata">
<source src="intro.webm" type="video/webm"> <!-- the first supported format is used -->
<source src="intro.mp4" type="video/mp4">
<track kind="captions" src="intro.en.vtt" srclang="en" label="English" default>
<p>Your browser cannot play this video. <a href="intro.mp4">Download it</a>.</p> <!-- fallback -->
</video>
| Attribute | Effect |
|---|---|
controls | Shows the browser's play, volume and full-screen controls |
autoplay | Browsers only allow it when the media is also muted (autoplay policies) |
muted, loop, playsinline | playsinline stops iOS from forcing full screen |
preload | none, metadata or auto: a hint about how much to download in advance |
poster | Image shown before playback starts |
Captions (<track> with WebVTT files) are needed for deaf and hard-of-hearing users, and they also help anyone watching with the sound off.
Embedded content with <iframe>. An <iframe> embeds a complete, separate document: maps, videos, payment widgets. The embedded page runs with the permissions of its own origin, so treat it as untrusted and restrict it.
<iframe src="https://www.youtube-nocookie.com/embed/VIDEO_ID"
title="Product demo video"
width="560" height="315"
loading="lazy"
sandbox="allow-scripts allow-same-origin allow-presentation"
allow="fullscreen; picture-in-picture"
referrerpolicy="strict-origin-when-cross-origin"></iframe>
<!-- title: the accessible name of the frame, which screen readers announce -->
<!-- sandbox: start with no permissions and add back only what is needed -->
<!-- allow: Permissions Policy that grants specific browser features -->
Never combine allow-scripts and allow-same-origin on a frame that loads content from your own origin: the framed script could then reach into its parent and remove the sandbox. A site can also refuse to be framed at all with the Content-Security-Policy: frame-ancestors header (Chapter 17).
The older <object> and <embed> elements were used for plugins such as Flash and Java applets, which no longer exist. Inline <svg> and <canvas> for graphics are covered in Chapter 7.
Key Takeaways
- Use tables only for tabular data, with a
<caption>,<th>andscope. - Every
<img>needs a meaningfulalt(oralt=""if it is decorative) pluswidthandheightto prevent layout shift. - Use
srcset/sizesfor resolution switching and<picture>for art direction or modern formats with fallbacks. <video>and<audio>accept several<source>formats. Add captions with<track>. Autoplay requiresmuted.- Restrict iframes with
sandbox,allowand atitle, and lazy-load them when they are off-screen.
Practice
- (Beginner) Build an accessible table of a weekly timetable with days as columns and hours as row headers, including a lunch break that spans all days.
- (Intermediate) Serve a hero image in AVIF, WebP and JPEG at 480, 960 and 1920 pixels wide. Use the DevTools Network panel with device emulation to check which file each viewport downloads.
- (Interview) "A page has a Cumulative Layout Shift problem caused by images and embeds. How do you fix it?" Hint: the browser needs to know an element's aspect ratio before the resource arrives. Think about
width/heightattributes, the CSSaspect-ratioproperty and reserved placeholders.
HTML5 Feature Overview
By the mid-2000s, web applications such as Gmail were being built with a language designed for documents. Video needed Flash, drawing needed plugins, and every site invented its own date picker. HTML5 was the response: a large, backward-compatible update that turned HTML from a document format into an application platform.
A short history. After HTML 4.01 (1999), the W3C bet on XHTML 2.0, a strict XML-based language that was not compatible with existing pages. Browser vendors disagreed and formed the WHATWG in 2004 to evolve HTML incrementally. Their work became HTML5, which the W3C published as a Recommendation in October 2014. Since a 2019 agreement between the two bodies, the HTML Living Standard maintained by the WHATWG is the single authoritative specification. It has no version numbers, and it is updated continuously (see 1.3 Standards and Development Environment). "HTML5" now works as an umbrella term for the modern web platform rather than as a version.
What HTML5 introduced:
| Area | Additions | Covered in |
|---|---|---|
| Semantics | header, nav, main, article, section, aside, footer, figure, time, mark | 2.1 Semantic Elements |
| Forms | New input types, placeholder, required, pattern, autocomplete, datalist, output | 2.1 Forms |
| Multimedia | <audio>, <video>, <track> | 2.1 Tables, Media |
| Graphics | <canvas>, inline <svg> and MathML | Chapter 7 |
| Interactivity | <details>/<summary>, <dialog>, contenteditable, drag and drop | This topic |
| Custom data | data-* attributes | This topic |
| APIs | Web Storage, History, Geolocation, Web Workers, WebSocket, File API | Chapters 5, 6, 10 |
| Offline | Application Cache (now removed), replaced by Service Workers | Chapter 15 |
| Parsing | One precise parsing algorithm, so every browser builds the same DOM even from broken markup | 2.1 Document Structure |
Removed and obsolete features. Presentational elements were dropped because styling belongs in CSS. Elements tied to old plugins or frames were dropped as well:
| Obsolete | Replace with |
|---|---|
<font>, <center>, <big>, <tt>, <strike> | CSS (font-*, text-align, text-decoration) |
<frameset>, <frame> | Normal layouts, or <iframe> where embedding is needed |
<applet> | JavaScript or WebAssembly |
<acronym> | <abbr> |
bgcolor, align, border attributes | CSS |
Content categories. HTML5 replaced the simple "block vs inline" split with content models. Each element belongs to categories (such as flow, phrasing, interactive or sectioning) and specifies which categories it may contain. For example, <p> may only contain phrasing content, which is why <p><div>...</div></p> is invalid and the parser closes the <p> before the <div>. Interactive content cannot be nested inside other interactive content, so a <button> inside an <a> is invalid.
Example: interactive elements that need no JavaScript
<!-- Disclosure widget: open and closed natively, keyboard-accessible -->
<details>
<summary>What payment methods do you accept?</summary>
<p>Cards, bank transfer and PayPal.</p>
</details>
<!-- An exclusive accordion: details elements that share a name close each other -->
<details name="faq"><summary>Shipping</summary><p>...</p></details>
<details name="faq"><summary>Returns</summary><p>...</p></details>
<!-- Popover: top-layer UI with light dismiss (click outside or Esc) and no JavaScript -->
<button popovertarget="user-menu">Account</button>
<div id="user-menu" popover>
<a href="/profile">Profile</a>
<a href="/logout">Log out</a>
</div>
<!-- Modal dialog: focus moves into it, Esc closes it, the rest of the page is inert -->
<dialog id="confirm-delete">
<form method="dialog"> <!-- submitting closes the dialog -->
<p>Delete this file permanently?</p>
<button value="cancel">Cancel</button>
<button value="delete">Delete</button> <!-- dialog.returnValue === "delete" -->
</form>
</dialog>
<button id="delete-btn" data-file-id="4821" data-owner="ada">Delete file</button>
<script type="module">
const dialog = document.querySelector('#confirm-delete');
const btn = document.querySelector('#delete-btn');
btn.addEventListener('click', () => dialog.showModal()); // showModal(), not show(), for a modal
dialog.addEventListener('close', () => {
if (dialog.returnValue === 'delete') {
// data-file-id becomes dataset.fileId (kebab-case is converted to camelCase)
console.log('Deleting file', btn.dataset.fileId);
}
});
</script>
/* The ::backdrop pseudo-element styles the layer behind a modal dialog */
dialog::backdrop {
background: rgb(0 0 0 / 0.5);
}
Other useful additions:
<template>: inert markup that is parsed but not rendered, used as a blueprint for cloning (Chapter 6).inert: makes a subtree non-interactive and hides it from assistive technologies.contenteditable: lets the user edit an element's content in place.hidden: hides an element through markup.spellcheck,translate,enterkeyhint,inputmode: hints for spelling, translation and virtual keyboards.
Key Takeaways
- HTML5 turned HTML into an application platform with semantics, native media, graphics, richer forms and JavaScript APIs.
- The WHATWG HTML Living Standard is the single, continuously updated specification. "HTML5" is now an umbrella term.
- Presentational elements and attributes are obsolete. Use CSS instead.
- Content models define what each element may contain, which explains many parser surprises.
<details>,<dialog>andpopoverprovide accessible interactive UI with little or no JavaScript.data-*attributes store custom data for scripts.
Practice
- (Beginner) Build an FAQ section of five questions using
<details name="...">so that only one answer is open at a time. Style the open state with the[open]attribute selector.- (Intermediate) Build a "Rename file" modal with
<dialog>that contains a form with a text input. Pre-fill the input from adata-filenameattribute on the button that opened it, and log the new name only when the user confirms.- (Interview) "Why does the browser turn
<p><div>Hi</div></p>into two separate paragraphs and a div, and how would you detect this kind of problem?" Hint: look up the content model of<p>and which end tags the parser can infer, then think about the DOM inspector and validators.
Accessibility and ARIA Basics
Accessibility (often shortened to a11y: "a", 11 letters, "y") means building pages that people can use regardless of disability: blind users with screen readers, people who cannot use a mouse, people with low vision, colour blindness, hearing loss or cognitive disabilities. The World Health Organization estimates that about 1.3 billion people, roughly 16% of the world's population, live with a significant disability. Accessibility is also a legal requirement in many places (the ADA in the US, the European Accessibility Act, in force since June 2025), and it improves the experience for everyone: captions in a noisy train, keyboard shortcuts for power users, readable contrast in bright sunlight.
WCAG. The Web Content Accessibility Guidelines (current version 2.2, published in 2023) define testable success criteria under four principles, known as POUR:
| Principle | Meaning | Examples |
|---|---|---|
| Perceivable | Users can perceive the content | Text alternatives, captions, sufficient colour contrast |
| Operable | Users can operate the interface | Full keyboard access, enough time, no seizure-inducing flashes |
| Understandable | Content and behaviour are predictable | Clear labels, helpful error messages, consistent navigation |
| Robust | Works with current and future assistive technology | Valid markup, correct roles, names and states |
Each criterion has a conformance level: A (minimum), AA (the usual legal and contractual target) and AAA (enhanced).
The accessibility tree. Assistive technologies do not read your HTML directly. The browser builds a parallel accessibility tree from the DOM, and screen readers query it through operating system APIs. Each node exposes four key properties:
DOM Accessibility tree
<button aria-expanded="false"> --> role: button
Menu name: "Menu"
</button> state: collapsed, focusable
value: (none)
<div onclick="toggle()">Menu</div> --> role: generic (not focusable, not announced as clickable)
name: "Menu"
- Role: what the element is (button, link, checkbox, heading).
- Name: what it is called (from its content, a
<label>,alt,aria-label...). - State/properties: its current condition (checked, expanded, disabled, invalid).
- Value: for controls such as sliders and text fields.
Native HTML first. Most accessibility comes for free when you use the right element. A <button> is focusable, activates with both Enter and Space, has the button role and gets its name from its text. A <div> styled to look like a button has none of these.
<!-- Inaccessible: no role, not focusable, mouse-only -->
<div class="btn" onclick="save()">Save</div>
<!-- The "fixed" div: all of this just to imitate a native element -->
<div class="btn" role="button" tabindex="0"
onclick="save()"
onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); save(); }">
Save
</div>
<!-- The correct solution: one element, all behaviour built in -->
<button type="button" onclick="save()">Save</button>
ARIA. WAI-ARIA (Accessible Rich Internet Applications) is a set of attributes that change how elements appear in the accessibility tree. ARIA changes semantics only: it adds no behaviour, no focusability and no keyboard handling. Its first rule is: if a native HTML element or attribute already does what you need, use it instead of ARIA. Surveys of large numbers of sites consistently find more accessibility errors on pages that use ARIA than on pages that don't, because incorrect ARIA overrides correct native semantics.
| Kind | Examples | Purpose |
|---|---|---|
| Roles | role="tablist", role="tab", role="alert", role="dialog" | Declare what a custom widget is |
| Naming | aria-label, aria-labelledby | Provide or reference an accessible name |
| Description | aria-describedby | Link extra help or error text |
| States | aria-expanded, aria-pressed, aria-checked, aria-selected, aria-invalid, aria-current | Reflect dynamic state (your JavaScript must keep them up to date) |
| Visibility | aria-hidden="true" | Remove decorative content from the accessibility tree (never on focusable elements) |
| Live regions | aria-live="polite", role="status", role="alert" | Announce content that changes without moving focus |
How the accessible name is chosen (simplified):
1. aria-labelledby (references other elements' text) highest priority
2. aria-label (a string on the element itself)
3. Native labelling (<label for>, alt, <caption>, <legend>, <figcaption>)
4. Element content (for buttons, links, headings)
5. title attribute (last resort, unreliable) lowest priority
Keyboard accessibility. Many users navigate only with the keyboard: Tab and Shift+Tab move focus, Enter and Space activate, arrow keys move inside composite widgets, Esc closes.
tabindex value | Effect |
|---|---|
| (absent) | Natural behaviour: interactive elements are focusable, others are not |
0 | Adds the element to the tab order in document order |
-1 | Focusable from JavaScript (el.focus()) but skipped by Tab. Useful for headings you jump to and dialog containers. |
positive (1, 2...) | Forces an order ahead of everything else. Avoid: it almost always creates a confusing order. |
Never remove the focus indicator without replacing it. Use :focus-visible, which shows focus styles for keyboard users without showing them on mouse clicks.
Example: an accessible disclosure menu and a status message
<a class="skip-link" href="#main">Skip to main content</a> <!-- first focusable element on the page -->
<nav aria-label="Account">
<button type="button" aria-expanded="false" aria-controls="account-menu">
Account
</button>
<ul id="account-menu" hidden>
<li><a href="/settings">Settings</a></li>
<li><a href="/logout">Log out</a></li>
</ul>
</nav>
<main id="main" tabindex="-1"> <!-- -1 so the skip link can move focus here -->
<button type="button" id="save">Save draft</button>
<p role="status" id="save-status"></p> <!-- live region: changes are announced politely -->
</main>
const toggle = document.querySelector('[aria-controls="account-menu"]');
const menu = document.querySelector('#account-menu');
toggle.addEventListener('click', () => {
const open = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', String(!open)); // announced as "expanded" / "collapsed"
menu.hidden = open;
});
document.querySelector('#save').addEventListener('click', async () => {
await saveDraft();
// The live region already exists in the DOM, so changing its text triggers an announcement
document.querySelector('#save-status').textContent = 'Draft saved.';
});
.skip-link {
position: absolute;
transform: translateY(-120%); /* off-screen until focused */
}
.skip-link:focus {
transform: none;
}
:focus-visible {
outline: 3px solid #1a73e8; /* clear focus indicator for keyboard users */
outline-offset: 2px;
}
Testing accessibility. Automated tools (Lighthouse, axe DevTools, WAVE) reliably catch only some kinds of issue, such as missing alt, low contrast or missing labels. Whether a label is meaningful or a focus order makes sense needs a human. Manual checks:
- Unplug the mouse and complete every task with the keyboard alone. Is focus always visible and in a logical order?
- Use a screen reader: NVDA (Windows, free), VoiceOver (macOS and iOS, built in) or TalkBack (Android).
- Zoom to 200% and 400%. Does the content reflow without horizontal scrolling?
- Check the accessibility tree in DevTools for correct roles and names.
Key Takeaways
- Accessibility serves a large population, is often legally required, and improves usability for everyone.
- WCAG 2.2 organises requirements under Perceivable, Operable, Understandable and Robust. AA is the usual target.
- Assistive technologies read the accessibility tree: role, name, state and value.
- Native HTML first. ARIA changes semantics only, never behaviour, and bad ARIA is worse than none.
- Everything must work with a keyboard, with a visible focus indicator. Avoid positive
tabindex. - Automated tools find only part of the problems. Test with a keyboard and a real screen reader.
Practice
- (Beginner) Run Lighthouse's accessibility audit on a page you built earlier in this chapter. Fix every reported issue, then navigate the whole page with the keyboard and fix anything Lighthouse missed.
- (Intermediate) Build an accessible tabs component (three tabs and three panels) following the WAI-ARIA Authoring Practices pattern:
role="tablist"/tab/tabpanel,aria-selected,aria-controls, a rovingtabindexand arrow-key navigation. Test it with a screen reader.- (Interview) "A teammate added
role="button"to a clickable<div>. Is it accessible now?" Hint: list what a native<button>provides beyond its role (focus, which keys activate it, disabled state, form behaviour) and which of those ARIA supplies.
<a id="22-css-fundamentals"></a>
2.2 CSS Fundamentals
This subchapter explains how CSS finds elements, decides which rule wins, sizes and positions boxes, and brings them to life with motion.
Selectors and Combinators
Before CSS, presentation was mixed into the markup: <font color="red"> on every heading of every page. Changing the brand colour meant editing thousands of tags. CSS (Cascading Style Sheets) separates presentation from structure. The HTML says what things are, and one stylesheet says how all of them look. To make that work, CSS needs a way to point at elements without touching the HTML. That is what selectors do.
Anatomy of a rule.
selector declaration block
| +------------------------+
v v v
nav a { color: #1a73e8; text-decoration: none; }
\____/ \_____/
property value
\______________/
declaration
Attaching CSS to a document:
| Method | Syntax | Use |
|---|---|---|
| External stylesheet | <link rel="stylesheet" href="main.css"> | The default: cacheable and shared across pages |
| Embedded | <style> ... </style> in <head> | Critical above-the-fold CSS, single-page demos |
| Inline | style="color: red" attribute | Values computed by JavaScript. Hard to override and to maintain. |
Basic selectors.
| Selector | Example | Matches |
|---|---|---|
| Type | p | Every <p> |
| Class | .card | Elements with card in their class list |
| ID | #checkout | The element with id="checkout" |
| Universal | * | Every element |
| Attribute | [disabled] | Elements that have the attribute |
| Attribute equals | [type="email"] | Exact value |
| Attribute prefix / suffix / contains | [href^="https"], [href$=".pdf"], [class*="icon-"] | Value starts with, ends with, contains |
| Attribute word | [data-tags~="sale"] | Space-separated list contains the word |
| Case-insensitive | [href$=".PDF" i] | Ignores ASCII case |
| Selector list | h1, h2, h3 | Any of the listed selectors |
You can chain simple selectors with no spaces to require all of them on the same element: button.primary[disabled] means "a button with class primary that is disabled".
Combinators select elements by their relationship in the DOM tree:
| Combinator | Syntax | Matches |
|---|---|---|
| Descendant | article p | Any p anywhere inside an article |
| Child | ul > li | li that are direct children of a ul |
| Next sibling | h2 + p | The p immediately after an h2, sharing the same parent |
| Subsequent sibling | h2 ~ p | Every p after an h2, sharing the same parent |
<article> article p article > p h2 + p h2 ~ p
<h2>Title</h2>
<p>Intro</p> yes yes yes yes
<p>Body</p> yes yes no yes
<blockquote>
<p>Quote</p> yes no no no
</blockquote>
</article>
Pseudo-classes select elements by state or position, which plain markup can't express:
| Group | Examples |
|---|---|
| User interaction | :hover, :active, :focus, :focus-visible, :focus-within |
| Form state | :checked, :disabled, :required, :valid, :invalid, :user-invalid, :placeholder-shown |
| Links | :link, :visited, :target (the element whose id matches the URL fragment) |
| Tree position | :first-child, :last-child, :only-child, :nth-child(2n+1), :nth-of-type(3), :empty |
| Logical | :not(.x), :is(h1, h2), :where(ul, ol), :has(img) |
:nth-child(an+b) matches the children whose 1-based position equals an+b for some n = 0, 1, 2 and so on. For example, 2n+1 (or odd) gives 1, 3, 5..., -n+3 gives the first three, and 3n gives every third child.
:has() is the long-awaited "parent selector". It matches an element based on what it contains or what follows it: .card:has(img) styles cards that contain an image, and label:has(+ input:invalid) styles a label whose input is invalid.
Pseudo-elements (two colons) style a part of an element, or generate content that does not exist in the DOM:
| Pseudo-element | Targets |
|---|---|
::before, ::after | Generated boxes as the first or last child (require content) |
::first-line, ::first-letter | Typographic parts of a block of text |
::placeholder | The placeholder text of an input |
::marker | The bullet or number of a list item |
::selection | Text highlighted by the user |
::backdrop | The layer behind a modal <dialog> or a full-screen element |
How browsers match. Browsers evaluate selectors right to left. For nav ul li a, the browser first finds every a, then checks whether each one has an li ancestor, and so on. Modern engines are fast enough that selector performance rarely matters, but keeping selectors short and class-based still makes stylesheets easier to read and override.
Example: selectors in practice
/* Links to external sites get an icon (generated content) */
a[href^="http"]:not([href*="example.com"])::after {
content: " \2197"; /* north-east arrow character */
}
/* Zebra-striped table rows */
tbody tr:nth-child(even) {
background: #f5f7fa;
}
/* Spacing between consecutive paragraphs only (the "lobotomised owl" pattern, narrowed) */
.prose p + p {
margin-top: 1em;
}
/* A form field container that contains an invalid input */
.field:has(input:user-invalid) {
border-left: 4px solid #c62828;
}
/* Keyboard-only focus ring */
button:focus-visible {
outline: 3px solid #1a73e8;
}
/* Group selectors without repeating the rest */
:is(h1, h2, h3):hover > .anchor-link {
opacity: 1;
}
Key Takeaways
- A rule is a selector plus a declaration block of
property: valuepairs. - Basic selectors match by type, class, ID or attribute. Chaining them with no space requires all of them on the same element.
- Combinators (space,
>,+,~) match by relationship in the DOM tree. - Pseudo-classes match states and positions. Pseudo-elements style parts of an element or generate content.
:is(),:where(),:not()and:has()make complex selections concise.:has()lets you select by descendants or by following siblings.
Practice
- (Beginner) Write selectors for: every PDF link, every
liexcept the last one in aul, the first paragraph after eachh2, and every required input that is empty.- (Intermediate) Without JavaScript, style a
<label>in bold when its checkbox is checked, using both a sibling combinator layout (input + label) and a wrapping layout (label:has(input:checked)). Explain when each works.- (Interview) "Why do browsers match selectors from right to left?" Hint: consider how many candidate elements remain after checking the rightmost part (the "key selector") compared with starting at the root and walking down every branch.
Cascade, Specificity and Inheritance
Any real page has many rules that target the same element: the browser's defaults, a CSS reset, a component library, your own styles, perhaps a user's custom stylesheet. They often disagree. Should this button be blue or grey? CSS needs a deterministic algorithm that always gives exactly one answer for every property of every element. That algorithm is the cascade, and it is the "C" in CSS.
The cascade, step by step. For each property of each element, the browser collects every declaration that applies, then sorts them by the following criteria. It stops at the first criterion that decides the winner:
1. Origin and importance (browser defaults vs user vs author, and !important)
| tie
2. Context (normal page vs shadow DOM encapsulation)
| tie
3. Element-attached styles (the style="" attribute beats selector-based rules)
| tie
4. Cascade layers (@layer order)
| tie
5. Specificity (how precise the selector is)
| tie
6. Order of appearance (the later declaration wins)
1. Origins and importance. Declarations come from three origins: the user agent (browser defaults, such as h1 being bold and large), the user (accessibility settings or a custom stylesheet) and the author (you). !important reverses the normal order, so that users can always override authors when they really need to, for example to enforce a larger font size.
| Precedence (lowest to highest) |
|---|
| 1. User-agent normal |
| 2. User normal |
| 3. Author normal |
4. CSS animations (@keyframes) |
5. Author !important |
6. User !important |
7. User-agent !important |
| 8. CSS transitions |
Treat !important as a last resort, for example to override third-party inline styles or for utility classes that must always win. Each use makes the next override harder, which leads to an !important arms race.
4. Cascade layers. @layer lets you set the priority of whole groups of styles explicitly, regardless of their specificity. Layers declared later win over layers declared earlier, and styles that are not in any layer beat all layered styles.
@layer reset, vendor, components, utilities; /* declare the order once, up front */
@layer reset { button { border: none; } }
@layer components { #app .btn { background: grey; } } /* high specificity... */
@layer utilities { .bg-blue { background: blue; } } /* ...but this later layer wins */
5. Specificity. Specificity is a weight with three components, written (A, B, C) and compared from left to right like version numbers. A single ID therefore beats any number of classes.
| Component | Counted from |
|---|---|
| A | ID selectors (#nav) |
| B | Classes (.btn), attribute selectors ([type]), pseudo-classes (:hover) |
| C | Type selectors (a), pseudo-elements (::before) |
| Nothing | Universal *, combinators, :where() |
| Most specific argument | :is(), :not(), :has() |
| Selector | Specificity |
|---|---|
p | (0, 0, 1) |
.card p | (0, 1, 1) |
nav a:hover | (0, 1, 2) |
input[type="email"]:focus | (0, 2, 1) |
#main .card > p::first-line | (1, 1, 2) |
:is(#a, .b) p | (1, 0, 1), because #a is the most specific argument |
:where(#a, .b) p | (0, 0, 1), because :where() contributes zero |
6. Order of appearance. When everything else ties, the declaration that appears last wins. "Last" follows the order in which stylesheets are loaded, and the order of rules within each stylesheet.
Inheritance. The cascade decides between declarations that target an element. But many elements have no declaration for a property at all. Then inheritance applies. Some properties pass their computed value from parent to child automatically, and others fall back to their initial value.
| Inherited by default | Not inherited |
|---|---|
color, font-*, line-height, letter-spacing, text-align, visibility, cursor, list-style, custom properties | margin, padding, border, background, width, height, display, position, opacity, transform |
The rule of thumb is that text-related properties are inherited and box-related ones are not. Setting font-family on body styles all the text on the page, but setting border on body does not give every element a border.
You can control inheritance explicitly with global keywords, which work on every property:
| Keyword | Result |
|---|---|
inherit | Use the parent's computed value |
initial | Use the property's initial value from the specification (for display, that is inline) |
unset | inherit for inherited properties, initial for the others |
revert | Roll back to the browser's default style for this element (for div, display: block) |
revert-layer | Roll back to the value from the previous cascade layer |
all: unset resets every property at once, which is useful when you build a component from a native element such as <button>.
Example: resolving a conflict by hand
<nav id="site-nav">
<a class="link active" href="/">Home</a>
</nav>
a { color: black; } /* (0,0,1) */
.link { color: grey; } /* (0,1,0) */
nav .active { color: green; } /* (0,1,1) */
#site-nav a { color: navy; } /* (1,0,1) <- highest specificity: wins */
.link.active { color: orange; } /* (0,2,0) two classes still lose to one ID */
The link is navy. To make .active win without adding an ID or !important, you could put #site-nav a into a lower @layer, or rewrite it as :where(#site-nav) a (specificity (0,0,1)).
From declaration to pixels. A property value goes through several stages. Understanding them explains what getComputedStyle() (Chapter 6) returns.
| Stage | Example for width: 50% |
|---|---|
| Declared | Every declaration that matched |
| Cascaded | The winner: 50% |
| Specified | Cascaded value, or the inherited or initial value if there is none |
| Computed | Relative values resolved where possible: 50% (a percentage width depends on layout) |
| Used | After layout: 400px (half of an 800px container) |
| Actual | After device constraints, such as rounding to whole device pixels |
Key Takeaways
- The cascade resolves conflicts in this order: origin and importance, context, inline styles, layers, specificity, source order.
!importantreverses origin precedence. Use it sparingly.- Specificity is (IDs, classes/attributes/pseudo-classes, types/pseudo-elements), compared left to right.
:where()adds zero. @layerlets you set priority by architecture instead of selector weight. Unlayered styles beat layered ones.- Text-related properties inherit and box-related ones don't. Use
inherit,initial,unset,revertto control this.
Practice
- (Beginner) Compute the specificity of
ul#menu li.item > a:hover,.btn.btn-primary:not(.disabled)andbody :where(.card) h2::first-letter. Order them from strongest to weakest.- (Intermediate) You import a third-party stylesheet that uses selectors like
#widget .title. Using@layer, structure your CSS so that your simple class-based styles override it without IDs or!important.- (Interview) "A colleague's style isn't applying even though it's the last rule in the file. Walk me through how you would debug it." Hint: use the DevTools Styles pane (struck-through declarations), think about specificity, inline styles,
!important, cascade layers, invalid values and inheritance versus direct targeting.
The Box Model
To lay out a page, the browser has to turn every element into a rectangle with a precise position and size. The box model defines that rectangle. Every element generates a box made of four nested areas. Most "why is this 20 pixels wider than I said?" questions are about the box model.
+--------------------------------------------------+
| margin | transparent, outside the border,
| +------------------------------------------+ | separates the box from its neighbours
| | border | |
| | +----------------------------------+ | |
| | | padding | | | takes the element's background
| | | +--------------------------+ | | |
| | | | content | | | | text, images, child boxes
| | | | width x height | | | |
| | | +--------------------------+ | | |
| | +----------------------------------+ | |
| +------------------------------------------+ |
+--------------------------------------------------+
box-sizing: what does width measure?
| Value | width sets | Total rendered width for width: 300px; padding: 20px; border: 5px |
|---|---|---|
content-box (default) | The content area only | 300 + 220 + 25 = 350px |
border-box | Content + padding + border | 300px (the content area shrinks to 250px) |
border-box matches how people think about sizes, so almost every modern project sets it globally:
*, *::before, *::after {
box-sizing: border-box;
}
Display types. The display property decides how a box takes part in layout. It has an outer type (how the box behaves among its siblings) and an inner type (how it lays out its children).
display | Outer behaviour | Width and height | Vertical margin and padding |
|---|---|---|---|
block | Starts on a new line and fills the available width | Respected | Push other boxes away |
inline | Flows within a line of text | Ignored | Margins ignored vertically. Padding is painted but doesn't move other lines. |
inline-block | Flows within a line, but is a box inside | Respected | Respected |
flex / grid | Block-level box whose children use Flexbox or Grid | Respected | Respected |
inline-flex / inline-grid | Inline-level versions | Respected | Respected |
none | No box at all: removed from layout and the accessibility tree | ||
contents | The element's own box disappears, and its children take its place |
Margin collapsing. In normal block flow, the vertical margins of adjacent blocks don't add up. They collapse into a single margin equal to the larger of the two.
<h2 style="margin-bottom: 30px"> +--------------+
| h2 |
+--------------+
| 30px (not 30 + 20 = 50px)
<p style="margin-top: 20px"> +--------------+
| p |
+--------------+
Collapsing happens in three situations:
- Between adjacent siblings.
- Between a parent and its first or last child when nothing separates them (no border, padding or inline content). The child's margin "escapes" through the parent.
- Through an empty block, whose own top and bottom margins collapse.
It never happens with horizontal margins, inside flex or grid containers, with floated or absolutely positioned boxes, or across a block formatting context boundary. You can create one with display: flow-root.
Overflow. When content is larger than its box:
overflow | Behaviour |
|---|---|
visible (default) | Content spills out and may overlap other content |
hidden | Clipped. Still scrollable from JavaScript. Creates a block formatting context. |
clip | Clipped, with no scrolling at all |
scroll | Always shows scrollbars |
auto | Shows scrollbars only when needed |
Outline vs border. outline is drawn outside the border and takes no space, so it never shifts the layout. That makes it ideal for focus indicators.
Logical properties. margin-left assumes left-to-right, horizontal text. Logical properties follow the writing direction instead: inline is the text direction and block is the direction in which lines stack. A layout written with them adapts automatically to Arabic, Hebrew or vertical Japanese.
| Physical | Logical (for horizontal left-to-right text) |
|---|---|
margin-left / margin-right | margin-inline-start / margin-inline-end |
padding-top + padding-bottom | padding-block |
width / height | inline-size / block-size |
top / left | inset-block-start / inset-inline-start |
Example: box model in practice
.card {
box-sizing: border-box;
inline-size: 320px; /* = width in horizontal writing modes */
padding: 16px 24px; /* vertical | horizontal */
border: 1px solid #d0d7de;
margin: 0 auto 24px; /* top | horizontal (auto = centre a block) | bottom */
overflow: auto; /* scroll if the content is too tall */
max-block-size: 400px;
}
/* Contain children's margins and floats inside the card without clearfix hacks */
.card-body {
display: flow-root;
}
/* An inline element ignores width and vertical margins; inline-block respects them */
.badge {
display: inline-block;
padding: 2px 8px;
margin-block: 4px;
}
Use the DevTools Computed tab: it shows an interactive box model diagram with the exact content, padding, border and margin values of the selected element.
Key Takeaways
- Every box has content, padding, border and margin, from the inside out.
box-sizing: border-boxmakeswidthinclude padding and border. Set it globally.- Block boxes stack and respect sizes. Inline boxes flow in text and ignore width, height and vertical margins.
- Vertical margins between blocks in normal flow collapse to the larger value. Flex, grid and
flow-rootprevent this. - Prefer logical properties (
margin-inline,padding-block) for layouts that work in any writing direction.
Practice
- (Beginner) Calculate the rendered width of
div { width: 50%; padding: 2rem; border: 4px solid; }inside a 1000px container, withcontent-boxand withborder-box(1rem = 16px).- (Intermediate) A
<section>with a grey background contains an<h2>withmargin-top: 40px, but the gap appears above the grey area instead of inside it. Explain why and give three different fixes.- (Interview) "What is the difference between
display: none,visibility: hiddenandopacity: 0?" Hint: compare whether each one keeps its space in the layout, whether it can still receive clicks and focus, whether it stays in the accessibility tree, and whether it can be animated.
Units, Colors and Typography
A size of "16px" or a colour of "red" seems simple, until users zoom in, switch to dark mode, rotate their phone or open the page on a 6K display. Choosing the right units makes layouts scale. Choosing colours with enough contrast keeps text readable. A solid typography setup is responsible for most of what makes a page look professional.
Length units.
| Unit | Relative to | Typical use |
|---|---|---|
px | A CSS pixel (1/96 inch at a typical viewing distance, not one device pixel) | Borders, shadows, fine details |
em | The element's own font-size (for font-size itself, the parent's font size) | Padding that scales with the component's text |
rem | The root (html) font size, by default 16px | Font sizes and spacing scales |
% | Depends on the property: the parent's font size for font-size, the containing block's width for width, padding and margin | Fluid widths |
ch | Width of the "0" glyph | Readable line lengths (max-width: 65ch) |
lh / rlh | The line height of the element / of the root | Vertical rhythm |
vw / vh | 1% of the viewport width / height | Full-screen sections |
svh / lvh / dvh | Small, large and dynamic viewport height (accounts for mobile browser toolbars) | Mobile full-height layouts (min-height: 100dvh) |
vmin / vmax | The smaller / larger of vw and vh | Orientation-independent sizing |
cqi / cqb | 1% of a query container's inline / block size | Components that size relative to their container (see 2.3) |
Watch out for two surprises:
emcompounds. Withli { font-size: 1.2em; }, a list nested three levels deep has text at 1.2 x 1.2 x 1.2 = 1.728 times the base size.remavoids this because it always refers to the root.- Vertical percentages refer to the width.
padding-top: 50%is half the containing block's width, not its height. Before theaspect-ratioproperty existed, this was the classic way to build fixed-ratio boxes.
Accessibility note: browsers let users set a larger default font size. Sizing text in rem respects that setting, while font-size: 16px on html overrides it.
Math functions.
.sidebar { width: calc(100% - 2 * var(--gap)); } /* mix units freely */
.content { width: min(100% - 2rem, 70ch); } /* the smaller of the two */
.hero { padding: max(2rem, 5vh); } /* the larger of the two */
h1 { font-size: clamp(1.75rem, 1rem + 3vw, 3.5rem); } /* minimum, preferred, maximum */
Colours. CSS supports several colour notations. All modern browsers support the space-separated syntax with / for alpha.
| Notation | Example | Notes |
|---|---|---|
| Named | rebeccapurple, transparent | About 150 names |
| Hex | #1a73e8, #1a73e880 (with alpha), #fff (short form) | Compact. Hard to adjust by hand. |
rgb() | rgb(26 115 232 / 50%) | Red, green, blue 0 to 255 |
hsl() | hsl(217 82% 51%) | Hue (degrees), saturation, lightness. Easy to make variations. |
oklch() | oklch(60% 0.18 255) | Perceptual lightness, chroma, hue. Equal lightness values look equally light, which makes it the best choice for generating palettes. Also reaches wide-gamut (P3) colours. |
color-mix() | color-mix(in oklch, var(--brand), white 20%) | Tints, shades and blends computed by the browser |
currentColor | border: 1px solid currentColor | The element's current color value |
Contrast. WCAG AA requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (about 24px, or about 18.5px bold) and for meaningful non-text elements such as input borders and icons. DevTools shows the ratio in the colour picker. Never use colour as the only way to communicate something: add an icon or text to "red = error" for colour-blind users.
Typography.
| Property | Purpose | Advice |
|---|---|---|
font-family | A list of fonts in order of preference | Always end with a generic family: serif, sans-serif, monospace, system-ui |
font-size | Text size | rem for scales, clamp() for fluid headings |
font-weight | Weight 100 to 900 | 400 is normal and 700 is bold. Load only the weights you use. |
line-height | Space between lines | Use a unitless value such as 1.5, so it scales with each child's font size |
letter-spacing, word-spacing | Tracking | Slightly positive for uppercase labels |
text-align, text-transform, text-decoration | Alignment, case, lines | text-wrap: balance for headings, text-wrap: pretty for paragraphs |
font-variant-numeric | Number glyph styles | tabular-nums for aligned figures in tables |
Web fonts are loaded with @font-face. Use the WOFF2 format (best compression) and control what happens while the font downloads with font-display:
@font-face {
font-family: "Inter";
src: url("/fonts/Inter-Variable.woff2") format("woff2");
font-weight: 100 900; /* one variable font file covers the whole weight range */
font-display: swap; /* show fallback text immediately and swap when loaded */
}
:root {
font-size: 100%; /* respect the user's default (usually 16px) */
}
body {
font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
line-height: 1.6; /* unitless: each child recalculates it */
color: oklch(25% 0.02 255); /* near-black, softer than pure #000 */
background: oklch(99% 0.005 255);
}
h1, h2, h3 {
line-height: 1.2; /* tighter for large text */
text-wrap: balance; /* even line lengths in headings */
}
h1 { font-size: clamp(2rem, 1.2rem + 3vw, 3.5rem); } /* fluid: scales between 32px and 56px */
.prose {
max-width: 65ch; /* about 65 characters per line is comfortable to read */
}
.price-table td {
font-variant-numeric: tabular-nums; /* digits all the same width, so columns align */
}
Key Takeaways
- Use
remfor font sizes and spacing,emfor values that should scale with a component's text,pxfor hairlines, and%,vwandfrfor fluid layouts. emcompounds when nested. Percentages for padding and margin always refer to the containing block's width.dvhhandles mobile browser toolbars better thanvh.calc(),min(),max()andclamp()produce responsive values without media queries.- Prefer
oklch()andcolor-mix()for consistent palettes. Meet 4.5:1 contrast for body text. - Use unitless
line-height, a generic fallback font family, WOFF2 fonts andfont-display: swap.
Practice
- (Beginner) Build a typographic scale for
h1toh4and body text inremwith a ratio of 1.25. Check every text and background combination you use with the DevTools contrast checker.- (Intermediate) Write a
clamp()expression for a heading that is 24px at a 360px viewport and 48px at a 1280px viewport, growing linearly in between. Show the arithmetic for the preferred value.- (Interview) "Why might setting
html { font-size: 62.5%; }orhtml { font-size: 10px; }be a problem, and what would you do instead?" Hint: consider which of the two respects a user who changed their browser's default font size, and what happens toremvalues in each case.
Positioning and Stacking Contexts
By default, boxes are placed by normal flow: blocks stack from top to bottom, and inline content wraps from line to line. That covers most of a page, but not tooltips anchored to a button, a header that stays at the top while you scroll, a badge in the corner of an avatar, or modal overlays. The position property takes boxes out of normal flow, or shifts them within it. Stacking contexts then decide which box is painted in front when they overlap.
The five position values.
| Value | Stays in normal flow? | Offsets (top, right, bottom, left) are relative to | Typical use |
|---|---|---|---|
static (default) | Yes | Offsets are ignored | Everything |
relative | Yes (its original space is kept) | Its own normal position | Small nudges. Anchor for absolute children. |
absolute | No | The nearest positioned ancestor (any value other than static), or the initial containing block | Badges, tooltips, overlays inside a component |
fixed | No | The viewport | Persistent headers, floating buttons, cookie banners |
sticky | Yes, until a threshold is reached | Its nearest scrolling ancestor | Table headers and section titles that stick while scrolling |
.avatar { position: relative; } .badge { position: absolute; top: -4px; right: -4px; }
+---------------+[3] <- badge positioned against .avatar's padding box,
| | because .avatar is the nearest positioned ancestor
| avatar |
| |
+---------------+
The inset shorthand sets all four offsets: inset: 0 stretches an absolutely positioned element over its whole containing block.
Pitfalls to know:
- An absolutely positioned element with no positioned ancestor is placed against the initial containing block (roughly the first screen of the page), and it often ends up far from where you expected.
- Any ancestor with a
transform,filter,perspective,contain: paintorwill-change: transformbecomes the containing block for itsfixeddescendants, so the "fixed" element scrolls with that ancestor. This often breaks modals that are rendered inside animated containers. stickyneeds a threshold (for exampletop: 0). It sticks only within its parent, and it stops working if an ancestor between it and the scroller hasoverflow: hiddenorauto.
Floats. float: left | right was used to build whole layouts for years. Its intended purpose, letting text wrap around an image, is still valid. For layout, use Flexbox and Grid (2.3).
z-index and stacking contexts. When boxes overlap, z-index controls which one appears in front. There are two important rules:
z-indexonly affects positioned elements (non-static) and flex or grid items.z-indexvalues are only compared within the same stacking context.
A stacking context is a group of elements that is painted as a single unit, like a sheet of paper. Inside it, children are ordered by their own z-index. But the whole sheet is placed in its parent context according to the sheet's z-index. A child can never rise above its context's siblings, however large its own value.
Common things that create a stacking context:
| Trigger | Example |
|---|---|
| The root element | <html> |
A positioned element with a z-index other than auto | position: relative; z-index: 1 |
position: fixed or sticky | Always |
opacity below 1 | opacity: 0.99 |
transform, filter, backdrop-filter, perspective, clip-path, mask | Any value other than none |
A flex or grid item with a z-index other than auto | |
isolation: isolate | Creates a context explicitly, with no side effects |
will-change naming any of the properties above | will-change: transform |
Example: the "z-index: 9999 doesn't work" bug
<header class="site-header"> <!-- z-index: 10 -->
<div class="dropdown">Menu items</div> <!-- z-index: 9999 -->
</header>
<main class="content"> <!-- z-index: 20 -->
<div class="card">...</div>
</main>
.site-header { position: relative; z-index: 10; } /* creates stacking context A */
.dropdown { position: absolute; z-index: 9999; }/* 9999 only counts *inside* A */
.content { position: relative; z-index: 20; } /* creates stacking context B */
Root stacking context
+-- A .site-header z=10
| +-- .dropdown z=9999 <- compared only with other children of A
+-- B .content z=20 <- B (20) is painted above A (10), and everything in A goes with it
The dropdown is painted behind the content, whatever its own value. The fix is to change the order at the level where the contexts are siblings: give .site-header a higher z-index than .content, or remove the z-index from .content if it doesn't need one.
Painting order inside one stacking context (from back to front):
1. Background and borders of the element that forms the context
2. Positioned descendants with negative z-index
3. Non-positioned block boxes, in document order
4. Floats
5. Inline content (text, images)
6. Positioned descendants with z-index: auto or 0, in document order
7. Positioned descendants with positive z-index, lowest first
The top layer. Modal <dialog> elements, elements with the popover attribute and full-screen elements are rendered in the browser's top layer, above every stacking context in the document, with no z-index needed. This removes the main reason for z-index: 99999 in modern code.
Example: a sticky header and a positioned badge
.page-header {
position: sticky;
top: 0; /* sticks once it reaches the top of the scroller */
z-index: 10; /* above normal content scrolling underneath */
background: white; /* without a background, content shows through */
}
.avatar {
position: relative; /* establish the containing block for the badge */
isolation: isolate; /* own stacking context: internal z-indexes can't leak out */
}
.avatar .badge {
position: absolute;
inset-block-start: -4px; /* = top: -4px */
inset-inline-end: -4px; /* = right: -4px in left-to-right text */
z-index: 1; /* compared only with other children of .avatar */
}
.overlay {
position: fixed;
inset: 0; /* cover the whole viewport */
background: rgb(0 0 0 / 0.5);
}
Key Takeaways
relativekeeps the element in flow.absoluteandfixedremove it.stickyis in flow until it reaches its threshold.absolutepositions against the nearest positioned ancestor, so setposition: relativeon the intended parent.transform,filterand similar properties on an ancestor change whatfixedis relative to.z-indexis only compared within the same stacking context. A child can never escape its parent context.- Use
isolation: isolateto contain z-index values inside a component. Use<dialog>andpopoverfor UI that must appear above everything.
Practice
- (Beginner) Build a product card with a "Sale" ribbon in its top-right corner using
position: absolute, without the ribbon escaping the card.- (Intermediate) Build a long table whose header row stays visible while you scroll, inside a container with
overflow: auto. Then addoverflow: hiddento an outer wrapper and explain what changes.- (Interview) "A modal with
position: fixed; z-index: 1000appears behind a sidebar and scrolls with the page. What could cause both symptoms?" Hint: look for an ancestor with atransformorfilter, and think about which stacking contexts the modal and the sidebar belong to. What would rendering the modal in the top layer change?
Transitions, Transforms and Animations
Motion explains change. A menu that slides in shows where it came from, a button that subtly presses down confirms a click, and a skeleton loader shows that something is happening. CSS provides three tools: transforms change an element's geometry visually, transitions animate between two states when a property changes, and animations run timelines defined with keyframes. All of them are declarative, and the browser can often run them off the main thread.
Transforms. transform moves, rotates, scales or skews an element visually, without affecting layout: surrounding elements don't move to make room for it.
| Function | Example | Effect |
|---|---|---|
translate(x, y) | translate(-50%, 0) | Move. Percentages refer to the element's own size. |
scale(sx, sy) | scale(1.05) | Resize |
rotate(angle) | rotate(45deg) | Rotate around transform-origin (the centre by default) |
skew(ax, ay) | skewX(10deg) | Slant |
| 3D | perspective(600px) rotateY(30deg), translateZ() | Depth effects |
Transform functions are applied from right to left, so translate(100px) rotate(45deg) is not the same as rotate(45deg) translate(100px). The individual properties translate, rotate and scale can be set and animated separately from each other.
Transitions. A transition says: "when this property's value changes, animate from the old value to the new one instead of jumping". The change can come from :hover, a class added by JavaScript, a media query, or anything else.
transition: transform 0.2s ease-out 0.05s
\_______/ \__/ \______/ \___/
property duration timing function delay
| Timing function | Feel |
|---|---|
linear | Constant speed, which looks mechanical (good for spinners and progress) |
ease (default) | Speeds up quickly, then slows gently |
ease-in | Starts slowly. Use it for elements leaving the screen. |
ease-out | Ends slowly. Use it for elements entering, which feels responsive. |
ease-in-out | Slow at both ends |
cubic-bezier(x1, y1, x2, y2) | A custom curve (y values outside 0 to 1 create overshoot) |
steps(n) | Discrete jumps (sprite sheets, typewriter effects) |
Only interpolable values animate smoothly: lengths, colours, numbers and transforms. Discrete properties such as display used to change instantly, which made it impossible to fade an element in from display: none. Modern CSS solves this with transition-behavior: allow-discrete and @starting-style, which defines the "before" state for an element that has just appeared.
Animations. @keyframes defines a timeline with any number of steps. Animations can run automatically, loop and alternate direction, and they don't need a state change to start.
| Property | Purpose | Example |
|---|---|---|
animation-name | Which @keyframes to run | pulse |
animation-duration | Length of one cycle | 1.5s |
animation-timing-function | Easing (applied between each pair of keyframes) | ease-in-out |
animation-delay | Wait before starting (negative values start part-way through) | 200ms |
animation-iteration-count | Number of repetitions | 3, infinite |
animation-direction | Play order | normal, reverse, alternate |
animation-fill-mode | Which styles apply before and after the run | forwards keeps the final frame |
animation-play-state | Pause and resume | paused |
Transitions vs animations.
| Transition | Animation | |
|---|---|---|
| Trigger | A property value changes | Starts when the animation is applied |
| States | Two (from, to) | Any number of keyframes |
| Looping | No | Yes |
| Best for | Hover and focus feedback, toggling UI states | Loaders, attention cues, multi-step choreography |
Performance: animate transform and opacity. Recall the rendering pipeline from Chapter 1: style, layout, paint, composite. What it costs to animate a property depends on the earliest stage it invalidates:
Animating... Style Layout Paint Composite Cost
width, top, margin x x x x High: reflows the page on every frame
color, background x x x Medium: repaints on every frame
transform, opacity x x Low: the GPU moves existing layers
To move something, animate transform: translate(), not left or top. To fade it, animate opacity. will-change: transform hints that the element should get its own layer ahead of time, but every layer uses memory, so add it only where you have measured a problem.
Respect motion preferences. Some users get dizzy or nauseous from motion (vestibular disorders) and enable "reduce motion" in their operating system. Honour that setting:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important; /* effectively instant, but animation events still fire */
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Example: a button, a fade-in panel and a loader
/* Transition: hover and press feedback */
.button {
transition: transform 150ms ease-out, background-color 150ms ease-out;
}
.button:hover { background-color: oklch(55% 0.18 255); }
.button:active { transform: scale(0.97); } /* compositor-only: cheap */
/* Transition from display: none (discrete property) */
.panel {
transition: opacity 200ms, translate 200ms, display 200ms allow-discrete;
}
.panel[hidden] {
display: none;
opacity: 0;
translate: 0 8px;
}
@starting-style {
.panel:not([hidden]) { /* the "from" state when the panel appears */
opacity: 0;
translate: 0 8px;
}
}
/* Keyframe animation: infinite spinner */
@keyframes spin {
to { rotate: 360deg; } /* "from" defaults to the current value */
}
.spinner {
inline-size: 2rem;
aspect-ratio: 1;
border: 3px solid currentColor;
border-block-start-color: transparent; /* gap that makes the rotation visible */
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Keyframe animation: one-shot attention shake */
@keyframes shake {
0%, 100% { translate: 0; }
20%, 60% { translate: -6px; }
40%, 80% { translate: 6px; }
}
.field.error {
animation: shake 0.4s ease-in-out 1;
}
For animations controlled from JavaScript (pausing, reversing, chaining, reading progress), use the Web Animations API, covered in Chapter 6.
Key Takeaways
- Transforms change appearance without affecting layout. Functions apply from right to left.
- Transitions animate between two values when a property changes. Keyframe animations define multi-step timelines that can loop.
- Use
ease-outfor elements entering andease-infor elements leaving. Keep UI transitions short (about 100 to 300ms). - Animate
transformandopacityfor smooth 60fps motion. Avoid animating layout properties. @starting-styleandallow-discreteallow enter and exit animations to and fromdisplay: none.- Always provide a
prefers-reduced-motionalternative.
Practice
- (Beginner) Create a card that lifts on hover (moves 4px up and gains a stronger shadow) using a transition, and that does not move at all when reduced motion is enabled.
- (Intermediate) Build a three-dot "typing" indicator with a single
@keyframesrule and staggeredanimation-delayvalues. Then record a performance profile in DevTools and confirm that no layout work happens during the animation.- (Interview) "Why is animating
transform: translateX()smoother than animatingleft?" Hint: walk through the stages of the rendering pipeline that each property invalidates, and where the compositor thread runs.
<a id="23-layout-and-responsive-design"></a>
2.3 Layout and Responsive Design
This subchapter covers the modern layout systems and the techniques that make a single codebase adapt to every screen, from a small phone to a wide desktop monitor.
Flexbox Layout
For many years CSS had no real layout system. Developers built columns with tables, then with floats and clearfix hacks, and centring an element vertically was a running joke. Flexbox (the Flexible Box Layout module) was designed to distribute space among items along one dimension, a row or a column. It handles alignment, ordering and "fill the remaining space" with a few properties.
Analogy: books on a shelf. You choose whether they line up from the left or the right, whether gaps are spread evenly, whether some books may lean (shrink) or are allowed to spread out (grow), and whether a full shelf continues on the next one (wrap).
Container and items. display: flex turns an element into a flex container, and its direct children become flex items. Everything is described in terms of two axes:
flex-direction: row (default)
main start main end
cross +---------------------------------------------------------+
start | +--------+ +--------+ +--------+ |
| | | item 1 | | item 2 | | item 3 | free space |
| | +--------+ +--------+ +--------+ |
v +---------------------------------------------------------+
cross ------------------ main axis ------------------>
end justify-content works along the main axis
align-items works along the cross axis
With flex-direction: column, the axes swap: the main axis runs vertically, so justify-content aligns vertically and align-items aligns horizontally.
Container properties.
| Property | Values | Effect |
|---|---|---|
flex-direction | row, row-reverse, column, column-reverse | Direction of the main axis |
flex-wrap | nowrap (default), wrap | Whether items may move onto new lines |
flex-flow | row wrap | Shorthand for direction and wrap |
justify-content | flex-start, center, flex-end, space-between, space-around, space-evenly | Distributes free space along the main axis |
align-items | stretch (default), flex-start, center, flex-end, baseline | Aligns items along the cross axis within their line |
align-content | Same values as justify-content, plus stretch | Distributes lines along the cross axis (only with wrapping) |
gap | 1rem, 1rem 2rem | Space between items (row gap, column gap) |
Item properties.
| Property | Default | Effect |
|---|---|---|
flex-grow | 0 | How much of the positive free space the item takes, relative to the others |
flex-shrink | 1 | How much the item gives up when there isn't enough space |
flex-basis | auto | The starting size before growing or shrinking (auto means use width, or the content size) |
flex | 0 1 auto | Shorthand for grow, shrink and basis |
align-self | auto | Overrides align-items for this item |
order | 0 | Changes the visual order (not the DOM or tab order) |
The flex shorthand has keywords that are worth memorising:
| Shorthand | Expands to | Meaning |
|---|---|---|
flex: initial | 0 1 auto | Content-sized, can shrink but not grow |
flex: auto | 1 1 auto | Content-sized, then shares the free space |
flex: 1 | 1 1 0% | Ignores content size and shares all the space equally |
flex: none | 0 0 auto | Rigid: exactly its content or width |
How free space is shared. Suppose a container is 600px wide and holds three items with a flex-basis of 100px each, so 300px of free space remains. With flex-grow values of 1, 1 and 2, the free space is split into 1 + 1 + 2 = 4 shares of 75px each. The items end up 175px, 175px and 250px wide. flex-grow: 2 doesn't make an item twice as wide. It gives it twice as much of the leftover space.
Two common pitfalls.
- Items refusing to shrink. Flex items default to
min-width: auto, which means "never smaller than my content". A long URL or a wide<pre>block will overflow the container. The fix ismin-width: 0(oroverflow: hidden) on the item. orderbreaks accessibility.orderchanges only the visual position. Keyboard focus and screen readers still follow the DOM order, so users see one sequence and hear or tab through another. Reorder the HTML instead whenever the order carries meaning.
Example: three common Flexbox patterns
/* 1. Perfect centring, horizontally and vertically */
.hero {
display: flex;
justify-content: center; /* main axis */
align-items: center; /* cross axis */
min-height: 60dvh;
}
/* 2. Navigation bar: logo on the left, links on the right */
.navbar {
display: flex;
align-items: center;
gap: 1rem;
}
.navbar .logo {
margin-inline-end: auto; /* an auto margin absorbs all free space and pushes the rest right */
}
/* 3. Wrapping card row: each card is at least 250px and the cards share the extra space */
.cards {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
}
.cards > .card {
flex: 1 1 250px; /* grow, shrink, start at 250px */
}
/* A media object: fixed-size image next to text that fills the rest */
.media {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.media img { flex: none; } /* never squash the image */
.media-body { flex: 1; min-width: 0; } /* fill the rest and allow long words to wrap */
<nav class="navbar">
<a class="logo" href="/">Acme</a>
<a href="/docs">Docs</a>
<a href="/pricing">Pricing</a>
<a href="/login">Log in</a>
</nav>
Key Takeaways
- Flexbox lays out items along one axis.
justify-contentworks on the main axis andalign-itemson the cross axis. flex: grow shrink basis. Growing shares the leftover space.flex: 1shares all of the space equally.- Use
gapfor spacing andmargin-*: autoto push items apart. - Add
min-width: 0to flex items that must shrink below the width of their content. orderchanges only the visual order. Keep the DOM order meaningful for keyboard and screen reader users.
Practice
- (Beginner) Build a footer with three groups of links that sit side by side on wide screens and wrap onto separate lines on narrow screens, using only Flexbox and
gap.- (Intermediate) In a 900px container, three items have
flex: 1 1 200px,flex: 2 1 200pxandflex: 0 0 300px. Calculate each final width, then verify your answer with the DevTools flex inspector.- (Interview) "A flex item containing a long unbreakable string overflows its container even though
flex-shrinkis 1. Why?" Hint: look up the automatic minimum size of flex items and whatmin-width: autoresolves to.
CSS Grid Layout
Flexbox works on one line at a time: items on different rows don't line up with each other. Page layouts, dashboards and galleries need two dimensions, rows and columns that align across the whole area. CSS Grid provides exactly that: you define the tracks on the container, then place items into cells or areas.
Analogy: Flexbox is a bookshelf, where each shelf is filled independently. Grid is a spreadsheet, where every cell in a column has the same width and every cell in a row has the same height.
Terminology.
line 1 line 2 line 3 line 4
| | | |
line 1 --+----------+---------------+----------+--
| cell | | | <- row track 1
line 2 --+----------+---------------+----------+--
| | area spanning 2x2 | <- row track 2
line 3 --+----------+ + +--
| | | <- row track 3
line 4 --+----------+---------------+----------+--
col track 1 col track 2 col track 3
Line: the dividing lines, numbered from 1 (and from -1 counting from the end)
Track: a row or a column between two lines
Cell: the intersection of one row and one column
Area: a rectangle of one or more cells
Gap: the gutter between tracks
Defining tracks.
| Feature | Example | Meaning |
|---|---|---|
| Fixed and flexible sizes | grid-template-columns: 200px 1fr 2fr | fr is a fraction of the remaining space after fixed tracks |
repeat() | repeat(4, 1fr) | Four equal columns |
minmax(min, max) | minmax(200px, 1fr) | A track that is never smaller than 200px and grows to fill space |
auto-fill | repeat(auto-fill, minmax(200px, 1fr)) | Creates as many columns as fit, keeping empty tracks |
auto-fit | repeat(auto-fit, minmax(200px, 1fr)) | Same, but collapses empty tracks so the items stretch |
| Content keywords | min-content, max-content, fit-content(300px) | Size tracks from their content |
gap | gap: 1rem 2rem | Row gap, column gap |
Placing items. Items fill cells automatically in order, unless you place them by line numbers or by named areas:
.item {
grid-column: 1 / 3; /* from column line 1 to line 3 (spans 2 columns) */
grid-row: 2 / span 2; /* start at row line 2 and span 2 rows */
}
.full-width {
grid-column: 1 / -1; /* -1 = the last line: span the whole grid */
}
Named areas let you draw the layout as ASCII art directly in the CSS. Each string is a row, each word is a cell, and . is an empty cell. Every area must form a rectangle.
The implicit grid. If there are more items than the template defines cells for, Grid creates extra tracks automatically. Control their size with grid-auto-rows (for example grid-auto-rows: minmax(100px, auto)) and the fill direction with grid-auto-flow (row, column, or dense to backfill holes).
Alignment. Grid uses the same alignment vocabulary as Flexbox:
| Property | Aligns |
|---|---|
justify-items / align-items | Each item inside its cell (inline and block axis) |
place-items | Shorthand for both: place-items: center |
justify-content / align-content | The whole grid inside the container, when the tracks are smaller than the container |
justify-self / align-self | One item inside its cell |
Subgrid. A nested grid normally defines its own tracks, unrelated to its parent's. With grid-template-rows: subgrid (or columns), a grid item reuses its parent's tracks, so the titles, text and buttons inside a row of cards align with each other even when their content lengths differ.
Example: a page layout with named areas
.page {
display: grid;
min-height: 100dvh;
grid-template-columns: 240px 1fr;
grid-template-rows: auto 1fr auto; /* header and footer fit their content; main fills the rest */
grid-template-areas:
"header header"
"sidebar main"
"footer footer";
gap: 1rem;
}
.page > header { grid-area: header; }
.page > nav { grid-area: sidebar; }
.page > main { grid-area: main; }
.page > footer { grid-area: footer; }
/* On narrow screens, redraw the layout as a single column */
@media (width < 48rem) {
.page {
grid-template-columns: 1fr;
grid-template-areas:
"header"
"main"
"sidebar"
"footer";
}
}
Wide screens Narrow screens
+-------------------------------+ +-------------+
| header | | header |
+---------+---------------------+ +-------------+
| sidebar | main | | main |
| | | +-------------+
+---------+---------------------+ | sidebar |
| footer | +-------------+
+-------------------------------+ | footer |
+-------------+
Example: a responsive gallery with no media queries, and aligned card contents
.gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
/* As many columns as fit, each at least 220px, and never wider than the container
(min(100%, ...) prevents overflow on very small screens) */
gap: 1rem;
}
.card {
display: grid;
grid-row: span 3; /* occupy 3 of the parent's implicit rows */
grid-template-rows: subgrid; /* image, text and button rows line up across all cards */
}
Flexbox or Grid?
| Flexbox | Grid | |
|---|---|---|
| Dimensions | One (a row or a column) | Two (rows and columns) |
| Approach | Content-out: item sizes drive the layout | Layout-in: the container defines the tracks |
| Alignment across rows | No, each line is independent | Yes |
| Overlapping items | Hard | Easy (place items in the same cells) |
| Typical uses | Navigation bars, button groups, centring, media objects | Page structure, galleries, dashboards, forms with aligned labels |
They work well together: a Grid page layout whose cells contain Flexbox components is the normal pattern.
Key Takeaways
- Grid lays out items in two dimensions with explicit row and column tracks.
frdivides the remaining space.minmax(),repeat(),auto-fitandauto-fillcreate responsive tracks.- Place items by line numbers (
1 / -1,span 2) or by namedgrid-template-areas. auto-fitcollapses empty tracks andauto-fillkeeps them.subgridaligns nested content to the parent's tracks.- Use Grid for two-dimensional layout and Flexbox for one-dimensional alignment, and combine them freely.
Practice
- (Beginner) Recreate a classic "holy grail" layout (header, footer, main content between two sidebars) with
grid-template-areas, collapsing to a single column on small screens.- (Intermediate) Build a photo gallery where some photos span two columns or two rows, and use
grid-auto-flow: denseto fill the gaps. Then explain what effectdensehas on keyboard navigation order.- (Interview) "What is the difference between
repeat(auto-fill, minmax(200px, 1fr))andrepeat(auto-fit, minmax(200px, 1fr))?" Hint: try both with only two items in a very wide container and inspect the empty tracks in the DevTools grid overlay.
Media Queries and Breakpoints
A layout that looks right on a 1440px laptop is unusable on a 375px phone: the text is tiny, the columns are squeezed and the buttons are hard to tap. Media queries apply CSS only when conditions about the device or the user's preferences are true. This is the core mechanism of responsive web design, a term Ethan Marcotte coined in 2010 to describe fluid grids, flexible images and media queries working together.
Prerequisite: the viewport meta tag. Without <meta name="viewport" content="width=device-width, initial-scale=1"> (see Document Structure and Metadata), a phone reports a viewport of about 980px and your small-screen queries never match.
Syntax.
@media screen and (min-width: 48rem) and (orientation: landscape) { ... }
\____/ \_______________/ \_______________________/
media type media feature media feature
(all, screen, print)
| Operator | Example | Meaning |
|---|---|---|
and | (min-width: 40rem) and (hover: hover) | Both conditions must be true |
, (or) | (orientation: portrait), (max-width: 30rem) | Either condition |
not | not print | Negates the whole query |
| Range syntax | (40rem <= width < 64rem) | Modern, readable alternative to min-width/max-width |
Useful media features.
| Feature | Example | Use |
|---|---|---|
width | (width >= 48rem) | The main responsive breakpoints |
orientation | (orientation: landscape) | Layouts for rotated devices |
hover / pointer | (hover: hover) and (pointer: fine) | Enable hover effects only on mouse devices. Use bigger targets for pointer: coarse (touch). |
resolution | (resolution >= 2dppx) | High-density screens |
prefers-color-scheme | (prefers-color-scheme: dark) | Automatic dark mode |
prefers-reduced-motion | (prefers-reduced-motion: reduce) | Turn off non-essential animation |
prefers-contrast | (prefers-contrast: more) | Stronger borders and contrast |
forced-colors | (forced-colors: active) | Windows high-contrast mode |
print (type) | @media print | Hide navigation and show link URLs on paper |
Choosing breakpoints. Don't design for specific devices: there are thousands, and new sizes appear every year. Let the content decide. Resize the browser slowly, and add a breakpoint where the layout starts to look wrong (lines too long, cards too narrow). Many projects end up with a handful of breakpoints similar to these:
| Name | Approximate width | Typical layout |
|---|---|---|
| Small | < 40rem (640px) | Single column |
| Medium | 40 to 64rem (640 to 1024px) | Two columns, collapsible sidebar |
| Large | 64 to 80rem (1024 to 1280px) | Full multi-column layout |
| Extra large | > 80rem (1280px) | Wider gutters, constrained content width |
Using em or rem for breakpoints makes the layout respond to the user's zoom and default font size as well as to the screen width.
Container queries. Media queries respond to the viewport, but a reusable component cares about the space it is given. The same card might sit in a wide main column or in a narrow sidebar. Container queries let a component adapt to the size of its container:
.card-slot {
container-type: inline-size; /* make this element a query container (width only) */
container-name: card; /* optional name, to target a specific ancestor */
}
.card { display: grid; gap: 1rem; }
@container card (inline-size > 30rem) {
.card {
grid-template-columns: 12rem 1fr; /* image beside text when the slot is wide */
}
}
| Media query | Container query | |
|---|---|---|
| Responds to | Viewport and device or user preferences | The size (or style) of an ancestor container |
| Best for | Page-level layout and user preferences | Reusable components |
| Declared with | @media | container-type on the ancestor, @container in the rule |
Example: a responsive stylesheet with preferences and print styles
/* Base: small screens, no query needed (mobile-first, see the next topic) */
.layout { display: grid; gap: 1rem; padding-inline: 1rem; }
@media (width >= 40rem) {
.layout { grid-template-columns: 1fr 1fr; }
}
@media (width >= 64rem) {
.layout { grid-template-columns: 16rem 1fr 1fr; padding-inline: 2rem; }
}
/* Hover effects only where a real hover exists (not on touch screens) */
@media (hover: hover) {
.card:hover { box-shadow: 0 8px 24px rgb(0 0 0 / 0.15); }
}
/* Automatic dark theme */
@media (prefers-color-scheme: dark) {
:root { --bg: #0f1115; --text: #e6e6e6; }
}
/* Paper-friendly output */
@media print {
nav, .ads, video { display: none; }
a[href^="http"]::after { content: " (" attr(href) ")"; } /* print the URL after each external link */
body { font-size: 12pt; color: black; background: white; }
}
Queries can also be used in HTML (<source media="..."> in <picture>, <link rel="stylesheet" media="print">) and in JavaScript with window.matchMedia('(width >= 64rem)'), which returns an object whose matches property you can check and whose change event you can listen to.
Key Takeaways
- Media queries apply CSS conditionally, based on viewport size, device capabilities and user preferences.
- The viewport meta tag is required for width-based queries to work on phones.
- Choose breakpoints where the content breaks, not at device sizes, and express them in
remorem. - Use preference queries (
prefers-color-scheme,prefers-reduced-motion,hover,pointer) as well as width. - Container queries make components respond to their own available space rather than to the viewport.
Practice
- (Beginner) Add a print stylesheet to an article page that hides the navigation and footer, removes background colours, and prints each external link's URL after the link text.
- (Intermediate) Build a product card that shows a stacked layout in a narrow container and a side-by-side layout in a wide one using container queries. Place the same card in a sidebar and in the main column to prove it adapts.
- (Interview) "When would you use a container query instead of a media query?" Hint: think about a design-system component that is reused in many layouts and does not know where it will be placed.
Mobile-First Design
More than half of all web traffic now comes from phones. Phones also have the hardest constraints: small screens, touch input, variable networks and less CPU power. Mobile-first design means designing and writing CSS for the smallest, most constrained context first, then progressively enhancing the layout as more space and capability become available. It applies the progressive enhancement principle from Chapter 1 to layout.
Analogy: packing for a trip with only a carry-on bag forces you to decide what really matters. Moving up to a bigger suitcase later is easy. Squeezing a fully packed suitcase into a carry-on is not.
Mobile-first vs desktop-first CSS.
| Mobile-first | Desktop-first | |
|---|---|---|
| Base styles (no query) | Small screen | Large screen |
| Queries | min-width (width >=): add layout as space grows | max-width (width <): undo layout as space shrinks |
| Complexity on small devices | Lowest: simple single-column styles | Highest: full layout plus overrides |
| Design pressure | Prioritise content first | Tends to cram desktop content into small screens |
| Risk | Large screens looking sparse if neglected | Mobile becoming an afterthought |
/* Desktop-first: write three columns, then undo them */
.grid { display: grid; grid-template-columns: repeat(3, 1fr); }
@media (width < 64rem) { .grid { grid-template-columns: repeat(2, 1fr); } }
@media (width < 40rem) { .grid { grid-template-columns: 1fr; } }
/* Mobile-first: start simple (a single column is the default for grid), then add */
.grid { display: grid; }
@media (width >= 40rem) { .grid { grid-template-columns: repeat(2, 1fr); } }
@media (width >= 64rem) { .grid { grid-template-columns: repeat(3, 1fr); } }
Principles beyond the CSS.
- Content prioritisation. A small screen forces you to decide what really matters. The question is "what can we add on larger screens?", not "what do we hide on mobile?".
- Touch targets. Fingers are far less precise than mouse pointers. WCAG 2.2 requires interactive targets of at least 24 x 24 CSS pixels (level AA), and platform guidelines recommend 44 to 48 pixels. Space targets apart so that taps don't land on the wrong one.
- No hover dependence. Touch screens have no hover. Any information or menu that appears on hover needs a tap or focus equivalent.
- Performance budget. Serve responsive images (
srcset), lazy-load content below the fold, keep JavaScript bundles small, and test on mid-range devices over throttled networks. Search engines notice too: Google indexes the mobile version of each page (mobile-first indexing). - Readable text without zooming. Use a base font size of at least 16px. iOS Safari also zooms in on any input with a smaller font size when it receives focus.
- Device realities. Mobile browser toolbars change the viewport height as the user scrolls (
100vhdoesn't fit; usedvh), and notched screens need safe-area padding.
Example: a mobile-first article layout
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<!-- viewport-fit=cover lets the page extend under the notch; env() then supplies safe margins -->
/* 1. Base: phones. A single column, comfortable text, large tap targets */
body {
font-size: 1rem; /* 16px: no forced zoom on input focus */
line-height: 1.6;
padding-inline: max(1rem, env(safe-area-inset-left)); /* respect notches and rounded corners */
}
.article-layout {
display: grid;
gap: 1.5rem;
}
.button {
min-block-size: 44px; /* comfortable touch target */
padding-inline: 1.25rem;
}
.hero { min-block-size: 70dvh; } /* dynamic viewport height: adjusts as toolbars appear */
/* 2. Tablets: more breathing room */
@media (width >= 40rem) {
body { padding-inline: 2rem; }
.article-layout { gap: 2rem; }
}
/* 3. Desktops: a sticky sidebar appears */
@media (width >= 64rem) {
.article-layout {
grid-template-columns: minmax(0, 1fr) 16rem; /* article plus sidebar */
grid-template-areas: "article toc"; /* the toc comes first in the DOM but sits on the right */
max-inline-size: 72rem;
margin-inline: auto;
}
.article-layout > article { grid-area: article; }
.toc {
grid-area: toc;
position: sticky;
top: 2rem;
align-self: start; /* otherwise the grid item stretches and can't stick */
}
}
/* 4. Hover-only enhancements for precise pointers */
@media (hover: hover) and (pointer: fine) {
.button:hover { filter: brightness(1.1); }
}
Phone (< 40rem) Tablet (40-64rem) Desktop (>= 64rem)
+-------------+ +-------------------+ +----------------------------+
| header | | header | | header |
+-------------+ +-------------------+ +------------------+---------+
| toc | | toc | | | toc |
+-------------+ +-------------------+ | article | (sticky)|
| | | | | | |
| article | | article | | | |
| | | | +------------------+---------+
+-------------+ +-------------------+
Test with DevTools device emulation during development (Chapter 1), but always confirm on real devices: emulation does not reproduce real touch precision, CPU speed or browser toolbar behaviour.
Key Takeaways
- Write base styles for small screens and add complexity with
min-width(width >=) queries. - Mobile-first is a content strategy as well as a CSS technique: decide what matters most first.
- Make touch targets at least 24 x 24px (44 to 48px recommended) and never rely on hover alone.
- Use
dvhfor full-height sections,env(safe-area-inset-*)for notches, and a base font of at least 16px. - Treat performance as part of the design: responsive images, lazy loading and small bundles.
Practice
- (Beginner) Take a desktop-first stylesheet with three
max-widthqueries and rewrite it mobile-first withmin-widthqueries, producing the same result at every width.- (Intermediate) Audit a page on a real phone or in Lighthouse's mobile mode. List every touch target smaller than 24 x 24px, every hover-only interaction and every horizontal scroll, and fix them.
- (Interview) "Your team's site was designed desktop-first and performs poorly on mobile. How would you plan a migration to mobile-first without a full rewrite?" Hint: think about which metrics to measure first, which templates get the most mobile traffic, and how to refactor queries component by component.
CSS Variables and Custom Properties
A design system has a brand colour, a spacing scale and border radii that are used hundreds of times. Hard-coding #1a73e8 everywhere makes a rebrand a search-and-replace exercise, and dark mode would need a duplicate of every rule. Custom properties (usually called CSS variables) let you name a value once and reference it everywhere. Unlike preprocessor variables, they are live: they take part in the cascade, are inherited, and can change at runtime through media queries, classes or JavaScript.
Syntax.
:root {
--brand: #1a73e8; /* define: the name starts with two dashes and is case-sensitive */
--space-m: 1rem;
}
.button {
background: var(--brand); /* use */
padding: var(--space-m) calc(var(--space-m) * 2);
color: var(--button-text, white); /* a fallback, used if --button-text is not defined */
}
Scoping and inheritance. A custom property is an ordinary inherited property. Defining it on :root makes it global. Redefining it on an element changes it for that element's whole subtree. This is what makes theming and component variants easy:
:root --brand: blue
+-- header uses var(--brand) -> blue
+-- .danger-zone --brand: red (redefined here)
+-- button uses var(--brand) -> red
Custom properties vs preprocessor variables.
Sass variable ($brand) | Custom property (--brand) | |
|---|---|---|
| Resolved | At build time, then disappears | At runtime, in the browser |
| Cascade and inheritance | No | Yes, and it can be scoped to any element |
| Changes in media queries or with classes | No (you must duplicate the rules) | Yes (redefine the variable only) |
| Readable and writable from JavaScript | No | Yes (style.setProperty('--x', ...)) |
| Can hold any token sequence | n/a | Yes (it is validated only when used) |
Invalid at computed-value time. The browser cannot check that a variable's value fits a property until it substitutes the value. If the result is invalid, the declaration does not fall back to an earlier declaration in the cascade. The property becomes unset instead:
:root { --size: red; } /* a nonsense value for a length, but defining it is legal */
p {
font-size: 20px;
font-size: var(--size); /* invalid after substitution -> unset -> inherited size, NOT 20px */
}
Registered properties with @property. By default, custom properties are untyped strings, so the browser cannot interpolate them in animations. Registering a property gives it a type, an initial value and inheritance rules. A typed property can be transitioned, and invalid values fall back to the initial value:
@property --angle {
syntax: "<angle>"; /* the type: <length>, <color>, <number>, <percentage>, ... */
inherits: false;
initial-value: 0deg;
}
.spinner-ring {
background: conic-gradient(from var(--angle), #1a73e8, transparent);
animation: rotate-gradient 2s linear infinite;
}
@keyframes rotate-gradient {
to { --angle: 360deg; } /* animating a gradient: only possible because --angle is typed */
}
Example: light and dark theming with design tokens
:root {
color-scheme: light dark; /* form controls and scrollbars follow the theme */
/* Primitive tokens: raw values */
--blue-500: oklch(58% 0.18 255);
--gray-50: oklch(98% 0.005 255);
--gray-900: oklch(20% 0.02 255);
/* Semantic tokens: what components use */
--color-bg: light-dark(var(--gray-50), var(--gray-900)); /* chooses based on color-scheme */
--color-text: light-dark(var(--gray-900), var(--gray-50));
--color-accent: var(--blue-500);
--radius: 8px;
--space: 1rem;
}
/* Manual override from a theme switcher: <html data-theme="dark"> */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
body {
background: var(--color-bg);
color: var(--color-text);
}
/* Component API: the button exposes variables that callers can override */
.button {
--button-bg: var(--color-accent);
--button-pad: calc(var(--space) * 0.75);
background: var(--button-bg);
padding: var(--button-pad) calc(var(--button-pad) * 2);
border-radius: var(--radius);
}
.button.danger { --button-bg: oklch(55% 0.2 25); } /* a variant changes only the variable */
.button.compact { --button-pad: calc(var(--space) * 0.4); }
/* Responsive tokens: one change applies everywhere */
@media (width >= 64rem) {
:root { --space: 1.25rem; }
}
// Reading and writing custom properties from JavaScript (see Chapter 6)
const root = document.documentElement;
root.style.setProperty('--color-accent', '#e8590c'); // runtime rebrand
getComputedStyle(root).getPropertyValue('--radius').trim(); // "8px"
// Pass pointer coordinates to CSS for a spotlight effect
card.addEventListener('pointermove', (e) => {
card.style.setProperty('--x', `${e.offsetX}px`);
card.style.setProperty('--y', `${e.offsetY}px`);
});
Key Takeaways
- Custom properties (
--name,var(--name, fallback)) are live values that cascade and inherit. - Define global tokens on
:rootand override them on any subtree for themes and variants. - Change variables in media queries and with classes instead of duplicating whole rules.
- A variable that produces an invalid value makes the property
unset. It does not fall back to an earlier declaration. @propertyadds a type, which makes custom properties animatable and safer.- Combine semantic tokens with
light-dark()andcolor-schemefor theming with little code.
Practice
- (Beginner) Refactor a stylesheet that repeats three colours and four spacing values into custom properties on
:root, then add a dark theme by redefining only the colour variables.- (Intermediate) Build a button component whose colour, size and radius are controlled entirely by variables such as
--btn-bg,--btn-sizeand--btn-radius. Create three variants without repeating any property declarations.- (Interview) "Why can't you use a CSS custom property inside a media query condition, such as
@media (width >= var(--bp))?" Hint: custom properties belong to elements and are resolved during the cascade, while media queries are evaluated against the whole document before any element is styled.
Preprocessors and Utility Frameworks
Plain CSS used to lack many features that large projects need: variables, nesting, reusable blocks, modules and loops. As stylesheets grew to thousands of lines, teams also ran into naming collisions and "append-only" CSS that nobody dared to delete. Two families of tools grew up around these problems. Preprocessors extend the language and compile it back to CSS. Frameworks provide ready-made styles, either as complete components or as small utility classes.
Preprocessors. A preprocessor is a language that compiles to CSS at build time:
styles.scss --(sass compiler)--> styles.css --(PostCSS: autoprefixer, minify)--> dist.css
variables, nesting, plain CSS shipped to
mixins, modules the browser
| Tool | Notes |
|---|---|
| Sass (SCSS syntax) | The most widely used. The reference implementation is Dart Sass (the sass npm package). LibSass and node-sass are deprecated. |
| Less | JavaScript-based. It was used by Bootstrap 3. |
| Stylus | Flexible, optional-punctuation syntax. Rarely chosen for new projects. |
| PostCSS | Not a language. It transforms CSS through plugins: Autoprefixer adds vendor prefixes based on Browserslist, postcss-preset-env compiles future CSS syntax, cssnano minifies. |
Main Sass features:
| Feature | Syntax | Purpose |
|---|---|---|
| Variables | $gap: 1rem; | Compile-time constants |
| Nesting | .card { &:hover { } .title { } } | Mirror the HTML structure. & refers to the parent selector. |
| Partials and modules | @use 'tokens'; tokens.$brand | Split code into files with namespaces. (@import and global functions such as map-get() are deprecated in Dart Sass in favour of modules such as sass:map.) |
| Mixins | @mixin focus-ring { } / @include focus-ring; | Reusable blocks of declarations, with parameters |
| Functions | @function rem($px) { @return math.div($px, 16) * 1rem; } | Computed values |
| Control flow | @each, @for, @if | Generate repetitive classes |
| Extend | @extend %placeholder; | Share a selector's styles (can produce bloated, surprising output) |
Example: SCSS and its compiled output
// _tokens.scss
$breakpoints: (md: 40rem, lg: 64rem);
$brand: #1a73e8;
// card.scss
@use 'sass:color';
@use 'sass:map';
@use 'tokens' as t;
@mixin above($bp) { // a reusable media-query mixin
@media (width >= #{map.get(t.$breakpoints, $bp)}) { @content; }
}
.card {
padding: 1rem;
border: 1px solid color.adjust(t.$brand, $lightness: 35%);
&__title { font-weight: 700; } // & = .card -> .card__title (BEM naming)
&:hover { border-color: t.$brand; }
@include above(md) { padding: 2rem; }
}
@each $name, $color in (info: #1a73e8, warn: #e8590c) {
.badge-#{$name} { background: $color; } // generates .badge-info and .badge-warn
}
/* Compiled CSS */
.card { padding: 1rem; border: 1px solid #bcd6f8; } /* brand blue, 35% lighter */
.card__title { font-weight: 700; }
.card:hover { border-color: #1a73e8; }
@media (width >= 40rem) { .card { padding: 2rem; } }
.badge-info { background: #1a73e8; }
.badge-warn { background: #e8590c; }
Native CSS has caught up. Many reasons to use a preprocessor are now built into browsers: custom properties (2.3), native nesting (supported in all major browsers since 2023), color-mix(), @layer, calc() and container queries. Preprocessors remain useful for loops, mixins and build-time logic, but small and medium projects can often use plain CSS with PostCSS.
/* Native CSS nesting: no build step needed */
.card {
padding: 1rem;
& .title { font-weight: 700; }
&:hover { border-color: var(--brand); }
@media (width >= 40rem) { padding: 2rem; }
}
Naming and scoping. Global class names collide. Several approaches address this:
| Approach | Idea | Example |
|---|---|---|
| BEM (Block, Element, Modifier) | A naming convention with flat, low specificity | .card, .card__title, .card--featured |
| CSS Modules | The build tool renames classes to be unique per file | .title becomes .Card_title__x7f2 |
| CSS-in-JS | Styles written in JavaScript next to components | styled-components, Emotion (Chapter 14) |
Cascade layers and @scope | Native control of priority and reach | @layer components { ... } |
Component frameworks: Bootstrap. Bootstrap provides a responsive 12-column grid, ready-made components (navbars, modals, cards, forms) and JavaScript behaviour. Version 5 removed the jQuery dependency and is customised through Sass variables and CSS custom properties. It is fast for prototypes and internal tools, but sites built with it tend to look alike, and overriding its design takes effort.
<!-- Bootstrap: semantic component classes plus grid classes -->
<div class="container">
<div class="row">
<div class="col-12 col-md-6"> <!-- full width on mobile, half from the md breakpoint -->
<div class="card">
<div class="card-body">
<h5 class="card-title">Pro plan</h5>
<a href="/buy" class="btn btn-primary">Buy</a>
</div>
</div>
</div>
</div>
</div>
Utility-first frameworks: Tailwind CSS. Instead of semantic component classes, utility-first CSS provides a large set of small, single-purpose classes (p-4 sets padding, text-center centres text) that you compose directly in the markup. Tailwind's build step scans your source files and generates only the classes you actually use, so the final CSS stays small regardless of the size of the framework. Since version 4 (2025), configuration is written in CSS itself:
/* app.css (Tailwind v4) */
@import "tailwindcss";
@theme {
--color-brand: oklch(58% 0.18 255); /* generates bg-brand, text-brand, border-brand, ... */
--font-display: "Inter", sans-serif; /* generates font-display */
}
<!-- Tailwind: styling by composing utilities; sm:, md:, hover:, dark: are variants -->
<article class="rounded-lg border border-gray-200 p-4 md:p-8 shadow-sm
hover:shadow-md transition-shadow dark:bg-gray-900 dark:border-gray-700">
<h2 class="font-display text-xl font-bold text-gray-900 dark:text-white">Pro plan</h2>
<p class="mt-2 text-gray-600">Everything in Free, plus priority support.</p>
<a href="/buy"
class="mt-4 inline-block rounded-md bg-brand px-4 py-2 text-white
hover:brightness-110 focus-visible:outline-2 focus-visible:outline-offset-2">
Buy
</a>
</article>
Choosing an approach.
| Hand-written CSS | Sass + BEM | Bootstrap | Tailwind | |
|---|---|---|---|---|
| Learning curve | CSS only | Low extra | Learn the component API | Learn the utility vocabulary |
| Design freedom | Total | Total | Limited without customisation | High, within a design-token system |
| Speed to prototype | Slow | Medium | Very fast | Fast |
| CSS size growth | Grows with features | Grows with features | Large unless unused parts are removed | Stays small (only the classes used) |
| HTML readability | Clean | Clean | Moderate | Long class lists |
| Typical fit | Small sites, full control | Long-lived custom designs | Admin panels, prototypes | Component-based apps (React, Vue) |
The common criticisms of utility-first CSS are long class attributes and a mix of styling and structure. Its defenders reply that in component-based apps the component is the abstraction, so the styling lives in one place anyway, and that a constrained token system keeps designs consistent.
Key Takeaways
- Preprocessors such as Sass add variables, nesting, mixins, modules and loops, then compile to plain CSS. Use
@use, not the deprecated@import. - PostCSS transforms CSS with plugins. Autoprefixer handles vendor prefixes based on Browserslist.
- Native CSS now covers nesting, variables, layers and colour functions, which reduces the need for preprocessors.
- BEM, CSS Modules and cascade layers address naming collisions and specificity at scale.
- Bootstrap provides complete components quickly. Tailwind provides composable utilities and generates only the CSS you use.
Practice
- (Beginner) Rewrite a 50-line plain CSS file using Sass partials, a variables module and nesting. Compile it with
npx sassand compare the output with the original.- (Intermediate) Build the same pricing card three times: with BEM classes in plain CSS using native nesting, with Bootstrap, and with Tailwind. Compare the size of the HTML, the size of the CSS shipped, and how long it takes to change the accent colour across all of them.
- (Interview) "Your team is debating Tailwind versus CSS Modules for a new React application. What trade-offs would you discuss?" Hint: consider bundle size, consistency through design tokens, readability of the markup, onboarding, how styles get deleted when a component is removed, and how dynamic class names interact with the build-time class scanner.
<a id="3-javascript-language-core"></a>