← Tech Guides

Tech Guide

HTML & CSS

Modern markup and styling — from semantic structure to scroll-driven animations

2026
Baseline
De Stijl
Reference
01

Quick Reference

The most-needed HTML elements, CSS selectors, and modern properties at a glance.

Essential HTML5 Elements

Semantic elements give your document meaning beyond visual presentation. Screen readers, search engines, and browser features all rely on correct element choice.

Element Purpose Example
<header> Introductory content or navigational links for its nearest sectioning ancestor <header><h1>Site Title</h1><nav>...</nav></header>
<nav> Section containing navigation links (main nav, breadcrumbs, TOC) <nav aria-label="Main"><a href="/">Home</a></nav>
<main> Dominant content of the document body, unique per page <main id="content">...</main>
<article> Self-contained composition — blog post, news story, widget <article><h2>Post Title</h2><p>...</p></article>
<section> Thematic grouping of content, typically with a heading <section id="faq"><h2>FAQ</h2>...</section>
<aside> Content tangentially related to surrounding content (sidebars, pull quotes) <aside><h3>Related Articles</h3>...</aside>
<footer> Footer for its nearest sectioning ancestor (copyright, links, contact) <footer><p>&copy; 2026</p></footer>
<figure> / <figcaption> Self-contained content (image, diagram, code listing) with optional caption <figure><img src="chart.png" alt="..."><figcaption>Fig 1</figcaption></figure>
<details> / <summary> Disclosure widget — content hidden until user expands it <details><summary>More info</summary><p>...</p></details>
<dialog> Modal or non-modal dialog box with built-in open/close API <dialog id="d"><p>Confirm?</p></dialog>
<template> Inert HTML fragment — not rendered until cloned via JavaScript <template id="row"><tr><td></td></tr></template>
<search> Container for search or filtering controls New <search><form role="search">...</form></search>
<time> Machine-readable date/time with human-readable text content <time datetime="2026-02-19">Feb 19, 2026</time>
<mark> Highlighted text — search results, key phrases <p>Found: <mark>CSS Grid</mark> in 3 results</p>
<picture> / <source> Art-direction responsive images with media-query-based source selection <picture><source srcset="wide.webp" media="(min-width:800px)"><img src="narrow.jpg" alt="..."></picture>

CSS Selector Quick Reference

Modern CSS selectors dramatically reduce the need for utility classes and JavaScript. These selectors have shipped in all major browsers.

Selector Syntax What It Selects
:is() :is(h1, h2, h3) Matches any element in the list. Takes the highest specificity of its arguments.
:where() :where(.card, .panel) .title Same matching as :is() but with zero specificity. Ideal for defaults.
:has() .card:has(img) Parent/relational selector — matches elements that contain a matching descendant.
:not() :not(.hidden, .collapsed) Matches elements that do NOT match any of the selectors in the list.
:nth-child(of) :nth-child(odd of .visible) Filtered nth-child — only counts elements matching the of selector.
::backdrop dialog::backdrop The overlay behind a top-layer element (dialog, fullscreen).
::marker li::marker The bullet or number of a list item — color, font, content customizable.
::placeholder input::placeholder The placeholder text inside form inputs.
[attr^=val] [href^="https://"] Attribute starts-with selector. Also: $= (ends), *= (contains).

Modern CSS Properties

Properties and at-rules that have reached Baseline availability across all major browsers. These are production-ready today.

container-type
container-type: inline-size;
aspect-ratio
aspect-ratio: 16 / 9;
accent-color
accent-color: #D40920;
text-wrap
text-wrap: balance;
color-mix()
color-mix(in srgb, red 30%, blue);
scroll-snap-type
scroll-snap-type: x mandatory;
content-visibility
content-visibility: auto;
@layer
@layer base, components, utilities;
@scope
@scope (.card) { h2 { ... } }
@property
@property --hue { syntax: "<angle>"; }
animation-timeline
animation-timeline: scroll();
view-transition-name
view-transition-name: hero;
02

HTML5 Semantics

Meaningful markup that browsers, assistive technologies, and search engines understand.

Document Outline

A well-structured HTML5 page uses landmark elements to create a clear hierarchy. Every page should follow this skeleton to maximize accessibility and SEO.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Page Title — Site Name</title>
    <meta name="description" content="Concise page description for SEO">
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <header>
        <a href="/" aria-label="Home">Logo</a>
        <nav aria-label="Main navigation">
            <ul>
                <li><a href="/about">About</a></li>
                <li><a href="/work">Work</a></li>
                <li><a href="/contact">Contact</a></li>
            </ul>
        </nav>
    </header>

    <main id="content">
        <article>
            <header>
                <h1>Article Title</h1>
                <time datetime="2026-02-19">February 19, 2026</time>
            </header>

            <section id="intro">
                <h2>Introduction</h2>
                <p>Opening paragraph...</p>
            </section>

            <section id="details">
                <h2>Details</h2>
                <figure>
                    <img src="diagram.svg" alt="Architecture diagram">
                    <figcaption>Fig 1: System architecture overview</figcaption>
                </figure>
            </section>

            <footer>
                <p>Written by <a href="/authors/ada">Ada Lovelace</a></p>
            </footer>
        </article>

        <aside aria-label="Related content">
            <h2>Related Articles</h2>
            <nav>
                <ul>
                    <li><a href="/css-grid">CSS Grid Guide</a></li>
                    <li><a href="/flexbox">Flexbox Deep Dive</a></li>
                </ul>
            </nav>
        </aside>
    </main>

    <footer>
        <p>&copy; 2026 Site Name. All rights reserved.</p>
        <nav aria-label="Footer navigation">
            <a href="/privacy">Privacy</a>
            <a href="/terms">Terms</a>
        </nav>
    </footer>
</body>
</html>
Tip Multiple <header> and <footer> elements are valid. Each belongs to its nearest sectioning ancestor. The page's top-level <header> is the site header; an <article>'s <header> contains the article title and metadata.

Content Sectioning

Choosing between <article>, <section>, and <div> is one of the most common questions. Here is the decision framework.

Use <article> when the content is self-contained and independently distributable — it would make sense in an RSS feed, a social media embed, or on its own page.

<!-- Blog post — self-contained, syndicatable -->
<article>
    <h2>Understanding CSS Cascade Layers</h2>
    <p>Cascade layers give authors explicit control over...</p>
</article>

<!-- Comment — also self-contained -->
<article>
    <header>
        <strong>Jane Doe</strong>
        <time datetime="2026-02-18T14:30">2h ago</time>
    </header>
    <p>Great article! One thing I would add...</p>
</article>

<!-- Product card in a shop —  self-contained unit -->
<article class="product-card">
    <img src="shoe.jpg" alt="Red running shoe">
    <h3>Ultraboost 23</h3>
    <p class="price">$180</p>
</article>

Use <section> when content is thematically related and needs a heading, but is NOT independently meaningful outside its page context.

<!-- Thematic grouping within a page -->
<section id="pricing">
    <h2>Pricing Plans</h2>
    <div class="plan">...</div>
    <div class="plan">...</div>
</section>

<section id="testimonials">
    <h2>What Our Customers Say</h2>
    <blockquote>...</blockquote>
</section>

Use <div> only when no semantic element fits — it is a last resort for purely visual grouping or JavaScript hooks.

<!-- Wrapper for layout purposes only -->
<div class="grid-container">
    <section>...</section>
    <aside>...</aside>
</div>

<!-- JS target with no semantic meaning -->
<div id="tooltip-root"></div>

Interactive Elements

HTML ships with interactive widgets that used to require JavaScript libraries. Using native elements gives you accessibility, keyboard support, and animation hooks for free.

<details> / <summary> — Accordion/disclosure pattern:

<details>
    <summary>What browsers support :has()?</summary>
    <p>All major browsers — Chrome 105+, Firefox 121+,
       Safari 15.4+, Edge 105+. It is Baseline 2024.</p>
</details>

<!-- Open by default -->
<details open>
    <summary>System Requirements</summary>
    <ul>
        <li>Node 18 or later</li>
        <li>2 GB RAM minimum</li>
    </ul>
</details>

<!-- Exclusive accordion (name attribute, Baseline 2024) -->
<details name="faq">
    <summary>Question 1</summary>
    <p>Answer 1</p>
</details>
<details name="faq">
    <summary>Question 2</summary>
    <p>Answer 2</p>
</details>

<dialog> — Native modal with ::backdrop, focus trapping, and Escape key support:

<dialog id="confirm-dialog">
    <h2>Delete this item?</h2>
    <p>This action cannot be undone.</p>
    <form method="dialog">
        <button value="cancel">Cancel</button>
        <button value="confirm">Delete</button>
    </form>
</dialog>

<button onclick="document.getElementById('confirm-dialog').showModal()">
    Delete Item
</button>

<style>
    dialog::backdrop {
        background: rgba(0, 0, 0, 0.6);
        backdrop-filter: blur(4px);
    }
</style>

<script>
    const dialog = document.getElementById('confirm-dialog');
    dialog.addEventListener('close', () => {
        if (dialog.returnValue === 'confirm') {
            // perform deletion
        }
    });
</script>

Popover attribute Baseline 2024 — Lightweight dismissable layer without the ceremony of <dialog>:

<!-- Toggle popover -->
<button popovertarget="menu">Open Menu</button>
<div popover id="menu">
    <nav>
        <a href="/settings">Settings</a>
        <a href="/logout">Log out</a>
    </nav>
</div>

<!-- Manual popover (must be explicitly closed) -->
<button popovertarget="toast" popovertargetaction="show">
    Show Toast
</button>
<div popover="manual" id="toast">
    <p>Item saved successfully.</p>
    <button popovertarget="toast" popovertargetaction="hide">
        Dismiss
    </button>
</div>

Media Elements

Modern HTML gives you full control over responsive images and embedded media without JavaScript.

<picture> with art direction — serve different crops at different viewport sizes:

<picture>
    <!-- Wide screens: landscape hero -->
    <source
        srcset="hero-wide.avif"
        type="image/avif"
        media="(min-width: 1024px)">
    <source
        srcset="hero-wide.webp"
        type="image/webp"
        media="(min-width: 1024px)">

    <!-- Mobile: square crop -->
    <source
        srcset="hero-square.avif"
        type="image/avif">
    <source
        srcset="hero-square.webp"
        type="image/webp">

    <!-- Fallback -->
    <img
        src="hero-square.jpg"
        alt="Mountain landscape at sunset"
        loading="lazy"
        decoding="async"
        width="800" height="600">
</picture>

<figure> / <figcaption> — semantic wrapper for any referenced content:

<figure>
    <pre><code>const x = await fetch('/api/data');</code></pre>
    <figcaption>Listing 3: Fetching data with async/await</figcaption>
</figure>

<figure>
    <blockquote cite="https://www.w3.org/TR/html52/">
        <p>Authors must not use elements, attributes, or
           attribute values for purposes other than their
           appropriate intended semantic purpose.</p>
    </blockquote>
    <figcaption>— HTML5 Specification, Section 1.1.1</figcaption>
</figure>

Video and audio with multiple sources and tracks:

<video controls preload="metadata" width="640" height="360">
    <source src="demo.mp4" type="video/mp4">
    <source src="demo.webm" type="video/webm">
    <track
        kind="captions"
        src="captions-en.vtt"
        srclang="en"
        label="English"
        default>
    <track
        kind="captions"
        src="captions-es.vtt"
        srclang="es"
        label="Espa&ntilde;ol">
    <p>Your browser does not support video.
       <a href="demo.mp4">Download the video</a>.</p>
</video>

Metadata & Inline Semantic Elements

These elements enrich text with machine-readable meaning. They are often overlooked but invaluable for accessibility and data extraction.

<!-- Machine-readable date -->
<p>Published <time datetime="2026-02-19T09:00:00Z">February 19, 2026</time></p>

<!-- Highlighted search result -->
<p>Your search for "grid" found: Use <mark>CSS Grid</mark>
   for two-dimensional layouts.</p>

<!-- Abbreviation with expansion -->
<p>The <abbr title="World Wide Web Consortium">W3C</abbr>
   maintains the HTML specification.</p>

<!-- Machine-readable value -->
<p>Size: <data value="256">256 GB</data></p>

<!-- Calculation output -->
<form oninput="result.value = parseInt(a.value) + parseInt(b.value)">
    <input type="number" id="a" value="0"> +
    <input type="number" id="b" value="0"> =
    <output name="result" for="a b">0</output>
</form>

<!-- Search landmark (Baseline 2023) -->
<search>
    <form action="/search" method="get">
        <label for="q">Search articles</label>
        <input type="search" id="q" name="q"
               placeholder="CSS Grid, Flexbox...">
        <button type="submit">Search</button>
    </form>
</search>
Common Mistake Do not use <mark> for visual highlighting alone. It conveys semantic emphasis — screen readers may announce "highlighted text." For purely visual emphasis, use a <span> with CSS instead.
03

Selectors & Specificity

Modern pseudo-classes, relational selectors, and cascade layers that eliminate specificity wars.

:is() and :where()

Both :is() and :where() accept a forgiving selector list and match any element in that list. The critical difference is specificity.

/* WITHOUT :is() — repetitive */
header a:hover,
nav a:hover,
footer a:hover {
    color: var(--stijl-red);
    text-decoration: underline;
}

/* WITH :is() — concise, same behavior */
:is(header, nav, footer) a:hover {
    color: var(--stijl-red);
    text-decoration: underline;
}

/* Nested headings — matches h1-h6 inside article or section */
:is(article, section) :is(h1, h2, h3, h4, h5, h6) {
    font-family: var(--font-display);
    line-height: 1.2;
}

:where() has identical matching behavior but zero specificity, making it perfect for base styles that should be easy to override:

/* Default styles — zero specificity, easily overridable */
:where(.card, .panel, .modal) {
    padding: 1rem;
    border: 1px solid #ddd;
    background: white;
}

:where(.card, .panel, .modal) :where(h2, h3) {
    margin-bottom: 0.5em;
}

/* A single class override wins, because :where() = 0 specificity */
.featured-card {
    padding: 2rem;           /* overrides :where(.card) padding */
    border: 2px solid gold;  /* overrides :where(.card) border */
}

/* SPECIFICITY COMPARISON:
   :is(nav, footer) a    → specificity of "nav a" = (0, 0, 2)
   :where(nav, footer) a → specificity of "a"     = (0, 0, 1)
   :is(#sidebar) a       → specificity of "#sidebar a" = (1, 0, 1)
*/
Rule of Thumb Use :is() in application code where you want normal specificity. Use :where() in library/framework code where you want users to override styles easily without !important.

:has() — The Parent Selector

The :has() pseudo-class is the most powerful addition to CSS selectors in a decade. It matches an element based on its descendants, siblings, or state — enabling patterns that previously required JavaScript.

/* 1. Cards with images get a different layout */
.card:has(img) {
    display: grid;
    grid-template-rows: 200px 1fr;
}

.card:not(:has(img)) {
    padding: 2rem;
}

/* 2. Form validation — style the form when any input is invalid */
form:has(:invalid) .submit-btn {
    opacity: 0.5;
    pointer-events: none;
}

form:has(:invalid)::after {
    content: "Please fix the errors above";
    color: var(--stijl-red);
    display: block;
    margin-top: 1rem;
}

/* 3. Navigation focus-within — highlight nav when it has focus */
.nav-wrapper:has(:focus-within) {
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

/* 4. Page-level state — dim everything when a dialog is open */
body:has(dialog[open]) main {
    filter: blur(2px);
    pointer-events: none;
}

/* 5. Quantity queries — style a list based on item count */
ul:has(li:nth-child(4)) {
    /* list has 4+ items: switch to 2-column grid */
    display: grid;
    grid-template-columns: 1fr 1fr;
}

ul:has(li:nth-child(10)) {
    /* 10+ items: switch to 3 columns */
    grid-template-columns: repeat(3, 1fr);
}

/* 6. Adjacent sibling state — style a label when its input is focused */
label:has(+ input:focus) {
    color: var(--stijl-blue);
    font-weight: 700;
}

/* 7. Empty state — show placeholder when list has no items */
.todo-list:not(:has(li)) {
    display: grid;
    place-items: center;
    min-height: 200px;
}

.todo-list:not(:has(li))::after {
    content: "No tasks yet. Add one above!";
    color: #999;
}
Performance Note Avoid deeply nested :has() selectors like body:has(.wrapper .list .item:nth-child(100)). Browsers evaluate :has() efficiently for common patterns, but overly complex relational selectors can impact rendering performance on large DOMs.

:not() and :nth-child(of)

Modern :not() accepts a comma-separated selector list (no more chaining). The of S syntax in :nth-child() filters which children are counted.

/* Multi-argument :not() — select links that are not
   inside nav AND not inside footer */
a:not(nav a, footer a, .skip-link) {
    text-decoration: underline;
    text-underline-offset: 3px;
}

/* All form controls except disabled ones */
:is(input, select, textarea):not(:disabled) {
    border-color: var(--stijl-blue);
}

/* Last visible child (not hidden) */
.list-item:not([hidden]):last-of-type {
    border-bottom: none;
}

Filtered :nth-child() — the of S syntax counts only matching siblings. This solves the classic "striped rows on filtered tables" problem:

/* Stripe only VISIBLE table rows (ignoring hidden ones) */
tr:nth-child(odd of :not([hidden])) {
    background: #f5f5f5;
}

tr:nth-child(even of :not([hidden])) {
    background: white;
}

/* Style every 3rd "active" card, ignoring inactive ones */
.card:nth-child(3n of .active) {
    border-color: var(--stijl-red);
}

/* First visible item in a filtered list */
.item:nth-child(1 of .visible) {
    border-top: none;
}

/* Target the 2nd .featured element regardless of DOM position */
.product:nth-child(2 of .featured) {
    grid-column: span 2;
}

Specificity Calculation

Specificity is a three-part weight: (ID, Class, Element). When two rules match the same element, the one with higher specificity wins. Understanding this system prevents !important abuse.

Selector IDs Classes Elements Specificity
p 0 0 1 (0, 0, 1)
.card 0 1 0 (0, 1, 0)
.card .title 0 2 0 (0, 2, 0)
#sidebar .widget h3 1 1 1 (1, 1, 1)
nav ul li a:hover 0 1 4 (0, 1, 4)
:is(#main, .content) a 1 0 1 (1, 0, 1)*
:where(#main, .content) a 0 0 1 (0, 0, 1)
:not(.hidden) 0 1 0 (0, 1, 0)

* :is() takes the specificity of its most specific argument. Since #main has ID specificity, the entire :is(#main, .content) contributes (1, 0, 0) — even when matching .content.

/* SPECIFICITY TIE-BREAKER: source order wins */

/* Both have specificity (0, 1, 1) — last one wins */
p.intro { color: blue; }
p.intro { color: red; }   /* This wins — red */

/* Higher specificity always wins regardless of order */
.card .title { color: blue; }   /* (0, 2, 0) — wins */
.title       { color: red; }    /* (0, 1, 0) — loses */

/* The cascade priority (highest to lowest):
   1. Transitions
   2. !important user-agent styles
   3. !important user styles
   4. !important author styles
   5. @keyframes animations
   6. Normal author styles (specificity applies here)
   7. Normal user styles
   8. User-agent defaults
*/

Cascade Layers

@layer gives you explicit control over the cascade ordering, independent of specificity. Styles in later layers beat styles in earlier layers, regardless of selector weight.

/* DECLARE layer order upfront (empty declarations) */
@layer reset, base, components, utilities;

/* Styles in "utilities" layer always beat "components",
   which always beat "base", which always beat "reset" —
   regardless of specificity! */

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

    a { color: inherit; text-decoration: none; }
}

@layer base {
    body {
        font-family: system-ui, sans-serif;
        line-height: 1.6;
        color: #333;
    }

    h1, h2, h3 {
        line-height: 1.2;
        text-wrap: balance;
    }
}

@layer components {
    .btn {
        display: inline-flex;
        align-items: center;
        padding: 0.75rem 1.5rem;
        font-weight: 600;
        border: 2px solid currentColor;
        cursor: pointer;
    }

    .card {
        padding: 1.5rem;
        border: 1px solid #ddd;
        background: white;
    }

    /* Even this high-specificity selector in "components"
       layer will LOSE to a simple class in "utilities" */
    #sidebar .card .title a.link {
        color: navy;
    }
}

@layer utilities {
    /* Simple class — wins over everything in prior layers */
    .text-red { color: var(--stijl-red); }
    .text-center { text-align: center; }
    .hidden { display: none; }
}

Importing third-party CSS into a layer ensures library styles never accidentally override your styles:

/* Put third-party CSS in a low-priority layer */
@import url("normalize.css") layer(reset);
@import url("component-library.css") layer(vendor);

/* Your layers come after, so they always win */
@layer reset, vendor, base, components, utilities;

@layer components {
    /* This beats anything in "vendor" layer,
       even vendor selectors with IDs */
    .btn { background: var(--stijl-blue); }
}
Important Unlayered styles (styles not in any @layer) have the highest priority and beat all layered styles. This means existing code keeps working when you start adopting layers — you can migrate incrementally.
/* UNLAYERED styles beat ALL layers */
.card { border: 3px solid red; }  /* WINS over any @layer */

@layer components {
    #app .card { border: 1px solid blue; }  /* LOSES to unlayered */
}

/* Nested layers (sublayers) */
@layer components {
    @layer cards {
        .card { padding: 1rem; }
    }
    @layer buttons {
        .btn { padding: 0.5rem 1rem; }
    }
}

/* Reference sublayer from outside */
@layer components.cards {
    .card-featured { padding: 2rem; }
}
04

Flexbox

One-dimensional layout for rows and columns — the backbone of component-level arrangement.

Container Properties

Every flex layout starts with display: flex on a container. These properties control how children are arranged, wrapped, and spaced.

Property Values Description
display flex | inline-flex Establishes a flex formatting context. inline-flex makes the container inline-level.
flex-direction row | column | row-reverse | column-reverse Sets the main axis. row (default) flows left-to-right; column flows top-to-bottom.
flex-wrap nowrap | wrap | wrap-reverse Controls whether items wrap to new lines. Default is nowrap (single line).
flex-flow <direction> <wrap> Shorthand for flex-direction + flex-wrap. Example: flex-flow: row wrap.
justify-content flex-start | center | flex-end | space-between | space-around | space-evenly Distributes items along the main axis.
align-items stretch | flex-start | center | flex-end | baseline Aligns items along the cross axis. Default is stretch.
align-content flex-start | center | flex-end | space-between | space-around | space-evenly | stretch Distributes wrapped lines along the cross axis. Only applies when flex-wrap: wrap.
gap <length> Space between items. Shorthand for row-gap and column-gap.
row-gap <length> Space between rows (cross axis spacing in wrapped layouts).
column-gap <length> Space between columns (main axis spacing in row direction).

Item Properties

Flex items can override their container's alignment and control how they grow, shrink, and size themselves within the available space.

Property Default Description
flex-grow 0 How much the item grows relative to siblings when extra space is available.
flex-shrink 1 How much the item shrinks relative to siblings when space is insufficient.
flex-basis auto The initial size before growing/shrinking. Can be a length, percentage, or auto.
flex 0 1 auto Shorthand for flex-grow, flex-shrink, flex-basis.
align-self auto Overrides the container's align-items for this specific item.
order 0 Visual ordering independent of DOM order. Lower values appear first.

The flex shorthand has several common presets that behave differently than you might expect:

/* flex: 1 → flex: 1 1 0%
   Item IGNORES its content size. All items with flex:1 get EQUAL widths
   because flex-basis is 0, and all remaining space is divided equally. */
.item { flex: 1; }

/* flex: auto → flex: 1 1 auto
   Item STARTS at its content size, then grows/shrinks from there.
   Items with more content will be wider. */
.item { flex: auto; }

/* flex: none → flex: 0 0 auto
   Item is completely inflexible. Sized purely by content/width.
   Will not grow or shrink. */
.sidebar { flex: none; width: 300px; }

/* flex: 0 0 200px
   Fixed-size item. Will not grow or shrink. Always 200px. */
.fixed-col { flex: 0 0 200px; }

/* flex: 2 vs flex: 1
   The flex:2 item gets twice as much EXTRA space, not twice the width. */
.main    { flex: 2; }  /* gets 2/3 of extra space */
.sidebar { flex: 1; }  /* gets 1/3 of extra space */

Common Patterns

These flex patterns appear in nearly every web project. Each one is a complete, copy-pasteable solution.

Centering on both axes — the simplest centering technique in CSS:

/* Perfect centering */
.centered {
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
}

/* Also works with a single item using place-items shorthand */
.centered-alt {
    display: flex;
    place-items: center;  /* shorthand for align + justify */
    min-height: 100vh;
}

Holy grail layout — header, footer, main content with two sidebars:

.holy-grail {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
}

.holy-grail header,
.holy-grail footer {
    flex: none;    /* fixed height, no grow/shrink */
}

.holy-grail .body {
    display: flex;
    flex: 1;       /* fill remaining vertical space */
}

.holy-grail .sidebar-left  { flex: 0 0 200px; }
.holy-grail .content        { flex: 1; }
.holy-grail .sidebar-right { flex: 0 0 200px; }

/* Stack on mobile */
@media (max-width: 768px) {
    .holy-grail .body { flex-direction: column; }
    .holy-grail .sidebar-left,
    .holy-grail .sidebar-right { flex-basis: auto; }
}

Equal-height cards — cards in a row always match the tallest card's height:

.card-row {
    display: flex;
    gap: 1rem;
}

.card {
    flex: 1;             /* equal widths */
    display: flex;
    flex-direction: column;
}

.card .content { flex: 1; }        /* push footer down */
.card .footer  { margin-top: auto; } /* stick to bottom */

Sticky footer — footer stays at the bottom even when content is short:

body {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
}

main { flex: 1; }   /* main expands to fill space */
footer { flex: none; }

Navigation bar — logo left, links right:

.navbar {
    display: flex;
    align-items: center;
    padding: 1rem 2rem;
}

.navbar .logo { margin-right: auto; }  /* pushes links right */
.navbar .links {
    display: flex;
    gap: 1.5rem;
}

Media object — image/avatar beside text content:

.media {
    display: flex;
    gap: 1rem;
    align-items: flex-start;
}

.media .avatar {
    flex: none;          /* don't shrink the image */
    width: 48px;
    height: 48px;
    border-radius: 50%;
}

.media .body { flex: 1; } /* text takes remaining space */

Alignment Deep Dive

Flexbox alignment goes beyond the basic properties. These techniques solve layout problems that are surprisingly hard without flex.

margin: auto trick — auto margins in flexbox absorb all extra space, pushing items apart:

/* Push last item to the far right */
.toolbar {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.toolbar .spacer { margin-left: auto; }

/* HTML: [Home] [About] [Contact]      [Login] */
/* The login button gets margin-left: auto      */

/* Center one item, push another right */
.header {
    display: flex;
    align-items: center;
}

.header .title { margin: 0 auto; }  /* centered */
.header .action { /* sits at the right edge */ }

Baseline alignment — aligning text of different sizes on their text baseline:

/* Mixed-size text aligned on baseline */
.price-row {
    display: flex;
    align-items: baseline;
    gap: 0.5rem;
}

.price-row .currency { font-size: 1rem; }
.price-row .amount   { font-size: 3rem; font-weight: 700; }
.price-row .period   { font-size: 0.875rem; color: #666; }
/* "$" "49" "/mo" — all sit on the same text baseline */

gap vs old margin approaches — why gap is the modern best practice:

/* OLD: margin on items (extra margin on edges) */
.old-way .item {
    margin: 0 8px;
}
.old-way {
    margin: 0 -8px;  /* negative margin hack to offset */
}

/* OLD: margin-right except last child */
.old-way-2 .item {
    margin-right: 16px;
}
.old-way-2 .item:last-child {
    margin-right: 0;
}

/* MODERN: gap (no edge issues, no hacks) */
.modern {
    display: flex;
    gap: 16px;  /* only between items, never on edges */
}

/* gap works with wrapping too */
.wrapped {
    display: flex;
    flex-wrap: wrap;
    gap: 16px 24px;  /* row-gap column-gap */
}

Wrapping with gap — a responsive chip/tag layout:

.tags {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
}

.tag {
    flex: none;          /* don't grow or shrink */
    padding: 0.25rem 0.75rem;
    background: #f0f0f0;
    border-radius: 999px;
    font-size: 0.875rem;
}

Gotchas & Tips

min-width: 0 for text truncation Flex items have an implicit min-width: auto, which prevents them from shrinking below their content size. If you need text truncation with ellipsis inside a flex item, you must explicitly set min-width: 0.
/* Text truncation inside flex items */
.list-item {
    display: flex;
    gap: 1rem;
    align-items: center;
}

.list-item .label {
    min-width: 0;             /* REQUIRED for truncation */
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.list-item .badge { flex: none; }
flex-basis vs width flex-basis sets the initial size along the main axis before flex-grow and flex-shrink apply. In a row layout, flex-basis and width both affect horizontal size, but flex-basis wins when both are set. In a column layout, flex-basis controls height, not width.
flex: 1 does not always mean equal widths flex: 1 means flex: 1 1 0%, so items start from zero and divide space equally. But if items have padding, borders, or min-width constraints, they may still end up with different sizes. For truly equal widths, combine flex: 1 with min-width: 0 or use flex-basis: 0 with box-sizing: border-box.
align-items vs align-content align-items aligns items within each line along the cross axis. align-content distributes the lines themselves within the container when there are multiple wrapped lines. If you have flex-wrap: nowrap (the default), align-content has no effect.
/* align-items: each item within its line */
.single-line {
    display: flex;
    align-items: center;      /* items centered within the single line */
}

/* align-content: the wrapped lines themselves */
.multi-line {
    display: flex;
    flex-wrap: wrap;
    height: 400px;
    align-items: center;      /* items centered within each line */
    align-content: flex-start; /* lines packed to the top */
}
05

CSS Grid

Two-dimensional layout for complex page structures — rows and columns under your full control.

Grid Container

Grid containers define the track structure — rows, columns, and gaps — that items are placed into. The fr unit, repeat(), and minmax() are the core building blocks.

Property Values Description
display grid | inline-grid Establishes a grid formatting context on the container.
grid-template-columns Track sizes separated by spaces Defines column track sizes. Accepts lengths, %, fr, auto, minmax(), repeat().
grid-template-rows Track sizes separated by spaces Defines row track sizes. Same value options as columns.
grid-template-areas Quoted ASCII-art strings Names grid areas using a visual text map. Each string represents one row.
gap <row-gap> <column-gap> Space between tracks. Shorthand for row-gap and column-gap.
grid-auto-flow row | column | dense Controls auto-placement direction. dense fills in holes left by larger items.
grid-auto-columns Track size Size for implicitly created columns (when items overflow the explicit grid).
grid-auto-rows Track size Size for implicitly created rows. Commonly minmax(100px, auto).

Key functions and units for defining tracks:

/* fr unit — fractional unit of remaining space */
.grid {
    display: grid;
    grid-template-columns: 1fr 2fr 1fr;  /* 1:2:1 ratio */
}

/* repeat() — avoids repetitive track definitions */
.grid-6 {
    grid-template-columns: repeat(6, 1fr);         /* 6 equal columns */
}

.grid-pattern {
    grid-template-columns: repeat(3, 100px 1fr);   /* repeating pattern */
}

/* minmax() — responsive tracks with constraints */
.grid-cards {
    grid-template-columns: repeat(3, minmax(200px, 1fr));
    grid-auto-rows: minmax(150px, auto);  /* rows at least 150px */
}

/* Named lines — reference lines by name instead of number */
.layout {
    grid-template-columns:
        [sidebar-start] 250px
        [sidebar-end content-start] 1fr
        [content-end];
    grid-template-rows:
        [header-start] auto
        [header-end main-start] 1fr
        [main-end footer-start] auto
        [footer-end];
}

/* Place items using named lines */
.header { grid-column: sidebar-start / content-end; }
.sidebar { grid-row: main-start / main-end; }

Grid Item Placement

Grid items can be placed explicitly by line number, span, or named area. Negative line numbers count from the end of the grid.

/* grid-column and grid-row shorthand */
.item {
    grid-column: 1 / 3;     /* start at line 1, end at line 3 (span 2 cols) */
    grid-row: 2 / 4;        /* start at line 2, end at line 4 (span 2 rows) */
}

/* Span keyword — span N tracks from auto-placement position */
.wide-item {
    grid-column: span 2;    /* span 2 columns from wherever placed */
}

.tall-item {
    grid-row: span 3;       /* span 3 rows */
}

/* Negative line numbers — count from the end */
.full-width {
    grid-column: 1 / -1;    /* first line to last line = full width */
}

.last-two-cols {
    grid-column: -3 / -1;   /* 2nd-to-last line to last line */
}

/* grid-area shorthand: row-start / col-start / row-end / col-end */
.featured {
    grid-area: 1 / 1 / 3 / 3;  /* top-left 2x2 block */
}

/* Placing into named areas (from grid-template-areas) */
.header  { grid-area: header; }
.sidebar { grid-area: sidebar; }
.main    { grid-area: main; }
.footer  { grid-area: footer; }
Tip When using grid-column: 1 / -1, the item spans all explicit columns. If the grid has implicit columns (from auto-placement overflow), -1 refers to the end of the explicit grid, not the implicit grid.

auto-fill vs auto-fit

Both auto-fill and auto-fit create as many tracks as will fit in the container, but they handle leftover space differently.

/* auto-fill: creates empty tracks to fill remaining space.
   Items do NOT stretch beyond their max size. */
.grid-fill {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
    gap: 1rem;
}
/* With 3 items in an 1100px container:
   4 columns created (4 x 250px fits), item 4's track is empty.
   Each item is ~275px, empty track takes remaining space. */

/* auto-fit: collapses empty tracks to zero width.
   Items STRETCH to fill the entire container. */
.grid-fit {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
    gap: 1rem;
}
/* With 3 items in an 1100px container:
   4 columns created, empty track collapses to 0.
   3 items stretch to fill all 1100px (~366px each). */

The RAM pattern (Repeat, Auto, Minmax) — a single line of CSS for fully responsive grids with no media queries:

/* The "RAM" responsive pattern */
.responsive-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr));
    gap: 1rem;
}

/* The inner min() prevents overflow on narrow viewports:
   - On wide screens: columns are at least 250px, grow to fill space
   - On narrow screens (<250px): columns are 100% width (single column)
   - No media queries needed! */
When to use which Use auto-fit when you want items to stretch and fill the row (most common). Use auto-fill when you want items to maintain a consistent maximum size and leave empty space at the end (useful for dashboards or fixed-size card grids).

grid-template-areas

Named grid areas let you define layouts with an ASCII-art-like syntax that is easy to read and rearrange with media queries.

/* Desktop layout — 3-column with header and footer */
.page {
    display: grid;
    grid-template-areas:
        "header  header  header"
        "sidebar main    aside"
        "footer  footer  footer";
    grid-template-columns: 200px 1fr 200px;
    grid-template-rows: auto 1fr auto;
    min-height: 100vh;
    gap: 1rem;
}

.page-header  { grid-area: header; }
.page-sidebar { grid-area: sidebar; }
.page-main    { grid-area: main; }
.page-aside   { grid-area: aside; }
.page-footer  { grid-area: footer; }

/* Empty cells use a dot (.) */
.dashboard {
    grid-template-areas:
        "stats  stats  stats"
        "chart  chart  ."
        "chart  chart  list";
}

/* Responsive: reorganize with media query */
@media (max-width: 768px) {
    .page {
        grid-template-areas:
            "header"
            "main"
            "sidebar"
            "aside"
            "footer";
        grid-template-columns: 1fr;
        grid-template-rows: auto;
    }
}
Area naming rules Each named area must form a rectangle. You cannot create L-shaped or T-shaped areas. Every row must have the same number of cells. Use . (dot) for empty cells — multiple dots in a row (e.g., ...) are treated as a single empty cell.

Subgrid

Subgrid lets a grid item's children participate in the parent grid's track sizing. This is essential for aligning inner elements across sibling cards — a problem that was previously unsolvable in CSS.

/* Parent grid: 3-column card layout */
.card-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 1.5rem;
}

/* Each card is a grid item AND a grid container.
   Its rows inherit from... itself, using subgrid for alignment. */
.card {
    display: grid;
    grid-template-rows: subgrid;  /* inherit parent's row tracks */
    grid-row: span 3;             /* card spans 3 rows */
    gap: 0;
}

/* Now .card-title, .card-body, .card-footer in every card
   sit on the SAME row lines — titles align, bodies align,
   footers align, regardless of content length. */

.card-title  { padding: 1rem; font-weight: 700; }
.card-body   { padding: 0 1rem; }
.card-footer { padding: 1rem; align-self: end; }

Subgrid also inherits named lines from the parent:

/* Parent with named lines */
.layout {
    display: grid;
    grid-template-columns:
        [full-start] 1fr
        [content-start] 2fr
        [content-end] 1fr
        [full-end];
}

/* Child inherits named lines via subgrid */
.layout-item {
    display: grid;
    grid-template-columns: subgrid;
    grid-column: full-start / full-end;
}

/* Grandchild can use the inherited named lines */
.layout-item .inner {
    grid-column: content-start / content-end;
}
Browser Support Subgrid is Baseline 2023. Supported in Chrome 117+, Firefox 71+, Safari 16+, Edge 117+. For older browsers, you can use a fallback with explicit row heights or @supports (grid-template-rows: subgrid) to progressively enhance.
06

Custom Properties & Modern Values

Dynamic theming, mathematical functions, and the new units that make CSS truly responsive.

CSS Custom Properties

Custom properties (CSS variables) are live, inherited values that can be changed at runtime — enabling dynamic theming, component variants, and responsive design without JavaScript.

/* Define variables on :root for global scope */
:root {
    --color-primary: #D40920;
    --color-surface: #FFFFFF;
    --color-text: #000000;
    --spacing-unit: 8px;
    --font-body: 'Work Sans', sans-serif;
    --radius: 4px;
}

/* Use with var() — optional fallback as second argument */
.card {
    background: var(--color-surface);
    color: var(--color-text);
    padding: calc(var(--spacing-unit) * 3);
    border-radius: var(--radius);
    font-family: var(--font-body, sans-serif);  /* fallback */
}

/* Scoped overrides — variables cascade like any CSS property */
.dark-section {
    --color-surface: #1a1a1a;
    --color-text: #f0f0f0;
}
/* All children of .dark-section automatically use dark values */

/* Component-level scoping */
.btn {
    --btn-bg: var(--color-primary);
    --btn-text: white;
    --btn-padding: 0.75rem 1.5rem;

    background: var(--btn-bg);
    color: var(--btn-text);
    padding: var(--btn-padding);
}

.btn.outline {
    --btn-bg: transparent;
    --btn-text: var(--color-primary);
}

Dark mode toggle using custom properties and prefers-color-scheme:

/* Light mode (default) */
:root {
    --bg: #FFFFFF;
    --text: #000000;
    --surface: #F5F5F5;
    --border: #DDDDDD;
    --accent: #D40920;
}

/* Dark mode — automatic via system preference */
@media (prefers-color-scheme: dark) {
    :root {
        --bg: #121212;
        --text: #E0E0E0;
        --surface: #1E1E1E;
        --border: #333333;
        --accent: #FF4D4D;
    }
}

/* Manual toggle via data attribute */
[data-theme="dark"] {
    --bg: #121212;
    --text: #E0E0E0;
    --surface: #1E1E1E;
    --border: #333333;
    --accent: #FF4D4D;
}

/* All components use the variables — theme changes automatically */
body {
    background: var(--bg);
    color: var(--text);
}

.card {
    background: var(--surface);
    border: 1px solid var(--border);
}

calc(), min(), max(), clamp()

CSS math functions let you mix units, set dynamic constraints, and create fluid values without media queries.

calc() — mix different units in a single expression:

/* Subtract fixed sidebar from fluid width */
.main {
    width: calc(100% - 250px);
}

/* Combine rem and viewport units */
.container {
    padding: calc(1rem + 2vw);
}

/* Use with custom properties */
.grid {
    gap: calc(var(--spacing-unit) * 2);
    padding: calc(var(--spacing-unit) * 3);
}

/* Nested calc — useful for complex calculations */
.element {
    width: calc(100% - calc(var(--sidebar) + var(--gap)));
}

min() — picks the smaller of two or more values. Great for max-width behavior without a separate property:

/* Container that is 90% wide but never exceeds 1200px */
.container {
    width: min(90%, 1200px);
    margin: 0 auto;
}

/* Responsive padding: 5vw on small screens, caps at 48px */
.section {
    padding: min(5vw, 48px);
}

/* Image that fits its container but never exceeds natural size */
img {
    width: min(100%, 800px);
}

max() — picks the larger of two or more values. Ensures minimum sizes:

/* Sidebar is at least 200px, or 25% of viewport */
.sidebar {
    width: max(200px, 25vw);
}

/* Font size never smaller than 16px */
.text {
    font-size: max(16px, 1.2vw);
}

clamp() — sets a preferred value with minimum and maximum bounds. The ultimate fluid typography tool:

/* clamp(minimum, preferred, maximum) */

/* Fluid typography — scales with viewport, stays in bounds */
h1 {
    font-size: clamp(2rem, 5vw, 4rem);
    /* At 320px viewport: 5vw = 16px → clamped to 2rem (32px)
       At 800px viewport: 5vw = 40px → within range, used as-is
       At 1200px viewport: 5vw = 60px → clamped to 4rem (64px) */
}

body {
    font-size: clamp(1rem, 2.5vw, 1.25rem);
}

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

/* Fluid container width */
.container {
    width: clamp(320px, 90%, 1200px);
}

Modern Viewport Units

Classic vh and vw have a notorious problem on mobile: the browser's address bar changes size as you scroll, causing layout jumps. Modern viewport units fix this.

Unit Full Name Behavior
vh / vw Viewport Height / Width Classic units. On mobile, 100vh may be taller than the visible area when the address bar is showing.
svh / svw Small Viewport Height / Width Based on the smallest possible viewport (address bar visible). Content always fits.
lvh / lvw Large Viewport Height / Width Based on the largest possible viewport (address bar hidden). May overflow when bar is visible.
dvh / dvw Dynamic Viewport Height / Width Adapts dynamically as the address bar shows/hides. Updates in real time.
/* Full-screen hero section — use dvh for mobile correctness */
.hero {
    min-height: 100dvh;    /* adapts to address bar changes */
    display: grid;
    place-items: center;
}

/* Fallback for older browsers */
.hero {
    min-height: 100vh;     /* fallback */
    min-height: 100dvh;    /* modern browsers use this */
}

/* svh for elements that must NEVER overflow the visible area */
.modal-overlay {
    height: 100svh;        /* always fits, even with address bar */
}

/* lvh for backgrounds that should cover the full scroll area */
.background-cover {
    min-height: 100lvh;    /* fills space when address bar hides */
}

/* Block-axis equivalents: dvb, svb, lvb (for vertical writing modes)
   Inline-axis equivalents: dvi, svi, lvi (for horizontal) */
Recommendation Use dvh for most full-screen layouts. Use svh when content must never be clipped (modals, sticky footers). Avoid lvh unless you specifically want the address-bar-hidden measurement.

Container Queries

Container queries let components respond to their container's size instead of the viewport. This makes truly reusable components that adapt wherever they are placed.

/* 1. Define a containment context */
.card-wrapper {
    container-type: inline-size;  /* enable width-based queries */
    container-name: card;         /* optional: name for targeting */
}

/* Shorthand */
.card-wrapper {
    container: card / inline-size;
}

/* 2. Query the container's width */
@container card (min-width: 400px) {
    .card {
        display: grid;
        grid-template-columns: 150px 1fr;
        gap: 1rem;
    }
}

@container card (min-width: 600px) {
    .card {
        grid-template-columns: 200px 1fr auto;
    }

    .card .actions {
        flex-direction: column;
    }
}

/* Container query units */
.card-title {
    font-size: clamp(1rem, 3cqw, 1.5rem);  /* relative to container width */
}
/* cqw = 1% of container width
   cqh = 1% of container height
   cqi = 1% of container inline size
   cqb = 1% of container block size */

Complete responsive card example that adapts to any container width:

/* The container */
.content-area {
    container: content / inline-size;
}

/* Small container: stacked layout */
.article-card {
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
}

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

/* Medium container: horizontal layout */
@container content (min-width: 500px) {
    .article-card {
        flex-direction: row;
    }

    .article-card img {
        width: 200px;
        aspect-ratio: 1;
    }
}

/* Large container: expanded with metadata */
@container content (min-width: 800px) {
    .article-card {
        display: grid;
        grid-template-columns: 250px 1fr auto;
        align-items: start;
    }

    .article-card .meta {
        display: block;  /* hidden in smaller containers */
    }
}

Style queries Baseline 2024 — query custom property values on the container:

/* Style container query — respond to custom property values */
.card-wrapper {
    --variant: default;
}

@container style(--variant: compact) {
    .card {
        padding: 0.5rem;
        font-size: 0.875rem;
    }

    .card img { display: none; }
}

/* Toggle variant */
.sidebar .card-wrapper { --variant: compact; }
.main    .card-wrapper { --variant: default; }

@property

The @property at-rule lets you register typed custom properties with syntax constraints, initial values, and inheritance control. This unlocks gradient animation and other effects that are impossible with regular custom properties.

/* Register a typed custom property */
@property --hue {
    syntax: "<angle>";
    inherits: false;
    initial-value: 0deg;
}

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

@property --card-bg {
    syntax: "<color>";
    inherits: true;
    initial-value: #ffffff;
}

/* Common syntax values:
   "<color>"       — any valid color
   "<length>"      — px, rem, em, etc.
   "<percentage>"  — 0% to 100%
   "<angle>"       — deg, rad, turn
   "<number>"      — any number
   "<integer>"     — whole numbers only
   "<length-percentage>" — either a length or percentage
   "*"             — any value (no type checking)
*/

Animating gradients — the killer use case. Regular custom properties cannot be animated because the browser does not know their type. @property fixes this:

/* Register the angle property so the browser can interpolate it */
@property --gradient-angle {
    syntax: "<angle>";
    inherits: false;
    initial-value: 0deg;
}

.animated-gradient {
    background: linear-gradient(
        var(--gradient-angle),
        #D40920,
        #1356A2,
        #F7D117
    );
    animation: rotate-gradient 3s linear infinite;
}

@keyframes rotate-gradient {
    to {
        --gradient-angle: 360deg;
    }
}

/* Animate a color stop position */
@property --stop {
    syntax: "<percentage>";
    inherits: false;
    initial-value: 30%;
}

.breathing-gradient {
    background: linear-gradient(135deg,
        #D40920 0%,
        #1356A2 var(--stop),
        #F7D117 100%
    );
    animation: breathe 2s ease-in-out infinite alternate;
}

@keyframes breathe {
    to { --stop: 70%; }
}

/* Animate individual colors in a gradient */
@property --color-a {
    syntax: "<color>";
    inherits: false;
    initial-value: #D40920;
}

@property --color-b {
    syntax: "<color>";
    inherits: false;
    initial-value: #1356A2;
}

.color-shift {
    background: linear-gradient(135deg, var(--color-a), var(--color-b));
    transition: --color-a 0.5s, --color-b 0.5s;
}

.color-shift:hover {
    --color-a: #F7D117;
    --color-b: #000000;
}
Browser Support @property is Baseline 2024. Supported in Chrome 85+, Edge 85+, Safari 16.4+, Firefox 128+. For older browsers, the custom property still works — it just cannot be animated or type-checked. Use @supports (font-size: 1cap) as a proxy for modern CSS support if you need a fallback strategy.
07

Typography & Text

Web fonts, variable axes, fluid type scales, and modern text features for polished, performant typography.

@font-face & font-display

The @font-face rule lets you load custom fonts. Always provide woff2 first (best compression), then woff as a fallback. The format() hint tells the browser which files to download.

/* Preconnect to the font CDN for faster loading */
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

/* Self-hosted @font-face declaration */
@font-face {
    font-family: 'CustomSans';
    src: url('/fonts/custom-sans.woff2') format('woff2'),
         url('/fonts/custom-sans.woff') format('woff');
    font-weight: 400;
    font-style: normal;
    font-display: swap;
    unicode-range: U+0000-00FF, U+0131, U+0152-0153;
}

@font-face {
    font-family: 'CustomSans';
    src: url('/fonts/custom-sans-bold.woff2') format('woff2'),
         url('/fonts/custom-sans-bold.woff') format('woff');
    font-weight: 700;
    font-style: normal;
    font-display: swap;
}

body {
    font-family: 'CustomSans', system-ui, sans-serif;
}
font-display Block Period Swap Period Best For
auto Browser default Browser default Let the browser decide
block Short (~3s) Infinite Icon fonts where fallback looks broken
swap Extremely short Infinite Body text — shows fallback immediately, swaps when ready
fallback Extremely short Short (~3s) Balanced — swaps only if font loads quickly
optional Extremely short None Performance-first — uses font only if already cached
Performance Tip Use font-display: swap for body text (avoids invisible text) and font-display: optional for non-critical decorative fonts. Always preconnect to font origins.

Variable Fonts

A variable font packs multiple weights, widths, and styles into a single file. Instead of loading separate files for regular, bold, and italic, one variable font covers the entire design space along configurable axes.

/* Load a variable font — note the weight range in @font-face */
@font-face {
    font-family: 'InterVariable';
    src: url('/fonts/Inter-Variable.woff2') format('woff2-variations');
    font-weight: 100 900;       /* supports any weight in this range */
    font-stretch: 75% 125%;     /* width axis range */
    font-style: oblique 0deg 10deg;
    font-display: swap;
}

/* Use standard properties — the browser interpolates */
h1 { font-weight: 850; }       /* not limited to 400/700 anymore */
h2 { font-weight: 650; }
body { font-weight: 380; }

/* Low-level control with font-variation-settings */
.fine-tuned {
    font-variation-settings:
        'wght' 620,     /* Weight */
        'wdth' 90,      /* Width */
        'ital' 1,       /* Italic (0 or 1) */
        'slnt' -8,      /* Slant in degrees */
        'opsz' 14;      /* Optical size in px */
}
Axis Tag Name CSS Property Typical Range
wght Weight font-weight 100 — 900
wdth Width font-stretch 75% — 125%
ital Italic font-style: italic 0 or 1
slnt Slant font-style: oblique Xdeg -90 — 90
opsz Optical Size font-optical-sizing 8 — 144
/* Google Fonts variable font URL syntax */
<link href="https://fonts.googleapis.com/css2?
  family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900
  &display=swap" rel="stylesheet">

/* Roboto Flex — multiple axes */
<link href="https://fonts.googleapis.com/css2?
  family=Roboto+Flex:opsz,wdth,wght@8..144,25..151,100..1000
  &display=swap" rel="stylesheet">

Fluid Typography

The clamp() function creates font sizes that scale smoothly between a minimum and maximum value based on viewport width. No media queries needed — the browser interpolates between the bounds.

/* The formula: clamp(min, preferred, max)
   preferred typically uses vw units to scale with viewport
   Common pattern: clamp(minRem, calc(base + vwScalar), maxRem) */

/* Complete fluid type scale */
:root {
    --step--1: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);   /* small */
    --step-0:  clamp(1rem, 0.925rem + 0.375vw, 1.125rem);    /* body */
    --step-1:  clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem);      /* h4 */
    --step-2:  clamp(1.5rem, 1.25rem + 1.25vw, 2rem);        /* h3 */
    --step-3:  clamp(2rem, 1.5rem + 2.5vw, 3rem);            /* h2 */
    --step-4:  clamp(2.5rem, 1.75rem + 3.75vw, 4.5rem);      /* h1 */
}

body  { font-size: var(--step-0); }
small { font-size: var(--step--1); }
h4    { font-size: var(--step-1); }
h3    { font-size: var(--step-2); }
h2    { font-size: var(--step-3); }
h1    { font-size: var(--step-4); }

/* Line-height that scales inversely with font size
   (larger text needs less relative line-height) */
h1 {
    font-size: var(--step-4);
    line-height: clamp(1.1, 1 + 0.5vw, 1.2);
}

body {
    font-size: var(--step-0);
    line-height: clamp(1.5, 1.4 + 0.3vw, 1.7);
}

/* Fluid spacing that matches the type scale */
.section {
    padding-block: clamp(2rem, 1.5rem + 2.5vw, 4rem);
}
How It Works In clamp(1rem, 0.925rem + 0.375vw, 1.125rem), the middle value scales with viewport width. At narrow viewports, it hits the 1rem floor. At wide viewports, it hits the 1.125rem ceiling. Between those points, it interpolates smoothly.

text-wrap: balance & pretty

CSS now has native text wrapping controls that solve common typographic problems — uneven heading lines and orphaned words — without JavaScript or manual <br> tags.

/* balance — equalizes line lengths in short text blocks
   Best for headings, captions, and blockquotes.
   The browser tries to make each line roughly the same width. */
h1, h2, h3, h4, h5, h6 {
    text-wrap: balance;
}

blockquote p, figcaption {
    text-wrap: balance;
}

/* pretty — prevents orphans (single words on last line)
   Best for paragraphs and longer text blocks.
   The browser adjusts line breaks to avoid a lonely last word. */
p {
    text-wrap: pretty;
}

article p, .content p {
    text-wrap: pretty;
}

/* Comparison: without text-wrap, a heading might break as:
   "The Complete Guide to Modern CSS
    Typography"

   With text-wrap: balance, it becomes:
   "The Complete Guide to
    Modern CSS Typography"
   — both lines are roughly equal width */
Browser Support text-wrap: balance is Baseline 2024 — Chrome 114+, Edge 114+, Safari 17.5+, Firefox 121+. text-wrap: pretty has the same baseline. Performance note: balance only applies to blocks with 6 or fewer lines (browsers cap it to avoid layout thrashing).

Font Features

OpenType fonts contain optional features — ligatures, small caps, tabular numbers, fractions — that are off by default. CSS gives you two ways to enable them: the low-level font-feature-settings and the higher-level font-variant shorthands.

/* font-feature-settings — low-level, uses 4-character tags */
.ligatures    { font-feature-settings: 'liga' 1, 'dlig' 1; }
.small-caps   { font-feature-settings: 'smcp' 1; }
.tabular-nums { font-feature-settings: 'tnum' 1; }
.oldstyle     { font-feature-settings: 'onum' 1; }
.fractions    { font-feature-settings: 'frac' 1; }
.stylistic    { font-feature-settings: 'ss01' 1, 'ss02' 1; }

/* font-variant — high-level, more readable (preferred) */
.liga     { font-variant-ligatures: common-ligatures discretionary-ligatures; }
.sc       { font-variant-caps: small-caps; }
.nums-tab { font-variant-numeric: tabular-nums; }
.nums-old { font-variant-numeric: oldstyle-nums; }
.frac     { font-variant-numeric: diagonal-fractions; }
.all-sc   { font-variant-caps: all-small-caps; }

/* Combine multiple numeric features */
.data-table td {
    font-variant-numeric: tabular-nums lining-nums slashed-zero;
}

/* Practical: price display with fractions and tabular alignment */
.price {
    font-variant-numeric: tabular-nums diagonal-fractions;
}

/* font-optical-sizing — adjusts glyph shapes for the rendered size
   Smaller sizes get slightly thicker strokes for legibility */
body {
    font-optical-sizing: auto;   /* on by default for variable fonts */
}

/* unicode-range — load different font files for different scripts */
@font-face {
    font-family: 'MyFont';
    src: url('latin.woff2') format('woff2');
    unicode-range: U+0000-00FF;  /* Basic Latin only */
}

@font-face {
    font-family: 'MyFont';
    src: url('greek.woff2') format('woff2');
    unicode-range: U+0370-03FF;  /* Greek characters */
}

@font-face {
    font-family: 'MyFont';
    src: url('cyrillic.woff2') format('woff2');
    unicode-range: U+0400-04FF;  /* Cyrillic */
}
/* Browser only downloads the file for character ranges actually used */
Tip Prefer font-variant-* over font-feature-settings when possible. The font-variant properties are more readable, cascade properly, and do not override each other like font-feature-settings does (since it is a single property, redeclaring it replaces all previous tags).
08

Colors & Visual Effects

Modern color spaces, dynamic color mixing, gradient techniques, and visual filters for richer, more maintainable designs.

Modern Color Functions

CSS has moved beyond hex and rgb(). Modern color functions give you perceptually uniform colors, wider gamut access, and more intuitive authoring. oklch() is the recommended default for new projects.

/* oklch() — Lightness, Chroma, Hue
   Perceptually uniform: equal L steps = equal perceived brightness
   Wide gamut: can express P3 colors that hex/rgb cannot */
.primary   { color: oklch(55% 0.25 265); }    /* vivid blue */
.secondary { color: oklch(70% 0.18 145); }    /* rich green */
.accent    { color: oklch(65% 0.30 30);  }    /* bright red-orange */
.muted     { color: oklch(75% 0.05 265); }    /* desaturated blue */

/* oklab() — Lightness, a-axis (green-red), b-axis (blue-yellow)
   Good for interpolation — gradients look natural */
.lab-color { color: oklab(0.7 -0.1 0.15); }

/* hsl() — still useful for quick mental model
   Hue: 0-360°, Saturation: 0-100%, Lightness: 0-100% */
.legacy { color: hsl(210 80% 55%); }   /* modern syntax: no commas */
.alpha  { color: hsl(210 80% 55% / 0.5); }  /* with transparency */

/* rgb() — modern syntax also drops commas */
.rgb-modern { color: rgb(212 9 32); }
.rgb-alpha  { color: rgb(212 9 32 / 0.8); }

/* Named colors — 148 keywords, useful for prototyping */
.named { color: tomato; }
.named { color: rebeccapurple; }   /* #663399 — honoring Eric Meyer */

/* Comparison: same visual blue in different spaces */
.hex  { color: #1356A2; }
.rgb  { color: rgb(19 86 162); }
.hsl  { color: hsl(212 79% 35%); }
.oklch { color: oklch(45% 0.15 260); }
Why oklch? In hsl(), two colors with the same L value can look very different in brightness (yellow vs blue). In oklch(), equal lightness values genuinely look equally bright. This makes building consistent color palettes much easier.

color-mix()

The color-mix() function blends two colors in a specified color space. It is ideal for generating tints, shades, and semi-transparent variants from a single base color — no preprocessor needed.

/* Syntax: color-mix(in <color-space>, color1 percentage, color2) */

/* Basic mixing — 70% red + 30% blue */
.mixed { color: color-mix(in oklch, red 70%, blue); }

/* Generate tints (lighter) by mixing with white */
:root {
    --brand: oklch(55% 0.25 265);
    --brand-100: color-mix(in oklch, var(--brand) 10%, white);
    --brand-200: color-mix(in oklch, var(--brand) 25%, white);
    --brand-300: color-mix(in oklch, var(--brand) 40%, white);
    --brand-400: color-mix(in oklch, var(--brand) 60%, white);
    --brand-500: var(--brand);
    --brand-600: color-mix(in oklch, var(--brand) 60%, black);
    --brand-700: color-mix(in oklch, var(--brand) 40%, black);
    --brand-800: color-mix(in oklch, var(--brand) 25%, black);
    --brand-900: color-mix(in oklch, var(--brand) 10%, black);
}

/* Semi-transparent variants — mix with transparent */
.overlay {
    background: color-mix(in srgb, var(--brand) 50%, transparent);
}

/* Hover states — darken by mixing with black */
.btn {
    background: var(--brand);
}
.btn:hover {
    background: color-mix(in oklch, var(--brand) 85%, black);
}
.btn:active {
    background: color-mix(in oklch, var(--brand) 70%, black);
}

/* Color space matters for interpolation:
   oklch gives perceptually smooth transitions
   srgb can produce muddy middle values */
.oklch-mix { color: color-mix(in oklch, red, blue); }  /* vibrant purple */
.srgb-mix  { color: color-mix(in srgb, red, blue); }   /* duller result */

Relative Color Syntax

Relative color syntax lets you derive new colors by modifying individual channels of an existing color. This is transformative for theming — define one base color and compute an entire palette from it.

/* Syntax: color-function(from base-color channel adjustments) */

:root {
    --primary: oklch(55% 0.25 265);
}

/* Lighten — increase L (lightness) */
.lighter {
    color: oklch(from var(--primary) calc(l + 0.2) c h);
}

/* Darken — decrease L */
.darker {
    color: oklch(from var(--primary) calc(l - 0.15) c h);
}

/* Desaturate — decrease C (chroma) */
.muted {
    color: oklch(from var(--primary) l calc(c - 0.1) h);
}

/* Shift hue — adjust H */
.complement {
    color: oklch(from var(--primary) l c calc(h + 180));
}

.analogous {
    color: oklch(from var(--primary) l c calc(h + 30));
}

/* Semi-transparent version */
.ghost {
    color: oklch(from var(--primary) l c h / 0.5);
}

/* Practical: complete theme from one variable */
:root {
    --base: oklch(55% 0.22 265);
    --on-base: oklch(from var(--base) 95% 0.02 h);
    --surface: oklch(from var(--base) 97% 0.01 h);
    --surface-hover: oklch(from var(--base) 93% 0.02 h);
    --border: oklch(from var(--base) 80% 0.05 h);
    --accent: oklch(from var(--base) l c calc(h + 60));
}
Browser Support Relative color syntax is Baseline 2024 — Chrome 119+, Edge 119+, Safari 16.4+, Firefox 128+. Use @supports (color: oklch(from red l c h)) to feature-detect.

Gradients

CSS gradients create smooth transitions between colors and can serve as backgrounds, borders, masks, and text fills. Three gradient types cover most use cases.

/* linear-gradient — along a line at any angle */
.hero {
    background: linear-gradient(135deg, #D40920, #F7D117);
}

/* With explicit color stops */
.striped {
    background: linear-gradient(
        to right,
        #D40920 0%,
        #D40920 33%,
        #FFFFFF 33%,
        #FFFFFF 66%,
        #1356A2 66%,
        #1356A2 100%
    );
}

/* repeating-linear-gradient — for patterns */
.diagonal-stripes {
    background: repeating-linear-gradient(
        45deg,
        transparent,
        transparent 10px,
        #000 10px,
        #000 12px
    );
}

/* radial-gradient — from a center point outward */
.spotlight {
    background: radial-gradient(circle at 30% 40%, #F7D117, transparent 60%);
}

.ellipse {
    background: radial-gradient(ellipse at center, #D40920, #1356A2);
}

/* conic-gradient — around a center point (like a pie chart) */
.pie {
    background: conic-gradient(
        from 0deg,
        #D40920 0% 40%,
        #1356A2 40% 70%,
        #F7D117 70% 100%
    );
    border-radius: 50%;
}

/* Gradient text technique */
.gradient-text {
    background: linear-gradient(135deg, #D40920, #1356A2);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
}

/* Modern: animate gradients with @property */
@property --gradient-angle {
    syntax: "<angle>";
    inherits: false;
    initial-value: 0deg;
}

.rotating-gradient {
    background: conic-gradient(from var(--gradient-angle), #D40920, #F7D117, #1356A2, #D40920);
    animation: rotate-gradient 3s linear infinite;
}

@keyframes rotate-gradient {
    to { --gradient-angle: 360deg; }
}

Filters & Blend Modes

CSS filters apply visual effects to elements, while blend modes control how layers combine. Together with mask-image, they enable complex visual compositions without image editing.

/* filter — applies effects to an element and its contents */
.blur       { filter: blur(4px); }
.bright     { filter: brightness(1.3); }
.contrast   { filter: contrast(1.5); }
.grayscale  { filter: grayscale(100%); }
.hue-shift  { filter: hue-rotate(90deg); }
.saturate   { filter: saturate(2); }
.shadow     { filter: drop-shadow(4px 4px 0 #000); }

/* Chain multiple filters */
.vintage {
    filter: sepia(40%) contrast(1.1) brightness(0.95) saturate(1.2);
}

/* backdrop-filter — glass/frosted effect on the backdrop */
.glass-panel {
    background: rgba(255, 255, 255, 0.15);
    backdrop-filter: blur(12px) saturate(1.5);
    -webkit-backdrop-filter: blur(12px) saturate(1.5);
    border: 1px solid rgba(255, 255, 255, 0.2);
}

/* Frosted dark glass */
.dark-glass {
    background: rgba(0, 0, 0, 0.4);
    backdrop-filter: blur(20px) brightness(0.8);
}

/* mix-blend-mode — how an element blends with its background */
.overlay-text {
    color: white;
    mix-blend-mode: difference;   /* inverts against background */
}

.multiply-img {
    mix-blend-mode: multiply;     /* darkens — like printing ink */
}

/* background-blend-mode — blend multiple backgrounds */
.textured {
    background:
        url('texture.png'),
        linear-gradient(135deg, #D40920, #1356A2);
    background-blend-mode: overlay;
}

/* mask-image — control element visibility with gradients */
.fade-bottom {
    mask-image: linear-gradient(to bottom, black 60%, transparent);
    -webkit-mask-image: linear-gradient(to bottom, black 60%, transparent);
}

.circle-reveal {
    mask-image: radial-gradient(circle at center, black 30%, transparent 70%);
    -webkit-mask-image: radial-gradient(circle at center, black 30%, transparent 70%);
}

/* Practical: image hover effect */
.card-image {
    filter: grayscale(100%) contrast(1.1);
    transition: filter 0.3s ease;
}
.card-image:hover {
    filter: grayscale(0%) contrast(1);
}
09

Transitions, Animations & Scroll

Motion design with transitions, keyframe animations, entry effects, view transitions, and scroll-driven timelines.

Transitions

Transitions smoothly interpolate property changes over a specified duration. They are the simplest way to add motion — just declare which properties animate, how long, and with what easing.

/* transition shorthand: property duration timing-function delay */
.btn {
    background: #D40920;
    color: white;
    transform: translateY(0);
    transition: background 0.2s ease, transform 0.2s ease;
}

.btn:hover {
    background: #a00718;
    transform: translateY(-2px);
}

/* Transition all animatable properties (use sparingly) */
.card {
    transition: all 0.3s ease;
}

/* Individual transition properties */
.detailed {
    transition-property: opacity, transform, background-color;
    transition-duration: 0.3s, 0.4s, 0.2s;
    transition-timing-function: ease-out, cubic-bezier(0.34, 1.56, 0.64, 1), ease;
    transition-delay: 0s, 0.05s, 0s;
}
Timing Function Curve Best For
ease Slow start, fast middle, slow end General purpose (default)
linear Constant speed Progress bars, continuous rotation
ease-in Slow start, fast end Elements leaving the screen
ease-out Fast start, slow end Elements entering the screen
ease-in-out Slow start and end Elements moving on screen
cubic-bezier() Custom curve with 4 control points Fine-tuned motion (bounce, elastic)
steps(n) Discrete jumps Sprite animations, typewriter effect
/* transition-behavior: allow-discrete — animate display and visibility */
.tooltip {
    opacity: 0;
    display: none;
    transition:
        opacity 0.3s ease,
        display 0.3s ease allow-discrete;
}

.trigger:hover .tooltip {
    opacity: 1;
    display: block;

    /* @starting-style needed for entry animation */
    @starting-style {
        opacity: 0;
    }
}

@keyframes & Animation

Keyframe animations run automatically without state changes. They support multi-step sequences, infinite loops, and fine-grained control that transitions cannot provide.

Property Values Description
animation-name Name of @keyframes rule Which keyframes to apply
animation-duration Time value (e.g. 0.5s, 300ms) How long one cycle takes
animation-timing-function ease, linear, cubic-bezier() Easing curve for each cycle
animation-delay Time value (can be negative) Wait before starting
animation-iteration-count Number or infinite How many times to repeat
animation-direction normal, reverse, alternate, alternate-reverse Direction of playback
animation-fill-mode none, forwards, backwards, both Styles applied before/after animation
animation-play-state running, paused Pause/resume control
animation-composition replace, add, accumulate How values combine with underlying
/* Loading spinner */
@keyframes spin {
    to { transform: rotate(360deg); }
}

.spinner {
    width: 24px;
    height: 24px;
    border: 3px solid #ddd;
    border-top-color: #D40920;
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
}

/* Pulse effect */
@keyframes pulse {
    0%, 100% { opacity: 1; }
    50%      { opacity: 0.5; }
}

.loading-text {
    animation: pulse 1.5s ease-in-out infinite;
}

/* Shimmer / skeleton loading */
@keyframes shimmer {
    0%   { background-position: -200% 0; }
    100% { background-position: 200% 0; }
}

.skeleton {
    background: linear-gradient(
        90deg,
        #f0f0f0 25%,
        #e0e0e0 50%,
        #f0f0f0 75%
    );
    background-size: 200% 100%;
    animation: shimmer 1.5s ease-in-out infinite;
}

/* Multi-step keyframes */
@keyframes bounce-in {
    0%   { transform: scale(0); opacity: 0; }
    50%  { transform: scale(1.15); }
    70%  { transform: scale(0.95); }
    100% { transform: scale(1); opacity: 1; }
}

.modal {
    animation: bounce-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}

/* animation-composition: accumulate — add to existing transforms */
.combined {
    transform: translateX(50px);
    animation: slide-up 0.5s ease forwards;
    animation-composition: accumulate;
}

@keyframes slide-up {
    from { transform: translateY(20px); }
    to   { transform: translateY(0); }
}
/* Result: element moves both right (existing) AND up (animation) */

@starting-style

@starting-style defines the initial state of an element when it first appears in the DOM or becomes visible. This enables entry animations for elements like <dialog> and [popover] that transition from display: none.

/* Without @starting-style, elements popping into display: block
   have no "from" state — transitions cannot run.
   @starting-style provides that initial state. */

/* Dialog that fades + slides in */
dialog {
    opacity: 1;
    transform: translateY(0) scale(1);
    transition:
        opacity 0.3s ease,
        transform 0.3s ease,
        overlay 0.3s ease allow-discrete,
        display 0.3s ease allow-discrete;
}

/* The state it starts FROM when showModal() is called */
@starting-style {
    dialog[open] {
        opacity: 0;
        transform: translateY(-20px) scale(0.95);
    }
}

/* Exit animation — state when closing */
dialog:not([open]) {
    opacity: 0;
    transform: translateY(20px) scale(0.95);
}

/* The ::backdrop also needs @starting-style for entry */
dialog::backdrop {
    background: rgba(0, 0, 0, 0.5);
    opacity: 1;
    transition: opacity 0.3s ease,
                display 0.3s ease allow-discrete;
}

@starting-style {
    dialog[open]::backdrop {
        opacity: 0;
    }
}

/* Popover with slide-in effect */
[popover] {
    opacity: 1;
    transform: translateY(0);
    transition:
        opacity 0.25s ease,
        transform 0.25s ease,
        overlay 0.25s ease allow-discrete,
        display 0.25s ease allow-discrete;
}

@starting-style {
    [popover]:popover-open {
        opacity: 0;
        transform: translateY(10px);
    }
}

/* Closing state */
[popover]:not(:popover-open) {
    opacity: 0;
    transform: translateY(-10px);
}
Key Concept @starting-style only applies on the first style update after an element enters the DOM or becomes visible. It does not apply on subsequent property changes. You also need allow-discrete on display and overlay transitions so those properties participate in the transition timeline.

View Transitions

The View Transitions API captures snapshots of elements before and after a DOM change, then animates between them. This creates smooth page transitions and morph effects with minimal code.

/* Step 1: Name elements that should transition */
.page-header {
    view-transition-name: page-header;
}

.hero-image {
    view-transition-name: hero-image;
}

/* Step 2: Trigger the transition in JavaScript */
document.startViewTransition(() => {
    // Update the DOM — swap page content, change route, etc.
    updatePageContent(newContent);
});

/* Step 3: Style the transition pseudo-elements */

/* Default crossfade for the entire page */
::view-transition-old(root) {
    animation: fade-out 0.25s ease forwards;
}

::view-transition-new(root) {
    animation: fade-in 0.25s ease forwards;
}

/* Named element: morph between old and new position/size */
::view-transition-old(hero-image) {
    animation: none;    /* let the browser handle the morph */
}

::view-transition-new(hero-image) {
    animation: none;
}

/* Custom transition: slide pages left/right */
@keyframes slide-out-left {
    to { transform: translateX(-100%); }
}

@keyframes slide-in-right {
    from { transform: translateX(100%); }
}

::view-transition-old(root) {
    animation: slide-out-left 0.3s ease;
}

::view-transition-new(root) {
    animation: slide-in-right 0.3s ease;
}

/* Unique names for list items — enables morph reordering */
.card {
    view-transition-name: var(--card-name);
    /* Set --card-name uniquely per element via inline style or JS */
}
Browser Support Same-document view transitions are Baseline 2024 — Chrome 111+, Edge 111+, Safari 18+, Firefox 132+. Cross-document view transitions (MPA) are newer — Chrome 126+, limited Safari/Firefox support. Always feature-detect: if (document.startViewTransition) { ... }

Scroll-Driven Animations

Scroll-driven animations tie animation progress to scroll position instead of time. Combined with scroll snap, they enable rich scroll experiences — progress bars, reveal effects, and snappy carousels — with pure CSS.

/* animation-timeline: scroll() — progress linked to scroll container */

/* Reading progress bar */
.progress-bar {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 4px;
    background: #D40920;
    transform-origin: left;
    transform: scaleX(0);
    animation: grow-x linear;
    animation-timeline: scroll();    /* scroll progress of nearest ancestor */
}

@keyframes grow-x {
    to { transform: scaleX(1); }
}

/* animation-timeline: view() — progress based on element visibility */

/* Reveal elements as they scroll into view */
.reveal {
    opacity: 0;
    transform: translateY(30px);
    animation: reveal-up linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 100%;
}

@keyframes reveal-up {
    to {
        opacity: 1;
        transform: translateY(0);
    }
}

/* Named scroll timelines — link to a specific scroll container */
.scroll-container {
    overflow-y: scroll;
    scroll-timeline-name: --my-scroller;
    scroll-timeline-axis: block;
}

.animated-child {
    animation: fade-in linear;
    animation-timeline: --my-scroller;
}

/* Complete example: horizontal scroll progress with named timeline */
.carousel {
    overflow-x: scroll;
    scroll-timeline-name: --carousel;
    scroll-timeline-axis: inline;
}

.carousel-progress {
    animation: grow-x linear;
    animation-timeline: --carousel;
}

/* ===== Scroll Snap — for carousel and section patterns ===== */

/* Vertical full-page sections */
.snap-container {
    overflow-y: scroll;
    scroll-snap-type: y mandatory;
    height: 100vh;
}

.snap-container > section {
    scroll-snap-align: start;
    height: 100vh;
}

/* Horizontal carousel */
.carousel {
    display: flex;
    overflow-x: scroll;
    scroll-snap-type: x mandatory;
    gap: 1rem;
    -webkit-overflow-scrolling: touch;
}

.carousel > .slide {
    scroll-snap-align: center;
    flex: 0 0 80%;
}

/* Proximity snap — less strict, allows free scrolling */
.gallery {
    scroll-snap-type: x proximity;
}

/* Stop at padding edges, not element edges */
.carousel {
    scroll-padding-inline: 1rem;
}
Browser Support Scroll-driven animations (animation-timeline: scroll() and view()) are Baseline 2025 — Chrome 115+, Edge 115+, Safari 18.4+, Firefox 133+. Scroll snap is fully supported across all modern browsers. For scroll-driven animation fallbacks, the animation simply will not run — elements stay in their final state if you use animation-fill-mode: both.
10

Responsive Design

Media queries, container queries, logical properties, and mobile-first strategies for adaptive layouts.

Media Queries

Media queries apply styles conditionally based on viewport size, device features, or user preferences. Modern range syntax is cleaner and more readable than the legacy min-width/max-width approach.

/* Legacy syntax */
@media (min-width: 768px) and (max-width: 1199px) {
    .sidebar { display: block; }
}

/* Modern range syntax (Baseline 2023) */
@media (768px <= width < 1200px) {
    .sidebar { display: block; }
}

/* Combine with logical operators */
@media (width >= 768px) and (orientation: landscape) {
    .hero { height: 60vh; }
}

/* OR — matches if either condition is true */
@media (width < 480px), (orientation: portrait) {
    .nav { flex-direction: column; }
}

/* NOT — negate entire query */
@media not (color) {
    .badge { border: 2px solid black; }
}
Breakpoint Legacy Syntax Range Syntax Targets
Small phones (max-width: 479px) (width < 480px) 320-479px
Large phones (min-width: 480px) (width >= 480px) 480-767px
Tablets (min-width: 768px) (width >= 768px) 768-1023px
Desktops (min-width: 1024px) (width >= 1024px) 1024-1439px
Large screens (min-width: 1440px) (width >= 1440px) 1440px+

User preference queries let you respect user settings at the OS or browser level:

/* Dark mode */
@media (prefers-color-scheme: dark) {
    :root {
        --bg: #1a1a1a;
        --text: #e0e0e0;
    }
}

/* Reduce motion for vestibular disorders */
@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
        scroll-behavior: auto !important;
    }
}

/* High contrast mode */
@media (prefers-contrast: more) {
    :root {
        --border: 3px solid black;
        --text: #000;
        --bg: #fff;
    }
}

/* Reduced data — skip heavy assets */
@media (prefers-reduced-data: reduce) {
    .hero-bg { background-image: none; }
    video { display: none; }
}

Container Queries vs Media Queries

Media queries respond to the viewport. Container queries respond to the parent container's size. Use container queries for reusable components that must adapt to wherever they are placed, regardless of viewport width.

Feature Media Queries Container Queries
Responds to Viewport dimensions Parent container dimensions
Best for Page-level layout shifts Component-level adaptation
Reusability Layout-coupled Context-independent
Nesting Cannot nest meaningfully Nested containers work
Support All browsers Baseline 2023
/* ===== Media Query approach — tied to viewport ===== */
.card { display: grid; grid-template-columns: 1fr; }

@media (width >= 768px) {
    .card { grid-template-columns: 200px 1fr; }
}

/* ===== Container Query approach — tied to parent ===== */
.card-wrapper {
    container-type: inline-size;
    container-name: card-container;
}

.card { display: grid; grid-template-columns: 1fr; }

@container card-container (inline-size >= 400px) {
    .card { grid-template-columns: 200px 1fr; }
}

/* Container query units — relative to container, not viewport */
@container (inline-size >= 300px) {
    .card-title { font-size: clamp(1rem, 3cqi, 2rem); }
    /* cqi = 1% of container inline size */
    /* cqb = 1% of container block size */
    /* cqmin / cqmax = smaller / larger dimension */
}
When to use which Use media queries for overall page layout (sidebar/no sidebar, header collapse). Use container queries for components that appear in different contexts (cards in a grid, sidebar, or modal). Combine both for maximum flexibility.

Logical Properties

Logical properties replace physical directions (left, right, top, bottom) with flow-relative directions (inline-start, inline-end, block-start, block-end). This makes layouts automatically adapt to writing direction (LTR, RTL) and writing mode (horizontal, vertical).

Physical Property Logical Property In LTR Horizontal
margin-left margin-inline-start Left margin
margin-right margin-inline-end Right margin
padding-top padding-block-start Top padding
padding-bottom padding-block-end Bottom padding
width inline-size Horizontal size
height block-size Vertical size
border-left border-inline-start Left border
top / bottom inset-block-start / inset-block-end Top / bottom offset
left / right inset-inline-start / inset-inline-end Left / right offset
text-align: left text-align: start Left-aligned text
text-align: right text-align: end Right-aligned text
/* Shorthand logical properties */
.element {
    margin-inline: 1rem 2rem;   /* inline-start  inline-end */
    margin-block: 0.5rem;       /* block-start = block-end */
    padding-inline: 1rem;       /* both inline sides */
    border-inline: 2px solid;   /* both inline borders */
    inset-inline: 0;            /* left: 0; right: 0; in LTR */
    border-start-start-radius: 8px; /* top-left in LTR horizontal */
}

/* Works automatically in RTL */
html[dir="rtl"] .sidebar {
    /* No changes needed if you used logical properties */
    margin-inline-start: 0;
    margin-inline-end: 2rem;
}

:has() for Responsive Components

The :has() selector lets you create content-aware layouts that respond to their own structure rather than viewport or container size. This enables responsive patterns that were previously impossible with CSS alone.

/* Sidebar detection — adjust main content when sidebar is present */
.layout:has(.sidebar) .main-content {
    max-width: 70%;
}

.layout:not(:has(.sidebar)) .main-content {
    max-width: 100%;
}

/* Empty state handling */
.card-grid:not(:has(.card)) {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 300px;
}

.card-grid:not(:has(.card))::after {
    content: "No items found";
    font-size: 1.25rem;
    color: #666;
}

/* Content-aware card layout — switch to horizontal if card has image */
.card:has(img) {
    display: grid;
    grid-template-columns: 200px 1fr;
    gap: 1rem;
}

.card:not(:has(img)) {
    display: block;
}

/* Form section — highlight when it contains invalid inputs */
.form-section:has(:invalid) {
    border-left: 4px solid #D40920;
    background: #fbe8ea;
}

/* Navigation — style parent when child link is active */
.nav-item:has(a[aria-current="page"]) {
    background: var(--stijl-blue);
    color: white;
}
Performance note :has() is Baseline 2023 and well-optimized in modern browsers. However, avoid deeply nested or overly broad selectors like body:has(.modal-open) in performance-critical contexts. Prefer scoping to the nearest useful ancestor.

Mobile-First Strategy

Mobile-first means writing base styles for the smallest screen, then progressively enhancing with min-width breakpoints. This ensures mobile devices download only the CSS they need and reduces override complexity.

/* ===== Mobile-first: base styles (no media query) ===== */
.page-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: 1rem;
    padding: 1rem;
}

.sidebar {
    order: 2;  /* sidebar below content on mobile */
}

.main-content {
    order: 1;
}

/* ===== Tablet: add sidebar beside content ===== */
@media (width >= 768px) {
    .page-grid {
        grid-template-columns: 240px 1fr;
        gap: 2rem;
        padding: 2rem;
    }

    .sidebar {
        order: unset;
    }
}

/* ===== Desktop: wider sidebar, max-width container ===== */
@media (width >= 1200px) {
    .page-grid {
        grid-template-columns: 300px 1fr;
        max-width: 1400px;
        margin: 0 auto;
    }
}

/* ===== When to break the mobile-first rule ===== */

/* Desktop-first can make sense for admin dashboards,
   data-heavy tools, or when mobile is an afterthought.
   Use max-width to progressively REMOVE complexity: */
.data-table th:nth-child(n+5) { display: table-cell; }

@media (width < 1024px) {
    .data-table th:nth-child(n+5) { display: none; }
}

@media (width < 640px) {
    .data-table th:nth-child(n+3) { display: none; }
}
Rule of thumb Use min-width (mobile-first) for content sites, marketing pages, and public-facing apps. Use max-width (desktop-first) for dense tooling UIs where the desktop experience is primary. Never mix both approaches in the same file.
11

Forms & Accessibility

Modern form elements, the dialog and popover APIs, validation, ARIA patterns, and focus management.

Modern Form Elements

HTML provides rich input types with built-in validation, mobile-optimized keyboards, and autocomplete hints. Use the right type and you get validation, accessibility, and UX improvements for free.

Input Type Purpose Mobile Keyboard Built-in Validation
text General text Standard None
email Email addresses @ and . keys Requires @ format
url Web addresses / and .com keys Requires protocol
tel Phone numbers Numeric dialpad None (use pattern)
number Numeric values Number pad Requires number, min/max/step
date Calendar date Date picker Valid date format
time Time of day Time picker Valid time format
color Color selection Color picker Valid hex color
range Slider control Slider min/max/step
search Search field Search key + clear None
file File upload File browser accept attribute
<!-- Complete accessible form -->
<form action="/signup" method="post" novalidate>
    <!-- Name field with autocomplete -->
    <div class="field">
        <label for="full-name">Full Name</label>
        <input type="text" id="full-name" name="name"
               autocomplete="name"
               required
               minlength="2"
               aria-describedby="name-hint">
        <p id="name-hint" class="hint">As it appears on your ID</p>
    </div>

    <!-- Email with inputmode -->
    <div class="field">
        <label for="email">Email</label>
        <input type="email" id="email" name="email"
               autocomplete="email"
               inputmode="email"
               required
               aria-describedby="email-error">
        <p id="email-error" class="error" role="alert" hidden></p>
    </div>

    <!-- Phone with pattern -->
    <div class="field">
        <label for="phone">Phone</label>
        <input type="tel" id="phone" name="phone"
               autocomplete="tel"
               inputmode="tel"
               pattern="[0-9\-\+\(\)\s]+"
               aria-describedby="phone-hint">
        <p id="phone-hint" class="hint">Optional</p>
    </div>

    <button type="submit">Sign Up</button>
</form>
inputmode vs type type controls validation and semantics. inputmode only controls the mobile keyboard. Use inputmode="numeric" on a text input when you want a number pad but not number validation (e.g., credit card fields, OTP codes).

<dialog> & Popover

The <dialog> element provides built-in modal behavior with focus trapping, backdrop, and Escape-to-close. The Popover API provides lightweight, dismissable floating UI without any JavaScript for setup.

<!-- ===== Dialog — modal with backdrop ===== -->
<dialog id="confirm-dialog">
    <h2>Confirm Action</h2>
    <p>Are you sure you want to delete this item?</p>
    <form method="dialog">
        <button value="cancel">Cancel</button>
        <button value="confirm">Delete</button>
    </form>
</dialog>

<button onclick="document.getElementById('confirm-dialog').showModal()">
    Delete Item
</button>

<style>
/* Style the backdrop */
dialog::backdrop {
    background: rgba(0, 0, 0, 0.6);
    backdrop-filter: blur(4px);
}

dialog {
    border: 4px solid black;
    padding: 2rem;
    max-width: 480px;
}

/* Animate open/close (Baseline 2025) */
dialog[open] {
    opacity: 1;
    transform: scale(1);
    transition: opacity 0.3s, transform 0.3s,
                display 0.3s allow-discrete,
                overlay 0.3s allow-discrete;
}

dialog {
    opacity: 0;
    transform: scale(0.95);
    transition: opacity 0.2s, transform 0.2s,
                display 0.2s allow-discrete,
                overlay 0.2s allow-discrete;
}

@starting-style {
    dialog[open] { opacity: 0; transform: scale(0.95); }
}
</style>
<!-- ===== Popover API — no JS required ===== -->

<!-- Tooltip -->
<button popovertarget="tooltip-1"
        popovertargetaction="toggle">
    Help
</button>
<div id="tooltip-1" popover>
    This is a tooltip! Click outside or press Escape to close.
</div>

<!-- Dropdown menu -->
<button popovertarget="dropdown-menu">Menu</button>
<nav id="dropdown-menu" popover>
    <a href="/profile">Profile</a>
    <a href="/settings">Settings</a>
    <a href="/logout">Log Out</a>
</nav>

<style>
/* Style popover */
[popover] {
    border: 2px solid black;
    padding: 1rem;
    background: white;
    box-shadow: 4px 4px 0 black;
}

/* :popover-open targets the open state */
[popover]:popover-open {
    opacity: 1;
}
</style>

Form Validation

Native constraint validation gives you powerful form checks without JavaScript. The key is using :user-valid and :user-invalid to only show validation feedback after the user has interacted with a field.

/* ===== Only show validation after user interaction ===== */

/* :user-invalid — only matches AFTER the user has changed the field */
input:user-invalid {
    border-color: #D40920;
    background: #fbe8ea;
    outline: 2px solid #D40920;
}

input:user-valid {
    border-color: #0a8a2e;
}

/* Show/hide error messages */
.error-message {
    display: none;
    color: #D40920;
    font-size: 0.875rem;
}

input:user-invalid ~ .error-message {
    display: block;
}

/* ===== Constraint validation attributes ===== */
<!-- Required field -->
<input type="text" required>

<!-- Pattern matching -->
<input type="text" pattern="[A-Z]{2}[0-9]{4}"
       title="2 uppercase letters followed by 4 digits">

<!-- Number range -->
<input type="number" min="1" max="100" step="5">

<!-- Length constraints -->
<input type="text" minlength="3" maxlength="50">

<!-- Custom validation with JavaScript -->
<script>
const password = document.getElementById('password');
const confirm = document.getElementById('confirm');

confirm.addEventListener('input', () => {
    if (confirm.value !== password.value) {
        confirm.setCustomValidity('Passwords do not match');
    } else {
        confirm.setCustomValidity('');
    }
});
</script>
Pseudo-Class Matches When Use Case
:valid Input passes validation Immediate feedback (rarely wanted)
:invalid Input fails validation Immediate feedback (rarely wanted)
:user-valid Passes validation after user interaction Success indicators
:user-invalid Fails validation after user interaction Error styling and messages
:required Has required attribute Visual required indicator
:optional No required attribute Dim optional fields
:placeholder-shown Placeholder is visible (empty) Floating label patterns
:in-range / :out-of-range Number within/outside min-max Range feedback

Accessibility Essentials

Semantic HTML provides built-in accessibility. ARIA attributes fill the gaps when HTML semantics are insufficient. The first rule of ARIA: do not use ARIA if a native HTML element will do the job.

ARIA Landmark Equivalent HTML Purpose
role="banner" <header> (top-level) Site header
role="navigation" <nav> Navigation links
role="main" <main> Primary content
role="complementary" <aside> Supporting content
role="contentinfo" <footer> (top-level) Site footer
role="search" <search> Search functionality
<!-- Skip link — first focusable element on the page -->
<a href="#main-content" class="skip-link">Skip to main content</a>

<style>
.skip-link {
    position: absolute;
    top: -100%;
    left: 0;
    padding: 1rem;
    background: #000;
    color: #fff;
    z-index: 9999;
}

.skip-link:focus {
    top: 0;
}
</style>

<!-- Live region for dynamic content updates -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
    <!-- Screen reader announces changes here -->
</div>

<!-- Expanding/collapsing widget -->
<button aria-expanded="false" aria-controls="panel-1">
    Show Details
</button>
<div id="panel-1" hidden>
    Panel content here.
</div>

<!-- Error alert -->
<div role="alert">
    Form submission failed. Please fix the errors below.
</div>

<!-- Visually hidden but accessible to screen readers -->
<style>
.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}
</style>

Focus Management

:focus-visible shows focus rings only for keyboard navigation, not mouse clicks. The inert attribute disables interaction and removes elements from the tab order. Together they provide clean, accessible focus behavior.

/* ===== :focus-visible vs :focus ===== */

/* :focus — ALL focus (keyboard + mouse + programmatic) */
button:focus {
    outline: 2px solid blue;  /* shows on click too */
}

/* :focus-visible — keyboard focus only */
button:focus-visible {
    outline: 3px solid var(--stijl-blue);
    outline-offset: 2px;
}

/* Remove default for mouse, keep for keyboard */
button:focus:not(:focus-visible) {
    outline: none;
}

/* ===== :focus-within — style parent when child is focused ===== */
.search-wrapper:focus-within {
    border-color: var(--stijl-blue);
    box-shadow: 0 0 0 3px rgba(19, 86, 162, 0.2);
}

.form-group:focus-within label {
    color: var(--stijl-blue);
    font-weight: 700;
}

/* ===== Outline best practices ===== */
:focus-visible {
    outline: 3px solid var(--stijl-blue);
    outline-offset: 2px;
    /* Never outline: none without a visible alternative */
    /* Minimum 3:1 contrast against adjacent colors */
}
<!-- ===== inert attribute — disable an entire subtree ===== -->
<main id="main-content">
    <!-- Normal interactive content -->
</main>

<div id="modal-overlay">
    <div role="dialog" aria-modal="true" aria-labelledby="modal-title">
        <h2 id="modal-title">Settings</h2>
        <!-- Modal content -->
    </div>
</div>

<script>
// When modal opens, make background inert
function openModal() {
    document.getElementById('main-content').inert = true;
    document.getElementById('modal-overlay').hidden = false;
    document.getElementById('modal-overlay').querySelector('[role="dialog"]').focus();
}

function closeModal() {
    document.getElementById('main-content').inert = false;
    document.getElementById('modal-overlay').hidden = true;
    // Return focus to trigger element
}
</script>

<!-- ===== tabindex usage ===== -->
<!-- tabindex="0" — add to tab order (natural position) -->
<div tabindex="0" role="button">Custom button</div>

<!-- tabindex="-1" — focusable via JS, not via Tab key -->
<div tabindex="-1" id="error-summary">Errors found</div>
<script>
document.getElementById('error-summary').focus();
</script>

<!-- AVOID positive tabindex — breaks natural order -->
<!-- <input tabindex="3"> DO NOT DO THIS -->
Never remove focus outlines outline: none without a visible replacement makes your site unusable for keyboard users. Use :focus-visible to hide outlines only for mouse interactions, and always ensure at least 3:1 contrast ratio on focus indicators.
12

Performance & Best Practices

CSS containment, loading strategies, architecture patterns, animation performance, and a modern best practices checklist.

CSS Containment

The contain property tells the browser that an element's internals are independent from the rest of the page, enabling rendering optimizations. content-visibility: auto takes this further by skipping rendering of off-screen content entirely.

/* ===== contain property ===== */

/* Layout containment — element size doesn't depend on children */
.widget { contain: layout; }

/* Paint containment — children don't paint outside bounds */
.card { contain: paint; }

/* Size containment — element size independent of children */
.sidebar { contain: size; }

/* Style containment — counters/quotes scoped to subtree */
.component { contain: style; }

/* content = layout + paint + style (most common) */
.isolated-widget { contain: content; }

/* strict = size + layout + paint + style (maximum isolation) */
.fully-isolated { contain: strict; }

/* ===== content-visibility: auto — skip off-screen rendering ===== */
.article-section {
    content-visibility: auto;
    contain-intrinsic-size: auto 500px;
    /* Browser estimates 500px height for off-screen sections
       "auto" remembers last rendered size after first paint */
}

/* Long list items */
.list-item {
    content-visibility: auto;
    contain-intrinsic-size: auto 80px;
}

/* Hidden content — skip rendering entirely */
.tab-panel:not(.active) {
    content-visibility: hidden;
    /* Like display:none but preserves state (scroll position, form data) */
}
Performance impact content-visibility: auto can reduce initial rendering time by 50-90% on long pages. The browser skips layout, paint, and style calculations for off-screen elements. Always pair with contain-intrinsic-size to prevent layout shift when elements scroll into view.

Loading Performance

Resource hints and lazy loading attributes let you control when and how assets are fetched. Prioritize above-the-fold content and defer everything else.

<head>
    <!-- Preconnect to external origins (fonts, CDNs) -->
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

    <!-- Preload critical font files -->
    <link rel="preload" href="/fonts/main.woff2"
          as="font" type="font/woff2" crossorigin>

    <!-- Preload critical CSS (for code-split CSS) -->
    <link rel="preload" href="/css/above-fold.css"
          as="style">

    <!-- DNS prefetch for less critical origins -->
    <link rel="dns-prefetch" href="https://analytics.example.com">

    <!-- Critical CSS inlined -->
    <style>
        /* Only styles needed for above-the-fold content */
        /* Extracted from full stylesheet */
    </style>

    <!-- Full CSS loaded asynchronously -->
    <link rel="stylesheet" href="/css/main.css"
          media="print" onload="this.media='all'">
</head>

<body>
    <!-- High-priority hero image -->
    <img src="hero.webp"
         fetchpriority="high"
         decoding="async"
         alt="Hero image">

    <!-- Lazy-loaded below-fold images -->
    <img src="photo.webp"
         loading="lazy"
         decoding="async"
         width="800" height="600"
         alt="Description">

    <!-- Lazy-loaded iframe -->
    <iframe src="https://www.youtube.com/embed/..."
            loading="lazy"
            title="Video title">
    </iframe>
</body>
Attribute / Hint Purpose When to Use
rel="preconnect" DNS + TCP + TLS handshake early External origins used within seconds
rel="preload" Fetch critical resource immediately Fonts, critical CSS, hero images
rel="dns-prefetch" DNS lookup only Less critical external origins
fetchpriority="high" Boost resource fetch priority LCP image, critical scripts
loading="lazy" Defer fetch until near viewport Below-fold images and iframes
decoding="async" Decode image off main thread All images (no visual impact)

CSS Architecture

Modern CSS features like @layer, @scope, and native nesting replace the need for complex naming conventions and build-time tooling. Use layers for specificity management, scope for component boundaries, and nesting for readability.

/* ===== @layer — control specificity order ===== */
/* Layers declared first = lowest priority, regardless of source order */
@layer reset, base, components, utilities;

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

@layer base {
    body { font-family: system-ui; line-height: 1.6; }
    h1 { font-size: 2.5rem; }
}

@layer components {
    .btn { padding: 0.5rem 1rem; border: 2px solid; }
    .card { border: 1px solid #ddd; border-radius: 8px; }
}

@layer utilities {
    .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; }
    .text-center { text-align: center; }
}

/* Un-layered styles ALWAYS beat layered styles */
.override { color: red; /* wins over any @layer rule */ }
/* ===== @scope — component boundaries ===== */
@scope (.card) to (.card-footer) {
    /* Styles apply to .card descendants but NOT inside .card-footer */
    p { margin-bottom: 1rem; }
    a { color: var(--stijl-blue); }
}

@scope (.sidebar) {
    /* Scoped to .sidebar subtree */
    .title { font-size: 1.125rem; }
    .link { display: block; padding: 0.5rem; }
}

/* ===== Native CSS Nesting ===== */
.card {
    border: 2px solid black;
    padding: 1.5rem;

    .title {
        font-weight: 800;
        font-size: 1.25rem;
    }

    .body {
        color: #333;
    }

    &:hover {
        box-shadow: 4px 4px 0 black;
    }

    &.featured {
        border-color: var(--stijl-red);
    }

    @media (width >= 768px) {
        display: grid;
        grid-template-columns: 200px 1fr;
    }
}
BEM in 2025 BEM (.block__element--modifier) is still valid but less necessary with @scope and @layer. Use BEM for large teams or projects without modern CSS support. For greenfield projects, prefer native scoping and nesting.

Will-Change & Animation Performance

Only transform, opacity, and filter can be animated on the compositor thread without triggering layout or paint. The will-change property hints to the browser to prepare GPU resources for an element, but overuse wastes memory.

/* ===== Compositor-only properties (cheap to animate) ===== */
.animate-cheap {
    transition: transform 0.3s, opacity 0.3s;
    /* These run on the GPU compositor thread */
    /* No layout recalc, no repaint */
}

/* ===== Properties that trigger layout (expensive) ===== */
.animate-expensive {
    /* AVOID animating these: */
    /* width, height, margin, padding, top, left, right, bottom */
    /* font-size, border-width, line-height */
    transition: width 0.3s; /* triggers layout + paint on every frame */
}

/* Use transform instead of top/left */
.slide-in {
    /* BAD */
    /* top: 100px; transition: top 0.3s; */

    /* GOOD */
    transform: translateY(100px);
    transition: transform 0.3s;
}

/* ===== will-change — use sparingly ===== */
/* Apply BEFORE the animation starts, remove after */
.card:hover {
    will-change: transform;
}

/* Or apply via JavaScript just before animation */

/* DO NOT do this — wastes GPU memory for every card */
/* .card { will-change: transform, opacity; } */

/* ===== requestAnimationFrame bridge ===== */
<script>
// Batch DOM reads and writes to avoid layout thrashing
function updateCards(cards) {
    // BAD: read-write-read-write forces synchronous layout
    // cards.forEach(card => {
    //     const height = card.offsetHeight;    // read
    //     card.style.height = height + 10 + 'px'; // write
    // });

    // GOOD: batch reads, then batch writes
    const heights = cards.map(card => card.offsetHeight);

    requestAnimationFrame(() => {
        cards.forEach((card, i) => {
            card.style.transform = `translateY(${heights[i]}px)`;
        });
    });
}
</script>
Layout thrashing Reading layout properties (offsetHeight, getBoundingClientRect()) then immediately writing (style.height) forces the browser to recalculate layout synchronously. Batch all reads first, then all writes inside a requestAnimationFrame.

Modern Best Practices Checklist

A quick-reference grid of essential practices for building performant, accessible, and maintainable web pages in 2025.

Use Semantic HTML
Prefer <article>, <nav>, <main>, <section>, <aside> over generic <div>. Screen readers and search engines rely on semantics.
Prefer Logical Properties
Use margin-inline, padding-block, inset-inline instead of physical directions. Your layout works in RTL automatically.
Use clamp() for Fluid Sizes
font-size: clamp(1rem, 2.5vw, 2rem) gives you responsive typography without media queries.
Prefer oklch() for Colors
oklch() provides perceptually uniform colors and a wider gamut. Adjust lightness and chroma independently for consistent palettes.
Use @layer for Specificity
Define layer order upfront: @layer reset, base, components, utilities. No more !important wars.
Test prefers-reduced-motion
Wrap all animations in @media (prefers-reduced-motion: no-preference) or provide reduced alternatives.
Use content-visibility: auto
Skip rendering off-screen sections. Pair with contain-intrinsic-size to prevent layout shift. 50-90% faster initial paint.
Lazy Load Below the Fold
Add loading="lazy" to images and iframes below the fold. Use fetchpriority="high" for the LCP image.
Use Variable Fonts
One file replaces multiple weights. Use font-variation-settings or font-weight ranges for precise control.
Validate Forms Natively
Use required, pattern, min/max, type="email" etc. Style with :user-valid/:user-invalid for post-interaction feedback.
Use :focus-visible
Show focus rings for keyboard users only. Never remove outlines without a visible alternative. Minimum 3:1 contrast.
Test Keyboard Navigation
Tab through every interactive element. Check logical order, skip links, focus trapping in modals, and Escape to close.