2. HTML

Every page you open in a browser, from a search engine's home page to a single-page app built with a large framework, arrives as HTML or is turned into HTML before it reaches the screen. HTML (HyperText Markup Language) describes what each piece of content is: a heading, a paragraph, a link, a form field, a video. CSS (Chapters 3 to 5) decides how it looks, and JavaScript (Chapters 6 to 9) decides how it behaves, but both work on the element tree that HTML defines.HTML is also the most forgiving language you will write. A browser never refuses a page because of a missing end tag; it repairs the markup by rules written into the standard and renders something. That tolerance kept the web running for over thirty years, and it is a trap: a page can look right in your browser while screen readers, search engines and other browsers see a broken structure. This chapter teaches you to write markup that is correct by the standard, not just markup that happens to render.The language keeps growing. Modal dialogs, popovers, accordions, lazy-loaded images and reusable components once needed JavaScript libraries; now they are built into HTML and supported by every major browser.What you will learn:
  • How a document is parsed into a tree, and the rules for elements, attributes and nesting

  • Where the HTML Living Standard comes from and how to check whether a feature is safe to use

  • Structural, text, image, media and form elements, plus native dialogs, popovers and Web Components

  • Security attributes, Content Security Policy and sandboxed iframes

  • Accessibility with WAI-ARIA and WCAG 2.2, and metadata for SEO and social sharing

  • Performance hints in markup and libraries such as htmx, Alpine.js, Cheerio and jsdom


  • 2.1 HTML Fundamentals

    An HTML file is plain text. What makes it a web page is the browser's parser, which reads the text as a stream of tags and builds a tree of objects, the Document Object Model (DOM). Every later topic in this book, from CSS selectors to document.querySelector(), works on that tree, so it pays to know exactly how the text becomes nodes, which nestings the standard allows, and what the parser does when your markup breaks the rules.

    2.1.1 Anatomy of an HTML Document

    A minimal valid document has five parts: the DOCTYPE, the <html> root, a <head> with metadata, a <title>, and a <body> with the visible content. The example below adds the two <meta> tags that every real page needs and a script that prints what the browser made of the file.

    <!doctype html>

    <html lang="en">

    <head>

    <meta charset="utf-8">

    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Hello, HTML</title>

    </head>

    <body>

    <h1>Hello, HTML</h1>

    <p>Spaces and

    line breaks collapse into single spaces.</p>

    <p id="info"></p>

    <script>

    document.getElementById('info').textContent =

    `Mode: ${document.compatMode}, charset: ${document.characterSet}, ` +

    `children of <html>: ${[...document.documentElement.children].map(e => e.tagName)}`;

    </script>

    </body>

    </html>

    Listing 2.1: A complete minimal HTML document Figure 2.1: Browser output of Listing 2.1Each line has a job:
  • <!doctype html> switches the browser into no-quirks (standards) mode; document.compatMode reports CSS1Compat. Omit it and you get quirks mode (BackCompat), which emulates the layout bugs of Internet Explorer 5 and Netscape Navigator 4. The DOCTYPE is case-insensitive.

  • lang="en" tells screen readers which pronunciation to use and lets browsers pick hyphenation and translation.

  • <meta charset="utf-8"> declares the encoding. UTF-8 is the only valid choice, and the element must sit entirely within the first 1024 bytes of the file, so put it first in <head>.

  • The viewport <meta> stops mobile browsers from laying the page out in a virtual viewport, typically 980 pixels wide, and shrinking it to fit the screen.

  • <title> is required (exactly one) and appears in tabs, bookmarks and search results.

  • The listing also shows whitespace collapsing: runs of spaces and newlines in text render as one space. Use CSS white-space or <pre> when spacing matters. Under the hood: The parser inserts elements you leave out. The standard lets you omit the <html>, <head> and <body> tags, and even </p> and </li>, because the tree construction stage creates the missing nodes. A file containing only <title>X</title><p>Hi still yields html, head and body in the DOM. Writing them explicitly makes the structure obvious to readers and tools. Pitfall: a <meta charset> placed after a large inline <style> block can land beyond byte 1024. Browsers only prescan that far, so they start decoding with a guessed encoding and, when the late declaration disagrees, reload the page from memory with the right one. Section 2.2 covers each head and body element in detail.

    2.1.2 Elements, Attributes and Nesting Rules

    An element is a start tag, its content and an end tag. The attributes inside the start tag add information or change behavior. Tag and attribute names are case-insensitive, so <P CLASS=x> works, but lowercase is the universal convention.Figure 2.2: The parts of an HTML elementAttribute values may be double-quoted, single-quoted or, if they contain no spaces, quotes, =, <, > or backticks, unquoted. Boolean attributes such as hidden, disabled and required are true when present and false when absent, whatever their value: disabled="false" still disables. Enumerated attributes accept a fixed list of keywords (dir="rtl", autocomplete="off") and fall back to a default for invalid ones. Global attributes (id, class, style, lang, title, data-* and others, Section 2.9.1) work on every element.Thirteen void elements have no content and no end tag: area, base, br, col, embed, hr, img, input, link, meta, source, track and wbr. The trailing slash in <br /> is allowed but ignored; on a non-void element such as <div /> it is ignored too, so the div stays open and swallows what follows.Elements must nest properly: close the most recently opened one first. When they do not, the browser's parser repairs the tree by rules the standard defines precisely, so every engine builds the same DOM. You can watch it happen with parse5 (https://github.com/inikulin/parse5) (npm install parse5), the spec-compliant parser used by jsdom, Angular, Lit and Cheerio.

    import { parse, serialize } from 'parse5';

    const html = '<p>One<div>Two</div><b><i>Bold italic</b> italic?</i><ul><li>A<li>B</ul>';

    const doc = parse(html);

    const body = doc.childNodes[0].childNodes[1]; // <html> -> <body>

    console.log(serialize(body));

    Listing 2.2: How the HTML parser repairs invalid nesting (Node.js, parse5) <p>One</p><div>Two</div><b><i>Bold italic</i></b><i> italic?</i><ul><li>A</li><li>B</li></ul> Output of Listing 2.2 Three repairs happened. A <div> cannot sit inside a paragraph, so its start tag closed the <p> first. The misnested </b> triggered the adoption agency algorithm, which closes <i> and reopens a new <i> for the remaining text. And each <li> implicitly closed the previous one, a legal shortcut. Under the hood: The WHATWG parsing algorithm is a tokenizer state machine feeding a tree builder with a stack of open elements. Firefox's C++ parser is mechanically translated from Henri Sivonen's Java Validator.nu parser, and Chromium and WebKit implement the same algorithm, which is why error recovery is identical across browsers. Pitfall: the repair can surprise JavaScript. After <p><div> the div is a sibling of the paragraph, so p.querySelector('div') returns null. Validate with the Nu Html Checker (Section 2.12.4) to catch these errors before they become bugs.

    2.1.3 The HTML Living Standard and Browser Support

    Tim Berners-Lee's 1991 "HTML Tags" note described 18 elements. The language then passed through the IETF and the W3C, which froze numbered versions and bet the future on XML-based XHTML. In 2004 Apple, Mozilla and Opera, unhappy with that direction, founded the WHATWG to extend HTML for web applications; its work became known as "HTML5". For years two competing specifications existed, until the W3C and WHATWG agreed on May 28, 2019 to maintain a single one.Figure 2.3: From 18 tags to a Living StandardToday's HTML Living Standard (https://html.spec.whatwg.org/) has no version number. Editors merge changes as pull requests on GitHub, and a new feature needs support from at least two browser implementers plus matching tests in the shared web-platform-tests suite. Roughly every six months the WHATWG snapshots a Review Draft for patent review; the W3C endorsed the January 2020 draft as a Recommendation on January 28, 2021. "HTML5" survives only as a buzzword: no browser checks a version, and <!doctype html> means "the current standard".A feature in the standard is not automatically usable, so check support with Baseline, a status that the W3C WebDX Community Group computes for each feature over the core browser set: Chrome and Edge, Firefox, and Safari, on desktop and mobile. Newly available means every core browser ships it; widely available follows 30 months later; everything else is limited availability. MDN pages and caniuse.com show the badge. HTML feature Baseline status (September 2026) Since <dialog> Widely available March 2022 (newly) <search> Widely available April 2026 popover attribute Newly available January 2025 command / commandfor Newly available December 2025 Customizable <select> Limited (Chromium only) Chrome 135 Table 2.1: Baseline status of recent HTML features (web-features 3.38.0) The data lives in the open-source web-features (https://github.com/web-platform-dx/web-features) package (npm install web-features), which build tools consume. Browserslist, which drives Autoprefixer and Babel, accepts the query baseline widely available, so your whole toolchain can target the same definition. Did you know? Browser vendors, Igalia and others also run Interop, a yearly push to fix incompatibilities. Interop 2026, announced February 12, 2026, has 20 focus areas including dialogs, popovers and view transitions, with scores tracked at wpt.fyi. Practical rule: use widely available features freely, use newly available ones with a fallback or a quick check of your analytics, and treat limited features as progressive enhancements that the page must work without.

    2.1.4 Content Categories

    Every element definition in the HTML standard lists two things: the categories the element belongs to and its content model, the categories it may contain. A <p> is flow content whose content model is phrasing content; a <div> is flow content that accepts flow content. That is the whole reason <div> may hold a <p> but <p> may not hold a <div>, and why the parser in Section 2.1.2 closed the paragraph.Figure 2.4: HTML content categories and how they overlap Category Meaning Examples Metadata Sets up the document; mostly in <head> title, link, meta, script Flow Almost anything allowed in <body> div, p, ul, form, main Sectioning Scopes header and footer article, aside, nav, section Heading Titles a section h1-h6, hgroup Phrasing Text and in-paragraph markup span, em, a, img, input Embedded Imports another resource or vocabulary iframe, video, svg, math Interactive Meant for user interaction a[href], button, details Table 2.2: The main content categories of HTML Some memberships are conditional: <a> is interactive only with href, and <video> only with controls. Interactive content may not contain other interactive content, so a <button> inside an <a href> is invalid. Elements such as <a>, <ins>, <del> and <map> have a transparent content model: they accept whatever their parent accepts. That is why HTML allows an <a> wrapping a whole card of headings and paragraphs inside a <div>, but only phrasing content when the <a> sits inside a <p>.HTML 4.01 split body elements into block-level and inline elements, a rule that mixed three ideas: what an element may contain, whether it starts on a new line, and how it inherits text direction. HTML5 replaced the content half with the categories above and handed the visual half to CSS. "Block" and "inline" now describe the CSS display property: a <div> looks like a block only because the browser's default stylesheet gives it display: block. Changing that with CSS (Chapter 4) never changes validity, so span { display: block } still cannot contain a <div>. Tip: A common interview question is "What is the difference between block and inline elements?" The strong answer separates the two layers: flow versus phrasing content decides valid nesting in HTML, while display: block versus display: inline decides layout in CSS.

    2.2 Structural Elements

    Structural elements are the skeleton that every other tag hangs on. Two top-level branches split the document by audience: <head> speaks to machines (the browser, search engines, social networks, password managers), and <body> speaks to people. Inside the body, sectioning elements such as <header>, <nav>, <main> and <article> tell assistive technology where the regions of the page begin and end, which is why a screen reader user can jump straight to the navigation or the main content with a single keystroke.Two structural elements create trees inside the tree. An <iframe> embeds a complete second document with its own window, and a <template> holds inert markup that JavaScript can stamp out, often into a shadow root whose <slot> elements pull in content from the page. The figure below shows how these pieces fit together; the subsections follow the same order, from the DOCTYPE at the top to the landmark roles that screen readers derive from the body.Figure 2.5: The structural elements of a page: metadata in the head, landmarks in the body, and nested trees

    2.2.1 Declaration: <!DOCTYPE> and <html>

    <!DOCTYPE html> must be the first thing in the file; only whitespace, comments and a byte order mark may come before it. It is not an element and never appears as one in the DOM: the parser turns it into a DocumentType node, available as document.doctype. Its only remaining job is to put the browser in no-quirks mode, as Section 2.1.1 showed. The keyword is case-insensitive, so <!doctype html> (the form most formatters emit) is identical.The HTML 4.01 and XHTML 1.0 DOCTYPEs with their long public identifiers and DTD URLs still trigger standards mode, but they add nothing, and Section 2.9.2 lists them for when you maintain old pages. The only other conforming variant is <!DOCTYPE html SYSTEM "about:legacy-compat">, which exists for XML tools (such as XSLT processors) that cannot output a DOCTYPE without a system identifier. History: HTML 4.01 had three DOCTYPEs (Strict, Transitional, Frameset), each pointing to a Document Type Definition. Validators read the DTD, but no browser ever used the DOCTYPE for anything except choosing between quirks and standards rendering, so HTML5 reduced it to the simplest string that still flips that switch. <html> is the root element, exposed as document.documentElement. Its content model is exactly one <head> followed by one <body>. The start and end tags are optional, but write them, because the root is where you declare document-wide facts:
  • lang sets the language of all the text. Screen readers choose a voice from it, browsers use it for hyphenation, spell checking, translation prompts, font selection for CJK text and language-specific quotation marks, and CSS can target it with :lang().

  • dir="rtl" (or ltr, auto) sets the base text direction for Arabic, Hebrew or Persian pages (see Section 2.3.3).

  • class and data-* attributes on the root are the usual hook for page-wide state such as a dark theme, because every element can see the root in a CSS selector.

  • <!doctype html>

    <html lang="en">

    <head>

    <meta charset="utf-8">

    <title>lang demo</title>

    <style>

    body { font: 17px/1.4 Georgia, serif; margin: 12px; }

    p { border-left: 4px solid #1565c0; padding-left: 8px; }

    :lang(de) { border-color: #ef6c00; }

    :lang(fr) { border-color: #2e7d32; }

    code { display: block; font-size: 15px; }

    </style>

    </head>

    <body>

    <p>She said <q>structure first</q>, and meant it.</p>

    <p lang="de">Er sagte <q>Struktur zuerst</q> und meinte es.</p>

    <p lang="fr">Elle a dit <q>la structure d'abord</q> et le pensait.</p>

    <code id="out"></code>

    <script>

    const root = document.documentElement;

    out.textContent = `<${root.localName}> lang=${root.lang}; doctype: ${document.doctype.name}`;

    </script>

    </body>

    </html>

    Listing 2.3: The lang attribute changes quotation marks and :lang() styling Figure 2.6: Browser output of Listing 2.3The same <q> element produces English, German and French quotation marks because browsers default the CSS quotes property to the behavior of auto, which picks marks for the inherited language, and each :lang() rule matches its paragraph because language is inherited from the nearest ancestor with lang. Hyphenation with hyphens: auto works the same way, but only for languages the browser has a dictionary for.Old templates often start with <html xmlns="http://www.w3.org/1999/xhtml">. In a document served as text/html that attribute is allowed only with exactly that value, and the specification says it "has absolutely no effect. It is basically a talisman." It matters only in the XML serialization of HTML (XHTML), which a server triggers by sending Content-Type: application/xhtml+xml. Then the XML parser is used, namespaces are real, tag names are case-sensitive, and a single unclosed tag stops rendering with an error page instead of being repaired. Did you know? The namespace value is a URI used as a name, not a location, and nothing is ever fetched from it. A URL is a URI that also says how to reach the resource (https://...). An IRI generalizes the URI to allow non-ASCII characters, as in https://例え.jp/パス; before a request, the host is converted to Punycode and the path is percent-encoded. Pitfalls: the manifest attribute (for the retired Application Cache) and the version attribute on <html> are obsolete; the specification points you to service workers (Section 9.4) for offline support. Set lang to the language of the content rather than the visitor's locale, and override it on any element that contains another language.

    2.2.2 Head

    <head> holds metadata content: information about the document rather than the document itself. The specification requires exactly one <title> and allows at most one <base>; everything else is optional. The order matters more than most people expect. <meta charset> must sit within the first 1024 bytes, <base> must precede every URL it should affect, and style sheets block rendering until they arrive, so the head of a production page usually looks like this:

    <head>

    <meta charset="utf-8">

    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Pricing | Acme Analytics</title>

    <meta name="description" content="Plans for teams of 1 to 10,000, with a free tier.">

    <meta name="color-scheme" content="light dark">

    <meta name="theme-color" content="#1565c0">

    <link rel="canonical" href="https://acme.example/pricing">

    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

    <link rel="stylesheet" href="/css/site.css">

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

    <!-- Non-critical CSS: load without blocking rendering, with a no-JS fallback -->

    <link rel="stylesheet" href="/css/comments.css" media="print" onload="this.media='all'">

    <noscript><link rel="stylesheet" href="/css/comments.css"></noscript>

    <link rel="icon" href="/favicon.svg" type="image/svg+xml">

    <link rel="manifest" href="/site.webmanifest">

    <style>

    .hero { min-height: 60vh; } /* small critical rules inline */

    </style>

    <script src="/js/app.js" type="module"></script>

    </head>

    Listing 2.4: A production head, in the order browsers benefit from <title> supplies the text for the browser tab, bookmarks, history, screen-reader announcements when the page loads, and the headline of a search result. It is an escapable raw text element: character references such as &amp; are decoded, but tags inside it are shown literally, never parsed. Read or change it with document.title. Google sets no length limit but truncates long titles to fit the device width, and recommends a unique, descriptive title per page without repeated keywords. Put the page-specific part first (Pricing | Acme Analytics), because tabs and search results cut off the end.<style> embeds CSS; <link rel="stylesheet"> references an external file, which the browser can cache across pages. Both accept media (the sheet applies only when the media query matches, though a non-matching <link> is still downloaded at low priority) and blocking="render", which is currently supported only in Chromium and Safari. The type="text/css" attribute you see in old code is obsolete: CSS is the only style language browsers support.The rel attribute turns <link> into a general-purpose relationship declaration. The keywords below are the ones you will meet most; Section 2.12.1 covers icons, Section 2.13 the performance hints, and Section 2.8 the crossorigin and integrity attributes. rel value What the browser or crawler does stylesheet Downloads and applies CSS; render-blocking unless media does not match icon, manifest Tab icon; web app manifest for installable apps canonical, alternate Preferred URL of duplicate content; translations (hreflang) or feeds preload (+ as) Fetches a resource needed by this page early, at high priority modulepreload Fetches a JavaScript module into the module map ahead of time preconnect, dns-prefetch Opens a connection, or only resolves DNS, for a third-party origin prefetch Caches a resource for a likely next navigation, at idle priority next, prev Series navigation hints; Google no longer uses them Table 2.3: Common link relationship keywords Tip: Many older tutorials load non-critical CSS with <link rel="preload" as="style" onload="this.rel='stylesheet'">. Filament Group, who popularized the trick, switched their loadCSS project to the simpler media="print" onload="this.media='all'" pattern shown in the listing above, calling it simpler and noting that preload could create unhelpful resource priority conflicts. <base href> changes the URL that every relative URL in the document resolves against, and <base target> sets the default browsing context for links and forms. It must come before any element with a URL attribute, and a document may contain only one.

    <!doctype html>

    <html lang="en">

    <head>

    <meta charset="utf-8">

    <title>base demo</title>

    <base href="https://cdn.example.com/docs/v2/" target="_blank">

    </head>

    <body style="font: 16px system-ui; margin: 12px">

    <a href="intro.html">intro.html</a> | <a href="../v1/">../v1/</a> |

    <a href="/login">/login</a> | <a href="#faq">#faq</a>

    <pre id="out"></pre>

    <script>

    out.textContent = [...document.links]

    .map(a => `${a.getAttribute('href').padEnd(11)} -> ${a.href}`).join('\n');

    </script>

    </body>

    </html>

    Listing 2.5: base changes how relative URLs resolve Figure 2.7: Browser output of Listing 2.5The last line is the classic <base> trap: a fragment link such as #faq also resolves against the base URL, so it navigates away from the page instead of scrolling. Single-page applications that set <base href="/app/"> break in-page anchors this way, which is why most sites avoid <base> and use root-relative URLs.<meta> has four forms: charset declares the encoding, name with content gives document metadata, http-equiv with content is a pragma directive that mimics a few HTTP headers, and property (from the Open Graph protocol, Section 2.11.2) is read by social networks. The table lists the values worth knowing. Tag Effect charset="utf-8" Encoding; must be first, within 1024 bytes name="viewport" Mobile layout width and zoom; keys width, initial-scale, viewport-fit, interactive-widget name="description" Summary that search engines may show as the snippet name="robots" / googlebot Crawler rules such as noindex, nofollow, nosnippet name="color-scheme" Supported color schemes, applied before CSS loads name="theme-color" Browser UI color; Safari and Chrome on Android only name="referrer" Default referrer policy (Section 2.8.3) name="author", generator, application-name Informational metadata name="keywords" Standard, but ignored by Google Search http-equiv="refresh" Reload or redirect after N seconds: content="5; url=/new" http-equiv="content-security-policy" CSP without frame-ancestors, report-uri or sandbox http-equiv="x-ua-compatible" Internet Explorer only; ignored by current browsers Table 2.4: Meta tags and pragma directives Warning: Never add user-scalable=no or a low maximum-scale to the viewport tag. Blocking zoom locks out low-vision users, and Safari on iOS ignores both settings anyway. Likewise avoid http-equiv="refresh" redirects: an HTTP 301 or 308 response is faster, keeps browser history clean and is what crawlers expect. <noscript> renders its content only when scripting is disabled or blocked. Inside <head> it may contain only <link>, <style> and <meta>, which makes it the natural fallback for the async style sheet in the first listing. In <body> it can hold any flow content, typically a message that the application needs JavaScript. Keep it for genuine fallbacks; it does nothing when a script fails to download or throws an error. Under the hood: While the main parser waits for a render-blocking style sheet or a synchronous script, a second, speculative preload scanner races ahead through the raw HTML and starts fetching the style sheets, scripts and images it finds. Resources that exist only in JavaScript or CSS (injected scripts, data-src lazy loaders, background-image URLs) are invisible to it, which is the problem rel="preload" solves.

    2.2.3 Sectioning

    <body> holds everything the user sees. Inside it, a small set of elements describes what role each block plays. They look identical to <div> until you style them, but browsers, reader modes, search engines and screen readers treat them differently, so choose by meaning, never by appearance. The layout below uses most of them. To save space, this and the following rendered listings show only a <style> block and the body content; wrap them in the skeleton from Section 2.1.1 when you use them.

    <style>

    body { font: 15px/1.4 system-ui; margin: 0; display: grid; grid-template-columns: 1fr 170px; }

    header, footer { grid-column: 1 / -1; background: #1565c0; color: #fff; padding: 6px 14px; }

    header h1 { margin: 0; font-size: 20px; } nav a { color: #fff; margin-right: 12px; }

    main, aside { padding: 0 14px; } aside { background: #fff3e0; font-size: 13px; }

    article { border: 1px solid #ccc; padding: 0 10px; } h2, h3 { margin: 8px 0 4px; }

    p { margin: 4px 0; } hr { border: 0; border-top: 2px dotted #999; }

    </style>

    <header>

    <h1>Trail Notes</h1>

    <nav aria-label="Primary">

    <a href="/">Home</a><a href="/hikes">Hikes</a><a href="/gear">Gear</a>

    </nav>

    </header>

    <main>

    <article>

    <h2>Crossing the Ridge in October</h2>

    <p>The first frost arrived early, and the path above the tree line was glazed by 7 a.m.</p>

    <hr>

    <section>

    <h3>What to pack</h3>

    <p>Microspikes, a spare layer and twice the water you think you need.</p>

    </section>

    </article>

    </main>

    <aside><h2>Related</h2><p>Choosing trekking poles</p><p>Reading a topo map</p></aside>

    <footer><small>&copy; 2026 Trail Notes</small></footer>

    Listing 2.6: A semantic page layout Figure 2.8: Browser output of Listing 2.6
  • <main> wraps the content unique to this page, excluding repeated headers, navigation and footers. A document may have only one visible <main>, and it must not sit inside <article>, <aside>, <header>, <footer> or <nav>. A "skip to content" link should point at it.

  • <header> and <footer> hold introductory and closing content for the page or for the nearest article or section, so an article can have its own. Neither may contain another <header> or <footer>.

  • <nav> marks major navigation blocks: the site menu, a table of contents, pagination. A list of social links in the footer does not need one. When a page has several, name them with aria-label.

  • <aside> is content tangentially related to its surroundings: a sidebar, a pull quote, a glossary box.

  • <article> is self-contained content that would make sense on its own in a feed or on another site: a blog post, a forum reply, a product card, a comment. Articles can nest (comments inside a post).

  • <section> is a thematic group of content that normally starts with a heading. The specification says it "is not a generic container element": if you need a wrapper only for styling or scripting, use <div>, the element of last resort that carries no meaning at all.

  • <p> is a paragraph of phrasing content, and <hr> is a thematic break between paragraphs, such as a scene change, rather than a decorative line. Its old size, width, noshade and color attributes are obsolete; style it with CSS as the listing does.

  • Tip: A common interview question is "What is the difference between <section>, <article> and <div>?" Test the content: if it could be syndicated alone, it is an <article>; if it is a titled part of something larger, a <section>; if the only reason for the element is a CSS class, a <div>. Two sectioning-level elements are interactive without a line of JavaScript. <details> is a disclosure widget: its first child <summary> is the always-visible label, and clicking it toggles the open attribute and fires a toggle event. <dialog> is a box that stays hidden until opened. Call show() for a non-modal dialog or showModal() for a modal one, which is placed in the browser's top layer above all other content, makes the rest of the page inert, closes on Esc and can be styled behind with ::backdrop. A <form method="dialog"> inside it closes the dialog on submit and sets dialog.returnValue to the value of the button that was pressed.

    <style>

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

    details { border: 1px solid #ccc; border-radius: 6px; padding: 6px 10px; margin: 0 0 8px; }

    dialog { border: 0; border-radius: 10px; box-shadow: 0 8px 30px #0005; }

    dialog::backdrop { background: #0d47a180; }

    </style>

    <details><summary>Shipping costs</summary>Free on orders over $50.</details>

    <details open><summary>Returns</summary>Return any item within 30 days for a refund.</details>

    <button id="del">Delete account</button>

    <dialog id="dlg">

    <form method="dialog">

    <p>Delete your account permanently?</p>

    <button value="cancel" autofocus>Cancel</button> <button value="yes">Delete</button>

    </form>

    </dialog>

    <script>

    del.onclick = () => dlg.showModal();

    dlg.showModal(); // open it now so the screenshot shows the modal state

    </script>

    Listing 2.7: A disclosure widget and a modal dialog Figure 2.9: Browser output of Listing 2.7Both elements are Baseline Widely available: <details> since July 2022 and <dialog> since September 2024. Newer additions such as the name attribute that turns several <details> into an exclusive accordion, and the closedby attribute on <dialog>, are covered with the popover API in Section 2.6. Under the hood: The rendering section of the HTML standard builds <details> from an internal shadow tree: one slot takes the first <summary>, a second slot takes everything else and gets content-visibility: hidden while the element is closed. Since Chrome 131, Firefox 143 and Safari 18.4 you can style that second slot with the ::details-content pseudo-element, for example to animate the opening. Your own components can use the same slot mechanism, as Section 2.2.5 shows. Pitfalls: do not toggle a dialog's open attribute by hand. The specification advises against removing it manually: no close event fires, close() can no longer close it, and a modal dialog leaves the page blocked. Call close() instead. And keep links and buttons out of <summary>: the summary is itself exposed as a button, so nested controls are hard to reach with a keyboard or screen reader.

    2.2.4 <iframe>

    An <iframe> (inline frame) embeds a second, complete document inside the page, with its own window, document, history and JavaScript globals. Maps, video players, payment forms, ads, code playgrounds and chat widgets are all iframes. If the embedded document comes from the same origin, the parent can reach into it through iframe.contentDocument; if not, the same-origin policy walls it off, and the two sides can talk only through window.postMessage() (see Section 8.2 for the Window object). Attribute Purpose src / srcdoc URL to load, or inline HTML that overrides src title Accessible name that screen readers announce; always set it width, height Size in CSS pixels (default 300 x 150); CSS works too name Target name for <a target> and <form target> loading="lazy" Defers loading until the frame nears the viewport allow, allowfullscreen Permissions policy for camera, geolocation, fullscreen (Section 2.8.6) sandbox Applies restrictions, lifted one token at a time (Section 2.8.7) referrerpolicy How much of the parent URL is sent as Referer Table 2.5: iframe attributes srcdoc is handy for previews, sandboxed user content and demos, because the document needs no separate file. A srcdoc frame shares the parent's origin unless it is sandboxed, so the parent can measure it and grow the frame to fit its content, a trick that cross-origin frames cannot use.

    <style>

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

    iframe { display: block; width: 100%; margin: 8px 0; border: 2px solid #1565c0; }

    </style>

    <iframe title="Release note" srcdoc="<body style='font:15px system-ui; margin:0; padding:8px'>

    <b>Version 2.4</b> adds offline mode and a dark theme.

    <p id=p>Scripts blocked</p>

    <script>p.textContent = 'Frame title: ' + frameElement.title</script>"

    onload="this.style.height = this.contentDocument.body.scrollHeight + 'px'"></iframe>

    <iframe title="Untrusted widget" sandbox height="40"

    srcdoc="<body style='font:15px system-ui; margin:0; padding:8px'>

    <b id=p>Scripts blocked</b>

    <script>p.textContent = 'Frame title: ' + frameElement.title</script>"></iframe>

    Listing 2.8: Auto-sizing a srcdoc frame, and a sandbox blocking scripts Figure 2.10: Browser output of Listing 2.8The first frame's script runs and reads the title of its own <iframe> element through frameElement, and the parent's onload handler resizes the frame so no scroll bar appears. The second frame has an empty sandbox attribute, which applies every restriction: no scripts, no forms, no popups, no top-level navigation and an opaque origin.A hidden same-origin frame can print a document without displaying it, and a YouTube player is a cross-origin frame whose allow list grants the features the player needs:

    <iframe id="receipt" src="/orders/1042/receipt" hidden></iframe>

    <button onclick="receipt.contentWindow.print()">Print receipt</button>

    <iframe width="560" height="315" loading="lazy" title="Product tour video"

    src="https://www.youtube-nocookie.com/embed/VIDEO_ID"

    allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen></iframe>

    Listing 2.9: Printing through a hidden frame, and a privacy-enhanced YouTube embed The youtube-nocookie.com domain is YouTube's privacy-enhanced mode, in which views of the embed do not influence the viewer's YouTube recommendations or ads. Each player still downloads the full player code even if nobody presses play, so pages with many videos often use lite-youtube-embed (https://github.com/paulirish/lite-youtube-embed) (Apache-2.0, npm install lite-youtube-embed), a custom element that shows a thumbnail and swaps in the real iframe only on click. For cross-origin auto-sizing, iframe-resizer (https://github.com/davidjbradshaw/iframe-resizer) runs a script on both sides that reports the content height through postMessage(); version 4 was MIT-licensed, but version 5 is GPL-3.0 or a paid commercial license, so a closed-source site either buys one or writes the dozen lines of ResizeObserver plus postMessage() itself. Under the hood: An iframe is a full browsing context, and in modern browsers often a separate operating system process. Chrome 67 enabled Site Isolation for all sites on desktop, putting cross-site documents, including iframes, into different renderer processes as a defense against Spectre-style attacks; Firefox shipped the equivalent architecture, Fission, on desktop in version 95. Every frame therefore adds memory, a network round trip and its own script startup, which is why loading="lazy" for below-the-fold frames matters. Lazy iframes became Baseline Widely available in June 2026. Pitfalls: many sites forbid framing with the X-Frame-Options header or the CSP frame-ancestors directive, and the frame then shows an error page (Section 2.8.5). The attributes frameborder, scrolling, marginwidth, marginheight and allowtransparency from old embed codes, including YouTube's own, are obsolete; use CSS border and overflow instead. Finally, never combine allow-scripts with allow-same-origin for a same-origin document: the specification warns that the framed page can simply remove its own sandbox and reload.

    2.2.5 Shadow DOM: <template> and <slot>

    <template> holds a fragment of markup that the browser parses but does not render. Its contents are inert: scripts inside do not run, images do not load, styles do not apply, and document.querySelector() cannot find them. JavaScript reaches the fragment through the content property, a DocumentFragment, and stamps out copies with content.cloneNode(true). That makes templates the native, safe alternative to building markup with string concatenation and innerHTML, and to the old trick of hiding markup in <script type="text/template">.<slot> is a placeholder inside a shadow root, the private DOM subtree that a custom element can attach to itself with attachShadow(). The element's ordinary children, called the light DOM, stay in the page, but they are displayed at the slot positions: a child with slot="title" goes into <slot name="title">, and all other children go into the unnamed default slot. Content written inside a <slot> is fallback shown when nothing is assigned. Note that the slot attribute alone does nothing; only an element with a shadow root distributes children into slots.

    <style>body { font: 15px system-ui; margin: 12px; display: flex; gap: 12px; }</style>

    <template id="card-tpl">

    <style>

    :host { display: block; width: 260px; border: 1px solid #ccc; border-radius: 8px; }

    header { background: #6a1b9a; color: #fff; padding: 6px 10px; border-radius: 8px 8px 0 0; }

    .body { padding: 4px 10px; }

    ::slotted(p) { margin: 6px 0; }

    </style>

    <header><slot name="title">Untitled</slot></header>

    <div class="body"><slot>No details yet.</slot></div>

    </template>

    <info-card>

    <strong slot="title">Offline mode</strong>

    <p>Pages you visited load without a connection.</p>

    <p>Changes sync when you are back online.</p>

    </info-card>

    <info-card></info-card>

    <script>

    customElements.define('info-card', class extends HTMLElement {

    constructor() {

    super();

    const tpl = document.getElementById('card-tpl');

    this.attachShadow({ mode: 'open' }).append(tpl.content.cloneNode(true));

    }

    });

    </script>

    Listing 2.10: A card component built from a template with named and default slots Figure 2.11: Browser output of Listing 2.10The first card fills both slots from its light DOM; the second has no children, so both slots show their fallback text. The <style> inside the template is scoped to each shadow root: header there cannot match any other header on the page, and page styles cannot reach into the card except through inherited properties (such as font), CSS custom properties and the ::part() selector. :host styles the custom element itself, and ::slotted() styles the assigned light-DOM elements themselves, not their descendants or text nodes.From script, slot.assignedElements() returns what a slot currently displays, and the slotchange event fires when that set changes, so a tab component can rebuild its tab list when tabs are added. Everything here is Baseline Widely available: <template> since 2018, and shadow DOM with <slot> since July 2022. Under the hood: The parser moves a template's children into a fragment owned by a separate template contents owner document, a document with no browsing context, which is why nothing in it runs or loads. When rendering, the browser builds a flat tree in which each slot is replaced by its assigned nodes; the DOM itself is unchanged, so card.children still returns the light-DOM paragraphs. Events from slotted content propagate through the shadow tree, and events from inside the shadow root are retargeted so outside listeners see the host element as event.target. Templates are also the engine inside libraries. The lit-html renderer in Lit (https://github.com/lit/lit) (BSD-3-Clause, npm install lit, version 3.3.3 at the time of writing) turns a tagged template literal into a <template> element once, clones it for each instance, and afterwards updates only the parts holding changed values. Section 2.7.1 covers the custom element lifecycle, Section 2.7.2 the declarative <template shadowrootmode="open"> form that creates shadow roots without JavaScript (Baseline Widely available since August 2026), and Section 8.4.9 the full attachShadow() API.Pitfalls: forgetting cloneNode(true) and appending tpl.content directly moves the nodes out of the template, so the second use gets an empty fragment. And remember that the shadow tree is not a security boundary: mode: 'closed' only hides element.shadowRoot from casual access.

    2.2.6 Document Outline and Landmark Roles

    Screen-reader users rarely read a page from top to bottom. They skim it through two structures that your markup creates: the outline of headings and the set of landmarks. In NVDA, H jumps to the next heading, 1 to 6 to the next heading of that level, and D to the next landmark; other screen readers have equivalent commands.HTML5 once specified an outline algorithm in which each <section> or <article> started a new level, so every section could begin with its own <h1>. Browsers never exposed that hierarchy in the accessibility tree, and in July 2022 the WHATWG replaced it: the outline is now simply all h1-h6 elements in document order. So give the page one level-1 heading and never skip a level going down (<h2> then <h3>, not <h4>); going back up by any number of levels is fine. Browsers are removing the leftovers too: Firefox 140 dropped the built-in styles that shrank an <h1> nested in <section>, <article>, <aside> or <nav>, and Chrome 136 started logging deprecation warnings for pages that rely on them.

    <style>

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

    article { display: none; } /* only the generated outline is shown */

    ul { margin: 4px 0; padding: 0; } li { list-style: none; } .skip { color: #c62828; }

    </style>

    <article>

    <h1>Home Espresso Guide</h1>

    <section><h2>Choosing beans</h2><h3>Roast level</h3><h3>Freshness</h3></section>

    <section><h2>Dialing in</h2><h4>Grind size</h4></section>

    <aside><h2>Glossary</h2></aside>

    </article>

    <nav aria-label="Table of contents"><b>Contents</b><ul id="toc"></ul></nav>

    <script>

    let prev = 0;

    for (const h of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {

    const level = +h.tagName[1];

    const li = toc.appendChild(document.createElement('li'));

    li.style.paddingLeft = `${(level - 1) * 18}px`;

    li.textContent = `h${level} ${h.textContent}`;

    if (level > prev + 1) { li.className = 'skip'; li.textContent += ' (skipped a level)'; }

    prev = level;

    }

    </script>

    Listing 2.11: Generating a table of contents from the outline and flagging skipped levels Figure 2.12: Browser output of Listing 2.11The script ignores sections entirely, because document order is all that screen readers use. The <h4> under "Dialing in" is flagged because it jumps from level 2 to level 4. The specification has since added a headingoffset attribute for embedding content whose heading levels should shift, but web-features lists no shipping browser for it yet.The W3C HTML Accessibility API Mappings specification defines which elements become landmarks. Several depend on context or on an accessible name from aria-label or aria-labelledby: Element Landmark role Condition <header> banner Only when not inside <article>, <aside>, <main>, <nav> or <section> <footer> contentinfo Same condition as <header> <main> main Always; one visible per page <nav> navigation Always <aside> complementary Inside <body> or <main>; elsewhere only when named <section> region Only when it has an accessible name <form> form Only when it has an accessible name <search> search Always (Baseline Widely available since April 2026) Table 2.6: HTML elements and the landmark roles they map to Use the native elements rather than <div role="navigation">, and do not repeat an implicit role (<main role="main">), which the ARIA in HTML specification marks as not recommended. Keep landmarks few and meaningful: one banner, one main, one contentinfo, and a distinct aria-label whenever a role appears more than once, such as "Primary" and "Footer" navigation. Everything visible should sit inside a landmark, or users who jump between landmarks will miss it. Tip: Test both structures automatically with axe-core (https://github.com/dequelabs/axe-core) (MPL-2.0, npm install axe-core, version 4.13.0 at the time of writing), Deque's open-source testing engine. Its rules page-has-heading-one, heading-order, landmark-one-main, landmark-unique, landmark-no-duplicate-banner and region cover everything in this section. Then check by ear: open the Accessibility tree in Chrome DevTools, or press NVDA+F7 in NVDA to list headings and landmarks. Section 2.10 covers accessibility testing in depth.

    2.3 Text Elements

    Most of a web page is running text: paragraphs sprinkled with links, emphasis, quotations, code, dates and abbreviations. HTML gives each of these a dedicated text-level element, and nearly all of them belong to the phrasing content category from Section 2.1.4: they sit inside a paragraph or heading without starting a new block.The choice matters beyond looks: a <time datetime="2026-09-15"> can be parsed by a crawler, a <kbd> tells a documentation tool that the text is a keystroke, and a <bdi> stops an Arabic user name from scrambling the sentence around it. The default styling is only a side effect you can change with CSS (Chapter 4). Ask what the text is, not how it should look; if no element describes it, use a meaningless <span> with a class.HTML 4's presentational tags and a few early ruby elements are obsolete: browsers render them, validators flag them. Obsolete element Use instead <font>, <center>, <big> CSS (font-*, color, text-align), or <strong> / <mark> <tt> <code>, <kbd>, <samp> or <var> <strike> <del> for edits, <s> for outdated content <acronym> <abbr> <rb>, <rtc> Plain base text inside <ruby>, or nested <ruby> Table 2.7: Obsolete text elements and their replacements

    2.3.1 Hyperlinks

    The <a> (anchor) element turns its content into a link to the URL in its href attribute. The URL can be absolute, relative to the page (or to <base>, Section 2.2.2), a fragment such as #faq that scrolls to the element with that id, or another scheme handled by the operating system. An <a> without href is only a placeholder: not focusable, not announced as a link. target takes _self (the default), _blank, _parent, _top or a frame name. Attribute Purpose Example href Destination URL or fragment href="/pricing#pro" target Browsing context to open in target="_blank" rel Relationship to the target rel="nofollow" download Download, optional file name download="list.pdf" hreflang, type Language, MIME type hints hreflang="de" referrerpolicy What to send as Referer "no-referrer" ping URLs to POST to on click ping="/track" Table 2.8: Attributes of the a element

    <!doctype html>

    <html lang="en">

    <head>

    <meta charset="utf-8">

    <title>Links</title>

    <style>

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

    p { margin: 0 0 6px; }

    a.card { display: block; width: 280px; padding: 8px; border: 1px solid #99a;

    border-radius: 8px; color: inherit; text-decoration: none; }

    </style>

    </head>

    <body>

    <p>Jump to the <a href="#faq">FAQ</a> or open

    <a href="https://developer.mozilla.org/" target="_blank">MDN (opens in new tab)</a>.</p>

    <p>Email <a href="mailto:sales@example.com?subject=Quote%20request&amp;cc=boss@example.com">

    sales</a>, call <a href="tel:+14155550123">+1 415 555 0123</a>, text

    <a href="sms:+14155550123?body=Hi">us</a>, or chat on

    <a href="https://wa.me/14155550123?text=Hi">WhatsApp</a>.</p>

    <p><a href="/files/prices.pdf" download="prices-2026.pdf">

    Download price list (PDF, 240 KB)</a></p>

    <a class="card" href="/courses/html">

    <strong>HTML Fundamentals</strong><br>12 lessons. The whole card is one link.

    </a>

    <h2 id="faq">FAQ</h2>

    </body>

    </html>

    Listing 2.12: Common kinds of links Figure 2.13: Browser output of Listing 2.12mailto: accepts subject, cc, bcc and body parameters, percent-encoded and joined with &amp; in HTML. Write tel: numbers in international + format without spaces; wa.me takes the number without + or leading zeros. The skype: scheme found in older tutorials is dead: Microsoft retired Skype on May 5, 2025. The card shows that <a> has a transparent content model: it may wrap headings and paragraphs, but never other interactive content such as links, buttons or inputs. download works only for same-origin, blob: and data: URLs, which lets a script generate a file in memory and set link.href = URL.createObjectURL(blob).Text fragments link to a passage on a page you do not control, even without an id: https://example.com/article#:~:text=first%20words,last%20words. The browser scrolls to the match, highlights it (style it with ::target-text), and ignores the directive if the text is not found. They work in Chrome 81, Edge 83, Safari 16.1 and Firefox 131.A page opened with target="_blank" once received a window.opener reference and could silently redirect your tab to a phishing copy, an attack called tabnabbing. Since early 2021 (Safari 12.1, Firefox 79, Chrome 88) target="_blank" implies rel="noopener", so adding it by hand is now redundant. Other rel values include noreferrer (no Referer header), nofollow (you do not vouch for the target), alternate, author, license, help, prev, next, tag and privacy-policy. Google added the hints sponsored (paid links) and ugc (user-generated content) in 2019; Chapter 10 covers them. Under the hood: Activating a link runs the standard's follow the hyperlink algorithm. The browser resolves href against the document's base URL, chooses the target from target, decides between navigating and downloading, applies the referrer policy (default strict-origin-when-cross-origin: other sites see only your origin), and fires any ping requests as POSTs with the body PING. Chrome and Safari send pings; Firefox disables them by default, so never rely on ping for analytics.
  • Write link text that makes sense alone. Screen reader users often list all links on a page, where "click here" is useless. Mention surprises in the text: "(opens in new tab)", "(PDF, 240 KB)".

  • Links navigate; buttons act. For anything that changes the current page, such as opening a menu, use <button>. Avoid href="#" with onclick and javascript: URLs, which break middle-click, bookmarks and Content Security Policy.

  • Keep links recognizable by more than color, and style :focus-visible (Section 4.3.5).

  • 2.3.2 Formatting

    <h1> to <h6> rank headings. Screen reader users jump between them and search engines read them as the topics of the page, so pick a level for its rank, never its size: use one <h1> describing the page, and do not skip levels on the way down. <hgroup> pairs a heading with a subtitle or tagline, and today may contain exactly one heading plus <p> elements before or after it; the older multi-heading form is invalid. Warning: Browsers used to shrink an <h1> nested in <article>, <aside>, <nav> or <section>, a leftover of the "document outline algorithm" that the standard dropped in July 2022. The special styles were removed from the standard in May 2025, Firefox 140 no longer applies them, and Chrome has logged deprecation warnings since version 136. Set h1 { font-size: 2em; margin-block: 0.67em; } yourself for consistent results. HTML5 kept <b>, <i>, <u>, <s> and <small> but gave each a precise meaning. Element Meaning (typical use) Default look <b> Attention, no importance (keywords) Bold <i> Alternate voice (foreign words, ships) Italic <u> Non-textual annotation (misspelling) Underline <s> No longer accurate (old price) Strikethrough <small> Side comment (legal small print) Smaller text <sub>, <sup> Typographic convention (H₂O, m²) Lowered, raised <q> Short inline quotation Quote marks added <blockquote> Long quotation as a block Indented block <del>, <ins> Removed and added text (edits) Struck, underlined <pre> Preformatted text, spaces kept Monospace, no wrap <br> Line break in content (poems) New line Table 2.9: Formatting elements

    <!doctype html>

    <html lang="en">

    <head>

    <meta charset="utf-8">

    <title>Formatting</title>

    <style>

    body { font: 15px/1.45 system-ui, sans-serif; margin: 10px; }

    p, h1 { margin: 0 0 5px; }

    h1 { font-size: 1.5em; }

    hgroup { margin-bottom: 8px; }

    hgroup p { color: #555; font-style: italic; }

    u.typo { text-decoration: wavy underline red; }

    pre { background: #f1f3f4; padding: 4px 10px; margin: 5px 0; }

    </style>

    </head>

    <body>

    <hgroup><h1>The Tide Table</h1><p>Harbor news since 1887</p></hgroup>

    <p><b>Tide Table Pro</b> is now <s>$49</s> $29. The <i>Nautilus</i> sails at dawn.</p>

    <p>Water is H<sub>2</sub>O; the area is 12 m<sup>2</sup>.

    Spell check: <u class="typo">recieve</u>.</p>

    <p>The harbor master said <q cite="/news/412">no ship leaves before the fog lifts</q>.</p>

    <p>Departure moved to <del datetime="2026-09-14T08:00Z">Monday</del>

    <ins datetime="2026-09-14T08:00Z">Tuesday</ins>.</p>

    <pre>

    Tide Time Height

    High 06:12 4.1 m

    </pre>

    <p><small>Prices include VAT. Offer ends December 31, 2026.</small></p>

    </body>

    </html>

    Listing 2.13: Formatting elements in context Figure 2.14: Browser output of Listing 2.13The deciding question is whether the words would be spoken differently. <em> adds stress that changes the meaning ("I never said that" versus "I never said that"), and <strong> marks importance, seriousness or urgency. <b> and <i> add neither: they only offset text by convention. For purely visual bold or italic, style a <span> with CSS.
  • <q> inserts the quotation marks itself through the default CSS rules q::before { content: open-quote } and q::after { content: close-quote }, so never type them inside. Change the characters with the CSS quotes property. The cite URL on <q> and <blockquote> is invisible to readers; name the source in the text too.

  • <del> and <ins> are transparent: they may wrap whole paragraphs or list items. Most screen readers do not announce them by default, so if the edit matters, add visually hidden text such as del::before { content: " [deleted] " }.

  • <pre> applies white-space: pre. The parser drops a single newline right after the start tag, so you can begin the content on the next line. Escape < as &lt;, and add overflow-x: auto for long lines. For code, nest <code> inside <pre>; highlighters look for pre > code.

  • Tip: Superscripts can push lines apart unevenly; sub, sup { line-height: 0; } keeps line spacing even.

    2.3.3 Orientation

    The orientation elements control where characters go: the direction of a run of text, the position of a pronunciation guide, and the points where a long word may break. They prevent visible bugs as soon as a page shows names or reviews from around the world. Element Purpose Example <bdi> Isolate text of unknown direction <bdi>{{userName}}</bdi>: 3 posts <bdo> Force a direction on every character <bdo dir="rtl">abc</bdo> shows "cba" <ruby> Annotate base text with a small guide <ruby>æ¼¢<rt>kan</rt></ruby> <rt>, <rp> Annotation; fallback parentheses <rp>(</rp> <wbr> Line-break opportunity inside a word Proxy<wbr>Factory Table 2.10: Orientation elements Arabic, Hebrew, Persian and Urdu run right to left (RTL), while digits and embedded Latin words inside them still run left to right. Text is always stored in logical (typing) order; at render time the browser runs the Unicode Bidirectional Algorithm (UBA, Unicode Standard Annex #9) to compute the visual order. Letters have a strong direction, digits are weak, and spaces and punctuation are neutral, taking the direction of their neighbors. That last rule scrambles sentences when you insert text you do not control: in NAME - 1st place with an RTL name, the dash and the digit join the name's RTL run and the line displays as 1 - EMAN st place (uppercase standing for RTL letters, as in the Unicode annex).<bdi> (bidirectional isolate) lays out its content on its own, detecting the direction from the first strong character, so it neither affects nor is affected by the surrounding text. It is equivalent to <span dir="auto"> but shorter and clearer. <bdo> (bidirectional override) does the opposite: its required dir attribute forces every character into that order, whatever the characters' own direction.

    <!doctype html>

    <html lang="en">

    <head>

    <meta charset="utf-8">

    <title>bdi and bdo</title>

    <style>

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

    h2 { font-size: 14px; margin: 6px 0 0; }

    ul { margin: 0; }

    </style>

    </head>

    <body>

    <h2>With &lt;span&gt; (broken)</h2>

    <ul><li><span>שרה</span> - 2nd place</li><li><span>أحمد</span>: 3 posts</li></ul>

    <h2>With &lt;bdi&gt; (correct)</h2>

    <ul><li><bdi>שרה</bdi> - 2nd place</li><li><bdi>أحمد</bdi>: 3 posts</li></ul>

    <h2>With &lt;bdo dir="rtl"&gt;</h2>

    <ul><li><bdo dir="rtl">Hello, world!</bdo></li></ul>

    </body>

    </html>

    Listing 2.14: Isolating names with bdi and overriding with bdo Figure 2.15: Browser output of Listing 2.14For whole pages and blocks, use the dir attribute: <html lang="ar" dir="rtl"> for an Arabic page and dir="auto" on inputs and containers holding user-generated text. Under the hood: The default stylesheet gives bdi the declaration unicode-bidi: isolate and bdo unicode-bidi: isolate-override, the CSS equivalents of the invisible Unicode controls FIRST STRONG ISOLATE (U+2068) and RIGHT-TO-LEFT OVERRIDE (U+202E). Such hidden controls are also an attack vector: the 2021 "Trojan Source" disclosure (CVE-2021-42574) showed code that reads differently to a reviewer than to the compiler. Strip U+202A-U+202E and U+2066-U+2069 from user-supplied file names. Ruby is small annotation text set above or beside base characters to show pronunciation: furigana in Japanese, pinyin in Chinese. The name comes from "ruby", the 5.5-point type size British printers used for such notes. Write the base text followed by its <rt>. The optional <rp> parentheses are hidden by the default rule rp { display: none } but appear in software without ruby support. The CSS property ruby-position (over, under, alternate, inter-character) has been Baseline since December 2024.<wbr> marks where a line may break inside a long string such as a URL or identifier; the second box below overflows without it. The soft hyphen &shy; also shows a hyphen at the break, and CSS overflow-wrap: anywhere breaks unmarked text as a last resort.

    <!doctype html>

    <html lang="en">

    <head>

    <meta charset="utf-8">

    <title>ruby and wbr</title>

    <style>

    body { font: 15px/1.3 system-ui, sans-serif; margin: 10px; }

    .big { font-size: 26px; line-height: 2; margin: 0; }

    :lang(ja) { font-family: "Yu Gothic", "Hiragino Sans", sans-serif; }

    :lang(zh) { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; }

    rt { font-size: 0.45em; color: #1565c0; }

    .under { ruby-position: under; }

    .flat, .flat :is(ruby, rt, rp) { display: inline; font-size: 15px; }

    .box { width: 150px; border: 2px solid #1565c0; padding: 4px; display: inline-block;

    vertical-align: top; margin: 6px 12px 0 0; font-size: 14px; }

    </style>

    </head>

    <body>

    <p class="big" lang="ja">

    <ruby>東<rp>(</rp><rt>とう</rt><rp>)</rp>京<rp>(</rp><rt>きょう</rt><rp>)</rp></ruby>

    <span class="flat">Fallback: <ruby>東<rp>(</rp><rt>とう</rt><rp>)</rp></ruby></span></p>

    <p class="big" lang="zh-Hans"><ruby class="under">北<rt>běi</rt>京<rt>jīng</rt></ruby>欢迎你</p>

    <div class="box">Abstract<wbr>Singleton<wbr>Proxy<wbr>Factory<wbr>Bean</div>

    <div class="box">AbstractSingletonProxyFactoryBean</div>

    </body>

    </html>

    Listing 2.15: Ruby annotations and line-break opportunities Figure 2.16: Browser output of Listing 2.15 Note: The standard lists <rb> and <rtc> as obsolete, although older tutorials still use them. For long URLs, follow the Chicago Manual of Style: allow breaks after // and before a single slash or dot (example<wbr>.com<wbr>/docs), and never add a hyphen.

    2.3.4 Indications

    The indication elements, often called phrase elements, label what text is: an abbreviation, a book title, a keystroke, program output, a date. Their meaning, not their default italics or monospace, is what screen readers, documentation tools and your scripts use. Element Represents Example <em> Stress that changes meaning I <em>did</em> pay <strong> Importance or urgency <strong>Stop</strong> <abbr> Abbreviation (title) <abbr title="...">CSS</abbr> <dfn> Defining use of a term <dfn>pixel</dfn> <cite> Title of a work <cite>Dune</cite> <code> Computer code <code>fetch()</code> <kbd> User input: keys, menus <kbd>Ctrl</kbd> <samp> Program output <samp>Not found</samp> <var> Variable in math or code <var>x</var> + 1 <time> Date, time, duration <time datetime="..."> <data> Value for machines <data value="8412"> <mark> Relevant highlight <mark>fog</mark> <address> Contact for page or article <address>...</address> Table 2.11: Indication (phrase) elements

    <!doctype html>

    <html lang="en">

    <head>

    <meta charset="utf-8">

    <title>Help: Saving files</title>

    <style>

    body { font: 15px/1.5 system-ui, sans-serif; margin: 10px; }

    p { margin: 0 0 8px; }

    kbd kbd { border: 1px solid #9aa0a6; border-bottom-width: 3px; border-radius: 4px;

    padding: 0 4px; background: #f8f9fa; font-size: 0.85em; }

    samp { background: #263238; color: #a5d6a7; padding: 1px 5px; }

    mark { background: #ffe082; }

    </style>

    </head>

    <body>

    <p>An <dfn id="autosave">autosave</dfn> is written every <var>n</var> minutes

    (<var>n</var> = 5 by default).

    Last autosave: <time datetime="2026-09-15T09:30Z">9:30 UTC</time>.</p>

    <p>Press <kbd><kbd>Ctrl</kbd>+<kbd>S</kbd></kbd> or choose

    <kbd><samp>File</samp> &gt; <samp>Save</samp></kbd>.

    A full disk reports <samp>Error: ENOSPC</samp>; then run <code>git status</code>.</p>

    <p>Files are <abbr title="JavaScript Object Notation">JSON</abbr>,

    as defined in <cite>RFC 8259</cite>.

    Plan: <data value="PRO-12">Pro</data>. You searched for <mark>autosave</mark>.</p>

    <p>This article is <em>not</em> for version 1.

    <strong>Never edit files while syncing.</strong></p>

    <address>Questions? <a href="mailto:docs@example.com">docs@example.com</a></address>

    </body>

    </html>

    Listing 2.16: A help article marked up with phrase elements Figure 2.17: Browser output of Listing 2.16The nesting of <kbd> carries meaning. An outer <kbd> containing inner <kbd> elements is one key combination; a <kbd> wrapping <samp> means "choose what the program shows", such as a menu item; <samp> alone is output. A few more rules keep these elements honest:
  • <abbr>: spell the term out on first use; title is only a supplement, because its tooltip never appears on touch screens or for keyboard users.

  • <dfn>: mark only the place where a term is defined, and give it an id so other pages can link there.

  • <cite>: the title of a book, film, song, standard or web page, never a person's name.

  • <address>: contact details for the nearest <article> or the page, not any postal address in the text.

  • <mark>: relevance now (search hits, a passage quoted with added emphasis); <strong> is for importance in itself. Most screen readers do not announce <mark> by default.

  • <time> displays a friendly date while datetime holds a precise one: a year (2026), month (2026-09), date (2026-09-15), time (14:30), local date and time (2026-09-15T14:30), global date and time with an offset (2026-09-15T14:30Z or 2026-09-15T16:30+02:00), week (2026-W38) or duration (PT2H30M). Without datetime, the text itself must use one of these formats. <data> does the same for other values, such as product codes or prices, through its required value attribute. Scripts read them as el.dateTime and el.value, for example to show "2 hours ago" with Intl.RelativeTimeFormat (Chapter 6) while the plain HTML still works without it. Under the hood: Wrapping search hits in <mark> rewrites the DOM, which can disturb frameworks and event listeners. The CSS Custom Highlight API, Baseline since June 2025, highlights text ranges without new elements: register Range objects with CSS.highlights.set("hits", new Highlight(...ranges)) and style them with ::highlight(hits). Browsers show <code> in plain monospace. Colored code comes from an open-source library that tokenizes the text and wraps each token in a styled <span>. Each looks for <pre><code class="language-js"> blocks. Library (license) GitHub stars, Sep 2026 Approach highlight.js (BSD-3-Clause) about 25,000 Regex grammars, 180+ languages Prism (MIT) about 13,000 Small grammars plus plugins Shiki (MIT) about 14,000 VS Code TextMate grammars Table 2.12: Open-source syntax highlighters Install highlight.js with npm install highlight.js (or a CDN <script>) and call hljs.highlightAll(). Build-time highlighting, as Astro and VitePress do with Shiki, ships colored HTML with no client-side JavaScript.

    2.4 Figure Elements

    Text elements (Section 2.3) describe words inside a sentence. The elements in this section describe larger units that a reader takes in at a glance: a list of steps, a gauge, a grid of numbers, a photograph, a video. Some are grouping content (<ol>, <ul>, <dl>, <figure>), some are tabular (<table> and its parts), and some are embedded content (<img>, <picture>, <video>, <audio>, <object>, <embed>), which pulls an external resource into the page.Embedded content is where most page weight lives, so this section goes further than markup. You will see how the browser decides which file in a srcset to download, when loading="lazy" and fetchpriority="high" help or hurt Largest Contentful Paint, which video and audio codecs actually play in each browser, and how WebP, AVIF and JPEG XL compare in September 2026.The <canvas> element also embeds content, but it is an empty drawing surface controlled entirely from JavaScript; it is covered with the other graphics APIs in Section 9.11. Styling lists and tables with CSS is Section 4.7. To save space, the listings here omit the optional <head>, <body> and </html> tags, which the parser supplies (Section 2.1.1).

    2.4.1 Lists

    HTML has three list types. An ordered list <ol> is for items whose sequence matters (recipe steps, a top-ten chart); an unordered list <ul> is for items whose order does not change the meaning (features, menu links); a description list <dl> pairs terms <dt> with descriptions <dd>. Inside <ol> and <ul> every child is an <li> (or a <script> or <template>). To nest a list, put the inner list inside an <li>.<ol> accepts start="5" (first number), reversed (count down) and type (1, A, a, I or i); an <li> inside it accepts value="7", which renumbers that item and those after it. type is still valid because numbering can carry meaning: a contract that cites "clause (iv)" needs Roman numerals even with CSS off. On <ul>, type="square" and compact are obsolete; use CSS (Section 4.7). <menu> is a semantic synonym of <ul> for a toolbar of commands and is rendered the same way.

    <!doctype html>

    <html lang="en">

    <meta charset="utf-8">

    <style>

    body { font: 15px/1.4 system-ui, sans-serif; margin: 10px; display: grid;

    grid-template-columns: 1fr 1fr; gap: 0 28px; }

    h2 { font-size: 16px; margin: 4px 0 2px; }

    ol, ul, dl { margin: 0 0 4px; }

    dt { font-weight: 600; }

    dd { margin: 0 0 6px 18px; }

    </style>

    <section>

    <h2>Pancakes</h2>

    <ol>

    <li>Whisk 2 eggs with 300 ml milk.</li>

    <li>Add dry ingredients:

    <ul><li>200 g flour</li><li>1 tsp baking powder</li></ul>

    </li>

    <li>Fry each side for 90 seconds.</li>

    </ol>

    <h2>Countdown and appendix</h2>

    <ol reversed start="5"><li>Close hatch</li><li>Arm engines</li></ol>

    <ol type="i" start="3"><li>Glossary</li><li value="7">Index</li><li>Credits</li></ol>

    </section>

    <section>

    <h2>Glossary</h2>

    <dl>

    <dt><dfn>HTML</dfn></dt>

    <dd>HyperText Markup Language, the structure of a page.</dd>

    <dt><dfn>CSS</dfn></dt>

    <dt><dfn>Cascading Style Sheets</dfn></dt>

    <dd>The language that styles the structure.</dd>

    </dl>

    </section>

    Listing 2.17: Ordered, nested, reversed and description lists Figure 2.18: Browser output of Listing 2.17In the glossary, two <dt> elements share one <dd>, and <dfn> marks the defining instance of each term. You may wrap each name-value group in a <div> for styling. Specifications and FAQ pairs are good <dl> material. Under the hood: List numbers are not stored in the DOM. The user-agent stylesheet gives <li> display: list-item, which increments an implicit CSS counter named list-item; start, reversed and value only adjust that counter, and the ::marker pseudo-element draws it. That is why markers can be restyled or replaced entirely in CSS (Section 4.7.1).
  • Screen readers announce "list, 3 items", so use real lists, not typed dashes. A menu is a <ul> in <nav>.

  • Safari does not expose a <ul> or <ol> as a list when CSS sets list-style: none, unless it is inside <nav>. Add role="list" if a bullet-free list elsewhere must still be announced.

  • Screen reader support for <dl> varies (VoiceOver offers no list navigation for it), so test before adding ARIA.

  • nowrap on <dd>, found in some old references, is not valid HTML; use CSS white-space: nowrap.

  • Interview question: "When do you choose <dl> over a table?" Use <dl> for one set of name-value pairs; switch to a <table> (Section 2.4.3) once several records share the same columns.

    2.4.2 Progress and Meter

    <progress> and <meter> both draw a horizontal bar, but they mean different things and screen readers announce them differently (progressbar versus meter role). The HTML standard is explicit: use <progress> for the completion of a task and <meter> for a measurement within a known range, also called a gauge.
  • <progress value="30" max="100"> shows a determinate bar at 30%. max defaults to 1.0, so value="0.3" alone means the same. Remove value and the bar becomes indeterminate: the browser animates it back and forth to say "working, but I do not know how long it will take".

  • <meter> takes min (default 0), max (default 1), value, and three optional hints: low, high and optimum. It suits disk usage, battery level, password strength or a match score, but not unbounded quantities such as a weight.

  • The hints split the range into low, middle and high regions, and optimum says which one is good. If optimum lies below low, low values are green, middle values yellow and high values red; above high, the colors reverse; and between low and high, the middle is green and both ends are yellow.

    <!doctype html>

    <html lang="en">

    <meta charset="utf-8">

    <style>

    body { font: 15px/1.4 system-ui, sans-serif; margin: 10px; }

    label { display: flex; justify-content: space-between; width: 520px; margin: 6px 0; }

    progress, meter { width: 260px; }

    </style>

    <label>Uploading photo.jpg <progress id="up" max="100" value="0">0%</progress></label>

    <label>Waiting for server <progress>Working...</progress></label>

    <!-- Disk: lower is better, so optimum sits below low -->

    <label>Disk 20 GB <meter max="100" low="60" high="85" optimum="10" value="20"></meter></label>

    <label>Disk 70 GB <meter max="100" low="60" high="85" optimum="10" value="70"></meter></label>

    <label>Disk 95 GB <meter max="100" low="60" high="85" optimum="10" value="95"></meter></label>

    <!-- Battery: higher is better, so optimum sits above high -->

    <label>Battery 15% <meter low="0.2" high="0.8" optimum="1" value="0.15">15%</meter></label>

    <label>Battery 90% <meter low="0.2" high="0.8" optimum="1" value="0.9">90%</meter></label>

    <script>

    const bar = document.getElementById('up');

    const timer = setInterval(() => {

    bar.value += 10; // a real upload would use progress events

    if (bar.value >= 60) clearInterval(timer);

    }, 100);

    </script>

    Listing 2.18: Progress bars and gauges in their different states Figure 2.19: Browser output of Listing 2.18The script stops the upload bar at 60% so the capture shows a determinate state. The disk meters turn green, yellow and red as usage grows, while the battery meter is red when low and green when full: same element, opposite optimum. With real data, update value from an XMLHttpRequest progress event or a fetch() stream reader. Under the hood: Both widgets are drawn by the browser's native theme, so their inner parts are not in the DOM. Each engine exposes its own pseudo-elements: Blink and WebKit use ::-webkit-progress-bar, ::-webkit-progress-value and ::-webkit-meter-optimum-value; Firefox uses ::-moz-progress-bar and the :-moz-meter-optimum pseudo-class. Set appearance: none first, then style those parts (Chapter 4).
  • Always pair the bar with a <label> or visible text; the element's own content is shown only by user agents that do not support it, which today means none.

  • Keep min <= low <= high <= max; out-of-order values are clamped silently, which hides bugs.

  • Old references list a form attribute for <meter>. It is not part of the current standard; omit it.

  • Do not fake a determinate bar. If you cannot compute a fraction (a server call with no size information), leave value out and let the indeterminate animation speak for itself.

  • Libraries such as NProgress (MIT) draw a thin page-top bar from plain <div> elements; it exposes no progress role, so pair it with an accessible status message.

  • 2.4.3 Tables

    A <table> presents data in rows and columns where both axes mean something: prices by plan and feature, sales by region and quarter. Rows are <tr>; each cell is a data cell <td> or a header cell <th>. Around them you can add:
  • <caption>: the title, as first child; screen readers read it on entering the table.

  • <thead>, <tbody> (one or more) and <tfoot>: header, body and summary row groups.

  • <colgroup> and <col span="2">: column groups after the caption. Only background, border, width and visibility: collapse apply to columns.

  • colspan and rowspan merge cells. scope (col, row, colgroup, rowgroup) on <th> states which cells a header labels; abbr gives a short header name for screen readers to repeat.

  • <!doctype html>

    <html lang="en">

    <meta charset="utf-8">

    <style>

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

    table { border-collapse: collapse; width: 600px; }

    caption { font-weight: 600; text-align: left; padding-bottom: 6px; }

    th, td { border: 1px solid #b0bec5; padding: 4px 8px; text-align: right; }

    th[scope="row"], th[scope="rowgroup"], th:first-child { text-align: left; }

    thead, tfoot { background: #eceff1; }

    .highlight { background: #fff3e0; }

    </style>

    <table>

    <caption>Sales by region, 2026 (thousand USD)</caption>

    <colgroup><col span="2"><col span="2" class="highlight"><col span="2"></colgroup>

    <thead>

    <tr><th scope="col" colspan="2">Region</th><th scope="col">Q1</th><th scope="col">Q2</th>

    <th scope="col">Q3</th><th scope="col">Total</th></tr>

    </thead>

    <tbody>

    <tr><th scope="rowgroup" rowspan="2">Americas</th><th scope="row">USA</th>

    <td>410</td><td>455</td><td>470</td><td>1,335</td></tr>

    <tr><th scope="row">Brazil</th><td>95</td><td>102</td><td>99</td><td>296</td></tr>

    </tbody>

    <tfoot>

    <tr><th scope="row" colspan="2">All regions</th>

    <td>505</td><td>557</td><td>569</td><td>1,631</td></tr>

    </tfoot>

    </table>

    Listing 2.19: A sales table with row groups, merged cells and column styling Figure 2.20: Browser output of Listing 2.19One <col> with a class highlights two columns. When scope cannot express irregular headers, give each <th> an id and list them on the data cell: <td headers="q1 brazil">. Under the hood: The HTML parser inserts a <tbody> whenever a <tr> appears directly inside <table>, so the selector table > tr matches nothing. Stray elements between rows are foster-parented: the parser moves them in front of the table, which is why a misplaced <div> jumps above it. The default table-layout: auto reads every cell before sizing columns, which is slow for thousands of rows; table-layout: fixed sizes columns from the first row. The old border, cellpadding, cellspacing, bgcolor and summary attributes are replaced by CSS (Section 4.7.2) and <caption>. Never use tables for page layout; if legacy markup does, add role="presentation".On a phone, wrap a wide table in <div style="overflow-x: auto"> rather than shrinking the text. Once users need to sort, filter, page or edit thousands of rows, use a data-grid library. DataTables (MIT) enhances an existing <table>, though editing needs its paid Editor. TanStack Table (github.com/TanStack/table (https://github.com/TanStack/table), MIT) is headless: it turns rows and column definitions into a sorted, filtered, paginated row model and leaves rendering to you, for example as a virtualized list that creates DOM rows only for the visible slice (Section 8.8.2). AG Grid's Community edition is MIT, while pivoting and other Enterprise features cost $999 per developer; Handsontable adds spreadsheet-style editing and is free only for non-commercial use.

    2.4.4 Images

    <img> is a void element that embeds one picture from src (a URL, or a data: URL for tiny icons). alt replaces the image for screen readers, search engines and broken connections, so describe what it means in context ("Chart: sales doubled in Q3"). Decorative images get alt=""; with no alt at all, some screen readers read out the file name.Always set width and height to the intrinsic pixel size, even when CSS resizes the image. Browsers derive a default aspect-ratio from them and reserve space before a byte arrives, avoiding the jumps Core Web Vitals measure as Cumulative Layout Shift; with img { max-width: 100%; height: auto; } the reserved box scales with the layout.Three attributes tune loading: loading="lazy" defers the download until the image nears the viewport (Baseline widely available), fetchpriority="high" or "low" hints network priority (Baseline 2024), and decoding="async" lets the page paint before the image is decoded. srcset and sizes get Section 2.4.6; crossorigin is needed before a <canvas> may read the pixels.<figure> wraps self-contained content that the text refers to, such as an image, a chart, a code listing or a quote, and <figcaption> (first or last child) captions it. The caption becomes the figure's accessible name. An image map makes regions of one image clickable: <map name="room"> holds <area> elements with shape="rect", "circle" or "poly", pixel coords and an href, and the image points to it with usemap="#room".

    <!doctype html>

    <html lang="en">

    <meta charset="utf-8">

    <base href="https://cdn.jsdelivr.net/gh/mdn/learning-area@main/html/multimedia-and-embedding/">

    <style>

    body { font: 14px/1.4 system-ui, sans-serif; margin: 10px; display: flex; gap: 16px; }

    img { display: block; height: auto; }

    figure { margin: 0; }

    .hero img { width: 200px; }

    figcaption { font-size: 13px; color: #37474f; padding-top: 4px; }

    </style>

    <figure class="hero">

    <!-- Above the fold: likely LCP, so load early and at high priority -->

    <img src="responsive-images/elva-480w-close-portrait.jpg" width="480" height="552"

    alt="A father holding his smiling daughter in a garden" fetchpriority="high">

    <figcaption>The hero image loads first.</figcaption>

    </figure>

    <figure>

    <!-- Below the fold on a real page: lazy, decoded off the main paint -->

    <img src="responsive-images/elva-fairy-480w.jpg" width="480" height="320" usemap="#room"

    alt="A girl in a fairy costume sitting on a red sofa" loading="lazy" decoding="async">

    <map name="room">

    <area shape="rect" coords="5,20,50,180" href="#books" alt="Bookshelf">

    <area shape="circle" coords="222,105,48" href="#face" alt="The girl's face">

    </map>

    <figcaption>Click the bookshelf or the face.</figcaption>

    </figure>

    <script>document.querySelector('area[shape="circle"]').focus();</script>

    Listing 2.20: A hero image, a lazy figure and an image map Figure 2.21: Browser output of Listing 2.20The script focuses the circular <area> to show the focus ring around its clickable region. <area> shares href, target, download and rel with <a> (Section 2.3.1) and needs its own alt. Its coordinates are intrinsic pixels, so the map drifts off a CSS-scaled image; for responsive hotspots, position links over the image or use inline SVG. The ismap attribute (server-side maps) is legacy.
  • Never lazy-load the Largest Contentful Paint image; give it fetchpriority="high" instead. On Google Flights this moved LCP from 2.6 s to 1.9 s.

  • Lazy-load images further down. Chrome fetches them once within 1,250 px of the viewport on fast connections (2,500 px on 3G or slower).

  • Lazy loading only works with JavaScript enabled; the standard requires this so that a server cannot track the scroll position of script-blocking users. The old lazysizes polyfill is no longer needed.

  • Use fetchpriority="low" for images in later, hidden carousel slides.

  • Under the hood: While the main parser is blocked by stylesheets or scripts, the preload scanner skims the raw HTML for src and srcset URLs and starts downloads early; script lazy loaders that hide URLs in data-src defeat it, and native lazy images wait for layout. Chrome starts in-viewport images at Low priority and boosts them to High after layout (the first five large images start at Medium since Chrome 117); fetchpriority="high" skips that wait. decoding="async" lets the rest of the frame paint while the image decodes.

    2.4.5 Multimedia

    <video> and <audio> play media natively, with no plugin. Give them a src, or several <source> children the browser tries in order, skipping any whose type it cannot play. Anything else inside the element is fallback content, shown only by browsers that do not know the element at all.Both accept controls, autoplay, muted, loop, crossorigin and preload (none, metadata or auto); <video> adds width, height, poster (shown before playback) and playsinline (no forced fullscreen on iPhone). Browsers block audible autoplay until the user interacts with the site, so a background video must be autoplay muted loop playsinline. Chromium-only controlslist="nodownload" trims the built-in controls.<track> attaches timed text in the WebVTT format: kind is subtitles (translation), captions (dialogue plus sounds, for deaf viewers), descriptions, chapters or metadata; srclang and label feed the captions menu; and default turns one track on.

    <!doctype html>

    <html lang="en">

    <meta charset="utf-8">

    <base href="https://cdn.jsdelivr.net/gh/mdn/learning-area@main/html/multimedia-and-embedding/">

    <video controls muted playsinline preload="metadata" width="280" height="280"

    poster="video-and-audio-content/poster.png">

    <source src="video-and-audio-content/rabbit320.webm" type="video/webm">

    <source src="video-and-audio-content/rabbit320.mp4" type="video/mp4">

    <track kind="captions" src="rabbit.en.vtt" srclang="en" label="English" default>

    <p>Your browser cannot play this video.

    <a href="video-and-audio-content/rabbit320.mp4">Download the MP4</a>.</p>

    </video>

    Listing 2.21: A video with format fallbacks, a poster and captions Figure 2.22: Browser output of Listing 2.21The capture shows the poster and Chrome's native controls; preload="metadata" asks only for the duration and dimensions until the user presses play. The captions file is plain text served as text/vtt: a WEBVTT line, then blank-line separated cues such as 00:00:03.500 --> 00:00:07.000 followed by the caption text. A cross-origin track loads only with crossorigin on the media element and an Access-Control-Allow-Origin header on the response.A file format (container) such as MP4 or WebM wraps separately compressed video and audio streams (codecs). Put the most efficient combination first and a universal one last, and add a codecs parameter so the browser can skip a source without downloading it: type='video/webm; codecs="av01.0.05M.08, opus"'. Codec (container) Chrome, Edge Firefox Safari Use it for H.264 + AAC (MP4) Yes Yes Yes Universal fallback VP9 + Opus (WebM) Yes Yes 16+ (iOS 17.4+) Good compression, royalty-free AV1 (MP4, WebM) Chrome 70+, Edge 121+ 67+ 17+ with hardware decoder only Smallest files, royalty-free HEVC / H.265 (MP4) Hardware-dependent Hardware-dependent (137+) Yes Apple-centric, HDR Ogg Theora (Ogg) Removed Removed No Nothing: re-encode old files MP3, AAC, FLAC (audio) Yes Yes Yes Music and podcasts Opus (WebM, Ogg) Yes Yes macOS 15.4+, iOS 18.4+ Speech, low bitrates Table 2.13: Video and audio codec support, September 2026 Under the hood: Seeking uses HTTP Range requests answered with 206 Partial Content, so your server must support ranges, and an MP4 needs its index (the moov box) at the start of the file. The browser demuxes the container and hands each stream to a decoder, in hardware where available, which is why AV1 and HEVC support depends on the device. Ask what will play smoothly with the Media Capabilities API (Section 9.6.4).

    # H.264 + AAC fallback, index moved to the start for fast playback

    ffmpeg -i master.mov -c:v libx264 -crf 23 -preset slow -c:a aac -b:a 128k \

    -movflags +faststart clip.mp4

    # AV1 + Opus in WebM (SVT-AV1 encoder)

    ffmpeg -i master.mov -c:v libsvtav1 -crf 35 -c:a libopus -b:a 96k clip.webm

    Listing 2.22: Encoding web-ready video with FFmpeg <embed src="guide.pdf" type="application/pdf"> and <object data="guide.pdf">fallback link</object> hand a file to the browser's built-in viewer; only <object> can hold fallback content. Both were built for plugins such as Flash, which Adobe stopped supporting on December 31, 2020. For YouTube, use its <iframe> embed (Section 2.2.4), or lite-youtube-embed (github.com/paulirish/lite-youtube-embed (https://github.com/paulirish/lite-youtube-embed), Apache-2.0), which loads the player only on click. Adaptive streaming (HLS, DASH) switches quality with the network: Safari and Chrome 142+ play HLS natively, and hls.js or dash.js add it elsewhere via Media Source Extensions (Section 9.6.7). Scripting playback is covered in Section 8.5.5.

    2.4.6 Responsive Images in Depth

    A 2,000-pixel photo suits a desktop hero but wastes mobile data on a phone showing it 375 pixels wide. Resolution switching serves the same picture at different pixel sizes and lets the browser pick (srcset, sizes); art direction serves different crops for different layouts, chosen by you (<picture>, media).A fixed-size image such as a logo needs only density descriptors: srcset="logo-200.png 1x, logo-400.png 2x". A fluid image uses width descriptors (each file's real pixel width) plus sizes, the width of the image slot, because the browser picks a file while parsing, before CSS layout exists. sizes lists media conditions with lengths (px, em, vw, calc(), but not percentages); the first match wins and the default is 100vw.Figure 2.23: How a browser chooses a file from srcset and sizes (1280 px viewport, 2x screen)The same markup on other devices shows why the phone, at full width on a 3x screen, deserves a 1200w candidate. Device Viewport, DPR Slot Pixels needed File chosen Phone 375 px, 3x 100vw = 375 px 1,125 800w (largest) Laptop 1,280 px, 1x 280 px 280 320w Desktop 1,440 px, 1.5x 280 px 420 480w Table 2.14: srcset="...320w, ...480w, ...800w" sizes="(width <= 600px) 100vw, 280px" The demo prints currentSrc, the URL actually chosen, under each image (the renderer uses a 2x screen).

    <!doctype html>

    <html lang="en">

    <meta charset="utf-8">

    <base href="https://cdn.jsdelivr.net/gh/mdn/learning-area@main/html/multimedia-and-embedding/">

    <style>

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

    align-items: start; }

    figure { margin: 0; } img { display: block; height: auto; }

    figcaption { font: 12px monospace; }

    </style>

    <figure>

    <img srcset="responsive-images/elva-fairy-320w.jpg 320w,

    responsive-images/elva-fairy-480w.jpg 480w,

    responsive-images/elva-fairy-800w.jpg 800w"

    sizes="(width <= 600px) 100vw, 160px" src="responsive-images/elva-fairy-480w.jpg"

    width="480" height="320" style="width: 160px" alt="Girl in a fairy costume">

    <figcaption></figcaption>

    </figure>

    <figure>

    <picture>

    <source media="(width <= 500px)" srcset="responsive-images/elva-480w-close-portrait.jpg"

    width="480" height="552">

    <img src="responsive-images/elva-800w.jpg" width="800" height="464" style="width: 200px"

    alt="Elva and her father">

    </picture>

    <figcaption></figcaption>

    </figure>

    <script>

    addEventListener('load', () => {

    for (const img of document.images) {

    img.closest('figure').querySelector('figcaption').textContent =

    `DPR ${devicePixelRatio}: ${img.currentSrc.split('/').pop()}`;

    }

    });

    </script>

    Listing 2.23: Resolution switching and art direction, reporting currentSrc Figure 2.24: Browser output of Listing 2.23The 160 px slot needs 320 device pixels and gets the 320w file; change both to 280 px and it switches to 800w, as in the diagram. The second figure is art direction: at 500 px or narrower the matching <source> supplies a portrait close-up, and at 720 px the <img> falls back to the wide shot. Only the <img> renders, so alt, loading, styles and listeners belong on it; width and height on each <source> reserve that crop's aspect ratio.For lazy images, sizes="auto" uses the real rendered width, since they load after layout. It works in Chrome 126+ and Firefox 150+; other browsers skip auto and use the fallback lengths after it: sizes="auto, 280px". Under the hood: The standard leaves the final choice to the browser. Chromium converts each width descriptor to a density, picks the smallest one at or above the device pixel ratio (matching its image-set() logic; older versions used a geometric-mean rule), and prefers a sharper file already in the memory cache. Pitfalls: a wrong sizes silently wastes bytes, so compare img.currentSrc with the rendered width; never mix w and x descriptors in one srcset; and preload a responsive LCP image with imagesrcset and imagesizes on <link rel="preload"> (Section 2.13).

    2.4.7 Modern Image Formats

    JPEG, PNG and GIF work everywhere, but newer codecs give the same quality in fewer bytes. WebP stores a VP8 video key frame (or a lossless bitstream) in a RIFF container; Google measures it 25-34% smaller than JPEG, and 26% smaller than PNG when lossless. AVIF stores an AV1 key frame in a HEIF container and usually beats WebP, at the cost of slower encoding. JPEG XL (ISO/IEC 18181) was designed as a still-image codec from the start: it decodes progressively, supports HDR and wide color gamuts (as AVIF does), and can losslessly repack an existing JPEG about 20% smaller. Format Compression Alpha Animation Support, September 2026 JPEG Lossy No No All browsers PNG Lossless Yes APNG All browsers GIF Lossless, 256 colors 1-bit Yes All browsers WebP Lossy or lossless Yes Yes Baseline since 2020 (Safari 14) AVIF Lossy or lossless Yes Yes Baseline since January 2024 (Edge 121) JPEG XL Lossy or lossless Yes Yes Safari 17+ (stills only); Chrome, Firefox behind flags Table 2.15: Raster image formats for the web JPEG XL has had a bumpy ride. Chrome removed its flag-only decoder in Chrome 110 (February 2023); Safari 17 (September 2023) shipped still-image support; Chrome 145 (February 2026) brought it back behind a flag with jxl-rs, a memory-safe Rust decoder, and Chrome Platform Status lists default enablement as proposed. Firefox enables it only in Nightly. Until more engines ship it, treat JPEG XL as an optional extra source.Serve new formats with a fallback chain. The browser takes the first <source> whose type it supports, without downloading the others, so list the smallest format first:

    <picture>

    <source type="image/avif" srcset="elva-480.avif 480w, elva-800.avif 800w, elva-1200.avif 1200w">

    <source type="image/webp" srcset="elva-480.webp 480w, elva-800.webp 800w, elva-1200.webp 1200w">

    <img src="elva-800.jpg" srcset="elva-480.jpg 480w, elva-800.jpg 800w, elva-1200.jpg 1200w"

    sizes="(width <= 600px) 100vw, 600px" width="800" height="533" alt="Girl in a fairy costume">

    </picture>

    Listing 2.24: AVIF first, WebP second, JPEG for everyone else Generate the files at build time with sharp (github.com/lovell/sharp (https://github.com/lovell/sharp), Apache-2.0, npm install sharp). It wraps libvips, which processes images on demand in small regions across threads rather than loading every pixel into memory; sharp's README puts resizing at typically 4x-5x faster than ImageMagick.

    import sharp from 'sharp';

    import { stat } from 'node:fs/promises';

    const source = 'elva-fairy-original.jpg'; // 2592 x 1728 camera JPEG

    const formats = {

    jpg: (img) => img.jpeg({ quality: 80, mozjpeg: true }),

    webp: (img) => img.webp({ quality: 75 }),

    avif: (img) => img.avif({ quality: 50, effort: 4 }),

    };

    for (const width of [480, 800, 1200]) {

    const row = [`${width}w`.padEnd(6)];

    for (const [ext, encode] of Object.entries(formats)) {

    const file = `elva-${width}.${ext}`;

    await encode(sharp(source).resize({ width })).toFile(file);

    row.push(`${ext} ${((await stat(file)).size / 1024).toFixed(0).padStart(4)} KB`);

    }

    console.log(row.join(' '));

    }

    console.log('JPEG XL output available:', sharp.format.jxl.output.file);

    Listing 2.25: build-images.mjs: generating srcset candidates in three formats with sharp

    480w jpg 25 KB webp 17 KB avif 12 KB

    800w jpg 58 KB webp 34 KB avif 25 KB

    1200w jpg 109 KB webp 59 KB avif 48 KB

    JPEG XL output available: false

    Output of Listing 2.25 Here AVIF is about half the size of MozJPEG, with WebP in between. Quality numbers differ between codecs, so tune by eye (squoosh.app compares side by side). sharp's prebuilt binaries omit JPEG XL; use cjxl from libjxl (github.com/libjxl/libjxl (https://github.com/libjxl/libjxl), BSD-3-Clause). To resize on request instead, run imgproxy (Apache-2.0) or Thumbor (MIT), or pay for an image CDN such as Cloudinary or imgix. Under the hood: Image CDNs pick a format by reading the request's Accept header, which Chrome and Edge 121+ send as image/avif,image/webp,image/apng,image/*,*/*;q=0.8, and answer with Vary: Accept so caches keep one copy per format. Safari's image Accept header lists WebP but not AVIF even though Safari decodes AVIF, so a CDN relying only on the header serves WebP to Safari; <picture> with type has no such blind spot.

    2.5 Form Elements

    Forms are how a web page asks for something back: a search query, a login, a shipping address, a file. Before JavaScript existed, <form> was the only way to send data to a server, and it still needs no script: the browser collects the named controls, validates them, encodes the values and sends an HTTP request to the URL in action. Frameworks have returned to this model: in React 19, <form action={fn}> passes a FormData object to your function, and a server-rendered form keeps working before its JavaScript has loaded.Native controls also come with things that are expensive to rebuild: keyboard support, screen reader semantics, mobile keyboards tuned to the data type, date and color pickers, autofill, and validation messages in the user's language. Reach for a custom widget only when a native control cannot do the job, and check Section 2.5.5 first.Figure 2.25: What the browser does when a form is submittedTwo old claims about forms need correcting. POST is not "secure": it only moves the values from the URL into the request body, readable by anyone on the network unless the page uses HTTPS. And form.submit() is not the same as pressing a submit button: it skips steps 2 and 3, so modern code calls form.requestSubmit() (Section 2.5.4).

    2.5.1 Form and Input Controls

    A <form> groups controls whose values are sent together: <input> (Section 2.5.2), <button>, <select> with <option> and <optgroup>, <textarea> and <output> (Section 2.5.3). <fieldset>, <legend> and <label> give them structure and names. On <form>, action is the URL that receives the data (default: the current page), method is get (values in the query string), post (values in the body) or dialog (just close the enclosing <dialog>), enctype picks the body encoding, target where the response appears (_self, _blank, a frame name), and novalidate turns off validation.Use get for searches and filters (the result can be bookmarked) and post for anything that changes data. File uploads need method="post" with enctype="multipart/form-data"; with the default encoding the browser sends only the file name. autocomplete sets the default for autofill (Section 2.5.4), and accept-charset now allows only UTF-8.<fieldset> groups related controls in a box captioned by its first child, <legend>, which screen readers announce with the controls inside. <fieldset disabled> disables every control inside (except within the legend), and disabled controls are not submitted.Every control needs a <label>: point for at the control's id, or wrap the control inside the label. Clicking the label focuses or toggles the control, and its text becomes the control's accessible name. A placeholder is not a label: it disappears as soon as the user types.<button> can contain markup. Its type defaults to submit, so write type="button" for script-driven buttons such as "Show password". A submit button can override the form with formaction, formmethod, formenctype, formnovalidate and formtarget, and its own name/value pair is sent, so "Save draft" and "Publish" buttons can share one form.<select> shows a drop-down; multiple allows several choices and size shows that many rows as a list box. <option> has value (defaulting to its text), selected, disabled and label; <optgroup label> adds group headings. <textarea> takes its initial value between its tags; rows and cols set its size, and wrap="hard" (with cols) submits line breaks where the text wrapped on screen.

    <style>

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

    fieldset { border: 1px solid #9ab; border-radius: 8px; margin: 0 0 10px; }

    legend { font-weight: 600; padding: 0 4px; }

    label { display: block; margin: 4px 0; }

    select, textarea { font: inherit; width: 100%; box-sizing: border-box; }

    button { font: inherit; padding: 4px 12px; margin-right: 6px; }

    </style>

    <form id="ticket" action="/tickets" method="post">

    <fieldset>

    <legend>Your request</legend>

    <label>Product

    <select name="product">

    <optgroup label="Software">

    <option value="ide">Code editor</option>

    <option value="cli" selected>Command-line tools</option>

    </optgroup>

    <optgroup label="Hardware">

    <option value="mon" disabled>Monitor (discontinued)</option>

    </optgroup>

    </select>

    </label>

    <label>Affected versions

    <select name="versions" multiple size="2">

    <option selected>4.1</option><option selected>4.2</option></select></label>

    <label>Problem

    <textarea name="details" rows="3" required>Steps to reproduce:

    1. </textarea></label>

    </fieldset>

    </form>

    <button form="ticket" name="intent" value="send">Send request</button>

    <button form="ticket" name="intent" value="draft" formnovalidate>Save draft</button>

    <button form="ticket" type="reset">Reset</button>

    Listing 2.26: A support request form with select, textarea and buttons Figure 2.26: Browser output of Listing 2.26The buttons sit outside the <form> but belong to it through form="ticket". "Save draft" sends intent=draft and skips validation; the server sees versions=4.1&versions=4.2, one pair per selected option. Avoid control names such as action or submit: form.action would return the control instead of the URL, and form.submit() would stop being a function. Under the hood: Each control has a form owner: its nearest <form> ancestor, or the form named in its form attribute. On submission the browser builds the entry list by walking the owned controls in tree order, skipping disabled ones, ones without name, unchecked checkboxes and radios, and buttons other than the submitter. new FormData(form) gives you the same list in JavaScript.
  • Never nest forms. The parser ignores an inner <form> start tag, so its fields join the outer form.

  • Prefer readonly to disabled for values the user must see and the server must receive.

  • 2.5.2 The Generic <input> Element and Its Types

    <input> is a void element whose type attribute turns it into one of 22 different controls. A missing or unknown type falls back to text, which is what makes the newer types safe to use: a browser that has no week picker shows a text box instead of nothing. The type also picks the phone keyboard and adds validation (Section 2.5.4). type Collects Submitted value / notes text, search One line of text (the default), a query As typed; line breaks removed password Secret text, masked Plain text unless the page uses HTTPS email An email address Validated; multiple allows a comma list url An absolute URL Must include a scheme such as https: tel A phone number Not validated: formats vary by country number A number, with spinner min, max, step; empty if not a number range An approximate number, as a slider Default 0-100, middle value color A color, with picker #rrggbb; alpha attribute in Safari 18.4+ date, time A calendar date, a time of day 2026-09-15, 14:30 datetime-local Date and time, no time zone 2026-09-15T14:30 month, week Month or ISO week of a year 2026-09, 2026-W38; text box in desktop Firefox, Safari checkbox An on/off choice value (default on) only when checked radio One choice of a same-name group value of the checked radio file One or more files Contents need POST + multipart/form-data hidden Nothing: invisible Value set by the page, e.g. a CSRF token submit, reset, button, image Buttons (image shows a picture) Prefer <button>, which can hold markup Table 2.16: The input types The old datetime type, which carried a UTC time zone, was dropped from the standard; use datetime-local plus the user's time zone from Intl (Chapter 6).

    <style>

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

    form { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 14px; }

    label { display: flex; flex-direction: column; gap: 3px; font-weight: 600; }

    input:not([type=checkbox], [type=radio]) { font: inherit; font-weight: 400; }

    .row { flex-direction: row; align-items: center; font-weight: 400; }

    </style>

    <form>

    <label>text <input name="t" placeholder="Your name" list="names"></label>

    <label>password <input type="password" value="secret"></label>

    <label>search <input type="search" value="html forms"></label>

    <label>email <input type="email" value="ana@example.com"></label>

    <label>url <input type="url" value="https://mdn.dev"></label>

    <label>tel <input type="tel" value="+1 415 555 0123"></label>

    <label>number <input type="number" value="3" min="1" max="9"></label>

    <label>range <input type="range" value="70"></label>

    <label>color <input type="color" value="#1565c0"></label>

    <label>date <input type="date" value="2026-09-15"></label>

    <label>time <input type="time" value="14:30"></label>

    <label>datetime-local <input type="datetime-local" value="2026-09-15T14:30"></label>

    <label>month <input type="month" value="2026-09"></label>

    <label>week <input type="week" value="2026-W38"></label>

    <label>file <input type="file" accept="image/*" multiple></label>

    <div>

    <label class="row"><input type="checkbox" checked> checkbox</label>

    <label class="row"><input type="checkbox" id="mixed"> indeterminate</label>

    </div>

    <div>

    <label class="row"><input type="radio" name="size" value="s"> Small</label>

    <label class="row"><input type="radio" name="size" value="m" checked> Medium</label>

    </div>

    <div>

    <input type="hidden" name="csrf" value="f3a9">

    <input type="submit" value="Submit"> <input type="reset"> <input type="button" value="Button">

    </div>

    <datalist id="names"><option value="Ana"><option value="Bo"></datalist>

    </form>

    <script>document.getElementById('mixed').indeterminate = true;</script>

    Listing 2.27: A gallery of input types Figure 2.27: Browser output of Listing 2.27The figure shows Chrome; desktop Firefox and Safari show text boxes for month and week. The dash in the second checkbox is the indeterminate state, settable only from JavaScript (for a "select all" box whose children are partly checked); it never changes what is submitted.
  • Text types: placeholder, minlength, maxlength, pattern, size (width in characters).

  • Numeric and date types: min, max, step (step="any" allows decimals; step="900" on time means 15-minute steps).

  • Files: accept (image/*, .pdf), multiple, and capture="environment" to open a phone's rear camera.

  • Most types: list points at a <datalist> of suggestions that the user may ignore.

  • Keyboard hints: the global attributes inputmode (numeric, decimal, tel, ...) and enterkeyhint (search, send, next, done) change the phone keyboard without adding validation.

  • const make = (type, value) => Object.assign(document.createElement('input'), { type, value });

    console.log('week:', make('week').type === 'week', '| datetime:', make('datetime').type); // Chrome

    console.log(make('date', '2026-09-15').valueAsDate.toISOString());

    const qty = make('number', '12abc');

    console.log(JSON.stringify(qty.value), qty.valueAsNumber); // sanitized: not a number

    Listing 2.28: Feature detection and typed value accessors

    week: true | datetime: text

    2026-09-15T00:00:00.000Z

    "" NaN

    Output of Listing 2.28 Under the hood: Each type has a value sanitization algorithm that runs whenever value is set: number drops anything that is not a valid number, color turns red into #ff0000, and email trims spaces. Date types store an ISO string however the picker displays it, so 03/04 ambiguity never reaches your server.
  • Do not use type="number" for postal codes, card numbers or IDs, which are not really numbers and are easy to change by accident with the spinner. Use type="text" inputmode="numeric" autocomplete="postal-code".

  • A type="hidden" value is visible and editable in DevTools. Never trust it for prices or permissions.

  • 2.5.3 <output>

    <output> marks the result of a calculation or user action: a total price, a slider's current value, a "Saved" confirmation. Its own attributes are for, the ids of the controls that feed the result; form, to join a form elsewhere in the page; and name, for form.elements. It holds plain text and is not submitted.What makes it better than a <span> is its implicit ARIA role status, a polite live region: when its text changes, screen readers announce the new value without moving focus away from the slider being dragged.

    <style>

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

    output { font-weight: 700; color: #1565c0; }

    input[type=number] { width: 7em; font: inherit; }

    </style>

    <form id="tip">

    <label>Bill <input type="number" id="bill" name="bill" value="84.50" step="0.01"></label><br>

    <label>Tip <input type="range" id="pct" name="pct" min="0" max="30" value="18"></label>

    <output name="pctOut" for="pct">18%</output><br>

    Tip <output name="tipOut" for="bill pct">$15.21</output>,

    total <output name="total" for="bill pct">$99.71</output>

    </form>

    <script>

    const form = document.getElementById('tip');

    const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });

    function update() {

    const { bill, pct, pctOut, tipOut, total } = form.elements;

    const tip = bill.valueAsNumber * pct.valueAsNumber / 100 || 0;

    pctOut.value = `${pct.value}%`;

    tipOut.value = money.format(tip);

    total.value = money.format(bill.valueAsNumber + tip || 0);

    }

    form.addEventListener('input', update); // one listener catches every control

    form.elements.pct.value = 25; // simulate the user dragging the slider

    update();

    </script>

    Listing 2.29: A tip calculator with output elements Figure 2.28: Browser output of Listing 2.29One input listener on the form serves every control, because input events bubble. valueAsNumber avoids the bug where "84.50" + tip concatenates strings, and || 0 hides the NaN of an empty field. The text initially inside each <output> (the 18% values) is what users see before the script loads. Under the hood: An <output> starts in default mode, where value and defaultValue both return its text. The first assignment to value saves that text as the default, which a form reset puts back. willValidate is always false: outputs are never validated. If the server needs the computed value, copy it into an <input type="hidden"> and recalculate it on the server anyway: client-side results can be tampered with. For a gauge or task progress, use <meter> or <progress> (Section 2.4.2).

    2.5.4 Constraint Validation and Autofill

    Browsers validate forms before they send them. You declare the rules as attributes, the browser checks them when the user submits, focuses the first invalid control, shows a message in the user's language, and cancels the submission. This constraint validation costs no JavaScript, and a small API lets you customize every step. It never replaces server-side validation: anyone can edit the HTML in DevTools or send a request with curl. Constraint Applies to ValidityState flag required Most controls valueMissing type="email", type="url" Those types typeMismatch pattern="[A-Z]{3}-\d{4}" Text-like types patternMismatch min, max Numbers, ranges, dates rangeUnderflow, rangeOverflow step Numbers, ranges, dates stepMismatch minlength, maxlength Text types, <textarea> tooShort, tooLong Unparseable input, e.g. letters typed in a date Date, number badInput setCustomValidity("message") Any control customError Table 2.17: Declarative constraints and the flags they set pattern must match the whole value, as if wrapped in ^(?:...)$, and it is compiled with the regular expression v flag, so characters such as -, ( and | need escaping inside a character class. Disabled and readonly controls, hidden inputs and type="button" or "reset" inputs are never validated. Add novalidate to the form, or formnovalidate to one submit button, to switch validation off.Every control exposes validity (a ValidityState with the flags above plus valid), validationMessage, willValidate, checkValidity(), reportValidity() and setCustomValidity(); the form has the two check methods too. checkValidity() returns a boolean and fires an invalid event on each failing control; reportValidity() also shows the browser's message. A non-empty custom message keeps the control invalid until you set it back to "".

    const form = document.createElement('form');

    form.innerHTML = `<input name="email" type="email" required>

    <input name="age" type="number" min="18">

    <input name="code" pattern="[A-Z]{3}-\\d{4}"><input name="pw2" type="password">`;

    const { email, age, code, pw2 } = form.elements;

    [email.value, age.value, code.value] = ['ana@', '17.5', 'abc-12'];

    pw2.setCustomValidity('Passwords do not match.'); // e.g. after comparing with the first field

    const flags = (el) => Object.keys(ValidityState.prototype).filter((k) => el.validity[k]);

    for (const el of [email, age, code, pw2]) {

    console.log(`${el.name}: [${flags(el)}] ${el.validationMessage}`);

    }

    console.log('form valid:', form.checkValidity());

    Listing 2.30: Inspecting validity states and custom errors

    email: [typeMismatch] Please enter a part following '@'. 'ana@' is incomplete.

    age: [rangeUnderflow,stepMismatch] Value must be greater than or equal to 18.

    code: [patternMismatch] Please match the requested format.

    pw2: [customError] Passwords do not match.

    form valid: false

    Output of Listing 2.30 The messages come from Chrome and differ in other browsers and languages; for a pattern mismatch the bubble may also show the title text. Styling follows the same state: :invalid matches immediately, which paints an empty required field red before the user has typed anything, while :user-invalid waits until the user has changed the field and left it, or tried to submit (Baseline since November 2023; Section 4.3.6 covers these selectors).

    <style>

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

    input { font: inherit; display: block; width: 260px; border: 1px solid #889; padding: 3px; }

    input:user-invalid { border: 2px solid #c62828; background: #ffebee; }

    input:user-valid { border: 2px solid #2e7d32; }

    label, .err { display: block; margin-bottom: 4px; }

    .err { color: #c62828; font-size: 13px; }

    </style>

    <form id="signup">

    <label>Email <input name="email" type="email" required autocomplete="username">

    <span class="err"></span></label>

    <label>Password (8+ characters)

    <input name="pw" type="password" minlength="8" required autocomplete="new-password">

    <span class="err"></span></label>

    <button>Create account</button>

    </form>

    <script>

    const form = document.getElementById('signup');

    form.addEventListener('invalid', (e) => { // invalid does not bubble: listen in capture

    e.preventDefault(); // suppress the browser bubble

    e.target.nextElementSibling.textContent = e.target.validationMessage;

    }, true);

    form.addEventListener('input', (e) => {

    e.target.setCustomValidity('');

    e.target.nextElementSibling.textContent = '';

    });

    form.elements.email.value = 'ana@example.com';

    form.elements.pw.value = 'short'; // minlength only checks user edits...

    form.elements.pw.setCustomValidity('Use at least 8 characters.'); // ...so flag it here

    form.requestSubmit(); // runs validation like a click on the button

    </script>

    Listing 2.31: Inline error messages with :user-invalid and the invalid event Figure 2.29: Browser output of Listing 2.31minlength and maxlength check only values the user typed, not values set by script, which is why the demo sets a custom error. requestSubmit() validates and fires submit; form.submit() skips both.The autocomplete attribute tells browsers and password managers what a field holds, so they can fill a whole address or card in one tap. Beyond on and off it takes standardized tokens such as name, email, tel, street-address, postal-code, cc-number, username, current-password, new-password and one-time-code. Prefixes scope a token: shipping postal-code, work tel, or section-guest1 email when one page holds two people's details. webauthn goes last, as in autocomplete="username webauthn": when the page calls navigator.credentials.get() with mediation: 'conditional', saved passkeys appear among the suggestions. Under the hood: Without tokens, autofill engines guess a field's purpose from its type, name and id, so fields named f_27, or ids regenerated on every deploy, fill badly. Most browsers ignore autocomplete="off" on login fields; use new-password to keep saved passwords out of a sign-up form. Correct tokens also satisfy WCAG 2.2 success criterion 1.3.5, Identify Input Purpose.
  • Validate on the server too, and map its errors back onto fields with setCustomValidity() plus reportValidity(), so both kinds of error look the same.

  • Browsers mark filled fields with :autofill; keep that highlight visible so users can review what was filled.

  • 2.5.5 Customizable <select> and Modern Form Controls

    For decades <select> was the control designers gave up on: the operating system drew it, <option> held only text, and the fix was a JavaScript widget that reimplemented keyboard handling and accessibility. One CSS declaration, appearance: base-select, switches a real <select> to a stylable rendering built from ordinary boxes, and the HTML parser now keeps elements such as <img> and <span> inside <option> instead of discarding them.

    <style>

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

    select, ::picker(select) { appearance: base-select; }

    select { border: 2px solid #1565c0; border-radius: 10px; padding: 6px 10px; min-width: 210px; }

    select::picker-icon { color: #1565c0; }

    select:open::picker-icon { rotate: 180deg; }

    ::picker(select) { border-radius: 10px; box-shadow: 0 6px 20px #0003; }

    option { display: flex; gap: 8px; align-items: center; padding: 6px 10px; }

    option:checked { background: #e3f2fd; font-weight: 600; }

    option::checkmark { order: 1; margin-left: auto; color: #2e7d32; }

    select[size] { height: auto; }

    selectedcontent { display: flex; gap: 8px; align-items: center; }

    .dot { width: 12px; height: 12px; border-radius: 50%; background: var(--c); }

    small { color: #607d8b; }

    </style>

    <select name="status">

    <button><selectedcontent></selectedcontent></button>

    <option value="open"><span class="dot" style="--c:#2e7d32"></span>Open</option>

    <option value="review" selected>

    <span class="dot" style="--c:#ef6c00"></span>In review <small>2 approvals</small></option>

    <option value="closed"><span class="dot" style="--c:#6a1b9a"></span>Closed</option>

    </select>

    <select name="labels" size="3">

    <option value="bug" selected><span class="dot" style="--c:#c62828"></span>bug</option>

    <option value="docs"><span class="dot" style="--c:#1565c0"></span>documentation</option>

    <option value="help"><span class="dot" style="--c:#2e7d32"></span>help wanted</option>

    </select>

    Listing 2.32: A customizable select as a drop-down and as a list box Figure 2.30: Browser output of Listing 2.32The optional <button> as the first child replaces the default button, and <selectedcontent> inside it receives a copy of the chosen option's content, dot included. Clicking opens the drop-down, styled through ::picker(select); ::picker-icon is the arrow, ::checkmark the mark beside the chosen option, and :open matches while the picker shows. With size or multiple the same styles apply to an in-page list box, which Chrome added in version 145 (February 2026). A browser without support ignores the CSS and the <button> and shows an ordinary select with the options' text, so the form keeps working.Support in September 2026: Chrome and Edge 135 (April 2025), Opera 120 and Samsung Internet 29; Safari 27, in beta since June 2026; Firefox only behind a preference. Use @supports (appearance: base-select) if the fallback must look different, and follow WebKit's "golden rule": every option needs real text, not just an icon. Under the hood: The picker is a popover in the top layer (Section 2.6.1), so overflow: hidden on a parent cannot clip it, and CSS anchor positioning places it next to the button. <selectedcontent> is refreshed with cloneNode() when the selection changes, so later script edits to an option are not mirrored. Content inside the button is inert.
  • field-sizing: content makes inputs, textareas and selects grow with their content, retiring auto-growing textarea scripts. It became Baseline in June 2026 with Firefox 152 (Section 4.5.9).

  • <input type="checkbox" switch> draws a native on/off switch with the switch role; only Safari 17.4 and later support it so far, and other browsers show a normal checkbox.

  • input.showPicker() opens a date, color, file or datalist picker from your own button's click handler (Baseline since 2022).

  • Native controls still lack a searchable combo box with tagging and date range pickers. For those, open-source libraries such as Tom Select (https://github.com/orchidjs/tom-select) (Apache-2.0, npm install tom-select) and flatpickr (https://github.com/flatpickr/flatpickr) (MIT, npm install flatpickr) enhance an existing <select> or <input> (new TomSelect('#labels')), so the form still submits the usual fields.

    2.6 Interactive Elements

    For fifteen years, every modal, dropdown menu, tooltip and accordion on the web came from JavaScript libraries that fought z-index wars, trapped focus by hand and often forgot the Escape key. HTML now ships those behaviors as markup. A <dialog> knows how to be modal, a popover closes when you click elsewhere, a <details> group keeps only one panel open, and a <button> can open any of them without a line of script.The native versions get keyboard handling, focus management and accessibility roles for free, and work before your JavaScript loads. Each subsection states the feature's Baseline status (see Section 2.1.3); "newly available" features work only in current browsers, so check your analytics before dropping a fallback. Section 9.5.11 drives dialogs and popovers from JavaScript.

    2.6.1 <dialog> and the popover Attribute

    <dialog> is a box that stays hidden until you open it. Call showModal() and it becomes modal: it moves to the top layer, the browser paints a ::backdrop behind it, the rest of the page becomes inert, focus moves inside (to the element marked autofocus, if any), and Escape closes it. Call show() for a non-modal dialog that lets the user keep working on the page. A <form method="dialog"> inside closes the dialog on submit and copies the submit button's value into dialog.returnValue. <dialog> has been Baseline Widely available since September 2024.

    <style>

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

    dialog { border: none; border-radius: 12px; padding: 16px 24px; }

    dialog::backdrop { background: rgb(21 101 192 / 0.35); backdrop-filter: blur(2px); }

    menu { display: flex; gap: 8px; justify-content: end; padding: 0; }

    </style>

    <p>Draft: <strong>Quarterly report.docx</strong> <button id="del">Delete</button></p>

    <p id="log">Result: (none yet)</p>

    <dialog id="confirm" closedby="any" aria-labelledby="t">

    <form method="dialog">

    <h3 id="t">Delete this draft?</h3> <p>You cannot undo this action.</p>

    <menu>

    <button value="cancel" autofocus>Cancel</button>

    <button value="delete">Delete</button>

    </menu>

    </form>

    </dialog>

    <script>

    const dlg = document.getElementById('confirm');

    document.getElementById('del').onclick = () => dlg.showModal();

    dlg.addEventListener('close', () => {

    document.getElementById('log').textContent = `Result: ${dlg.returnValue || 'dismissed'}`;

    });

    dlg.showModal(); // open it for the screenshot

    </script>

    Listing 2.33: A modal confirmation dialog that reports which button closed it Figure 2.31: Browser output of Listing 2.33The close event fires however the dialog closes: a form button, dialog.close('value'), Escape, or, because of closedby="any", a click on the backdrop. closedby also accepts closerequest (Escape only, the default for modal dialogs) and none; it is supported in Chrome 134 and Firefox 141 but not yet Safari, so treat backdrop dismissal as an enhancement. dialog.requestClose() (Baseline since May 2025) closes the way Escape does, firing a cancelable cancel event first so you can ask "Discard unsaved changes?".The global popover attribute turns any element into a hidden, always non-modal overlay for menus, pickers and toasts. A button with popovertarget="id" toggles it (popovertargetaction restricts it to show or hide), and opening puts it in the top layer. The default popover="auto" light-dismisses (click outside or Escape) and closes other open auto popovers except its ancestors, which suits menus; popover="manual" closes only from code or a button, which suits toasts; popover="hint", for tooltips, light-dismisses and closes only other hints, but is not in Safari yet. Popovers became Baseline in January 2025.

    <button popovertarget="menu">File â–¾</button>

    <div id="menu" popover>

    <button>New</button> <button>Open…</button> <button popovertarget="toast">Save</button>

    </div>

    <div id="toast" popover="manual">

    Saved ✓ <button popovertarget="toast" popovertargetaction="hide">×</button>

    </div>

    Listing 2.34: A popover menu and a manual toast declared in markup Click outside the menu and it closes; the manual toast stays until its × button hides it. The beforetoggle (cancelable when opening) and toggle events report oldState and newState, and :popover-open styles the open state. Under the hood: The top layer is a browser-managed list of elements painted after the whole document, outside every stacking context, so no z-index on the page can cover them. Each showModal() or showPopover() call appends to that list, which is why a later popover appears above an earlier dialog. CSS anchor positioning (Section 4.8.10) can still tie a popover to its button. Use showModal() when the user must respond before continuing and popover for anything that should vanish when attention moves on. Never put tabindex on a <dialog>, and always include a visible close button.

    2.6.2 Invoker Commands: command and commandfor

    popovertarget only controls popovers. Invoker commands generalize the idea: any <button> with commandfor="id" and a command value performs that action on the target element when activated by mouse, keyboard or touch. The feature became Baseline in December 2025, when Safari 26.2 joined Chrome 135 and Firefox 144. command value Target Equivalent JavaScript show-modal <dialog> dialog.showModal() close / request-close <dialog> dialog.close(value) / dialog.requestClose(value) toggle-popover [popover] el.togglePopover() show-popover / hide-popover [popover] el.showPopover() / el.hidePopover() --anything Any element Your own command event listener Table 2.18: Built-in and custom invoker commands Custom commands must start with two dashes, like CSS custom properties, so future built-in names never clash with yours. The browser fires a command event on the target; its command property holds the string and source is the button. In the photo viewer below, the dialog opens and closes with no script, and two custom commands rotate the image.

    <style>

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

    dialog { border: 1px solid #999; border-radius: 10px; text-align: center; }

    #pic { width: 80px; height: 80px; margin: 8px auto;

    background: linear-gradient(135deg, #1565c0 50%, #ef6c00 50%); }

    </style>

    <button commandfor="viewer" command="show-modal">Open photo</button>

    <dialog id="viewer" aria-label="Photo viewer">

    <div id="pic" role="img" aria-label="Sample photo"></div>

    <button commandfor="pic" command="--rotate-left">⟲ Left</button>

    <button commandfor="pic" command="--rotate-right">⟳ Right</button>

    <button commandfor="viewer" command="close" value="done">Close</button>

    <p id="status">Angle: 0°</p>

    </dialog>

    <script>

    const pic = document.getElementById('pic');

    let angle = 0;

    pic.addEventListener('command', (e) => {

    angle += e.command === '--rotate-left' ? -90 : 90;

    pic.style.rotate = `${angle}deg`;

    document.getElementById('status').textContent = `Angle: ${angle}° (${e.command})`;

    });

    document.querySelector('[command="show-modal"]').click(); // two clicks for the screenshot

    document.querySelector('[command="--rotate-right"]').click();

    </script>

    Listing 2.35: A photo dialog controlled by built-in and custom commands Figure 2.32: Browser output of Listing 2.35The Close button's value becomes dialog.returnValue, just as with a <form method="dialog"> button. Declaring the link in markup also helps accessibility: for popover invokers the browser creates an implicit aria-expanded and aria-details relationship and places the open popover right after its button in the Tab order. From script, the link is exposed as button.commandForElement and button.command. Under the hood, activating the button fires a cancelable command event on the target and, unless a listener calls preventDefault(), runs the built-in action; custom commands have no built-in action, so the event is the whole feature.For older browsers, load Keith Cirkel's invokers-polyfill (https://github.com/keithamus/invokers-polyfill) (MIT, npm install invokers-polyfill), which listens for clicks on the document and dispatches the same events. Use real <button> elements: command does nothing on a <div> or a link.

    2.6.3 <details>, <summary> and Exclusive Accordions

    <details> is a disclosure widget: its first <summary> child is always visible and acts as a toggle button, and everything else shows only while the boolean open attribute is present (so open="false" still means open). The browser supplies the marker, keyboard support and the expanded state for screen readers. Baseline since January 2020.Give several <details> elements the same name and they form an exclusive accordion: opening one closes the others, with no JavaScript. Members need not be adjacent, and if several start with open, only the first in source order stays open. Exclusive accordions reached Baseline in September 2024 (Chrome 120, Firefox 130, Safari 17.2); older browsers simply let several panels open at once, a harmless fallback.

    <style>

    :root { interpolate-size: allow-keywords; } /* lets height animate to auto */

    body { font: 15px system-ui; margin: 16px; max-width: 560px; }

    details { border: 1px solid #cfd8dc; border-radius: 8px; margin-bottom: 8px; }

    summary { padding: 10px 14px; cursor: pointer; font-weight: 600; list-style: none; }

    summary::before { content: "+"; display: inline-block; width: 1.2em; color: #1565c0; }

    details[open] > summary { background: #e3f2fd; }

    details[open] > summary::before { content: "−"; }

    details::details-content { block-size: 0; overflow: hidden;

    transition: block-size .3s, content-visibility .3s allow-discrete; }

    details[open]::details-content { block-size: auto; }

    details p { margin: 0; padding: 10px 14px; }

    </style>

    <details name="faq" open>

    <summary>Do I need JavaScript for this accordion?</summary>

    <p>No. The shared <code>name</code> attribute makes the browser close the other panels.</p>

    </details>

    <details name="faq">

    <summary>Can users open two answers at once?</summary>

    <p>Not in the same group. Use different names, or none, to allow that.</p>

    </details>

    <details name="faq" open>

    <summary>What happens to this second open attribute?</summary>

    <p>It is ignored: only the first open member of a group stays open.</p>

    </details>

    Listing 2.36: An FAQ built as an exclusive accordion with custom markers and an animated panel Figure 2.33: Browser output of Listing 2.36The ::details-content pseudo-element (Baseline since September 2025) targets the collapsible part, so you can animate it; interpolate-size lets block-size transition to auto in Chromium, while other browsers open instantly. list-style: none removes the default ::marker of the <summary>. The toggle event fires after every change; read details.open in the handler. Under the hood: Browsers implement <details> with a user-agent shadow root holding two slots, one for the summary and one for the rest. The spec's user-agent style sheet hides the second with content-visibility: hidden rather than display: none, which is why ::details-content can be styled and why find-in-page can search a closed panel, open it and fire toggle (HTML spec section 6.9.2). Keep critical information outside accordions, since hidden content is easy to miss, and do not build tabs from <details>: tabs need the tablist pattern with arrow-key navigation (see Section 2.10). If an interviewer asks how to build an accordion without JavaScript, the answer is now <details name>, not the old checkbox-and-:checked hack.

    2.6.4 <search>, inert and hidden="until-found"

    <search> wraps a search or filter form and gives it the search landmark role, so screen reader users can jump to it as they jump to <nav> or <main> (see Section 2.2.6). It replaces <form role="search">, is purely semantic, and still needs the <form> inside it. Use it for site search and on-page filters, not for the results. It is Baseline Widely available since April 2026.inert is a global boolean attribute that makes a whole subtree non-interactive: its elements cannot be clicked, focused, selected or found by find-in-page, and they leave the accessibility tree. It is what showModal() applies to the rest of the page, available for a form while it submits, a closed off-canvas menu or hidden carousel slides. It adds no visual change, so pair it with a style such as reduced opacity. inert is Baseline Widely available since October 2025.hidden="until-found" hides content like hidden, but lets the browser reveal it when find-in-page (Ctrl+F) or a URL fragment such as #refunds targets it. The browser fires a beforematch event on the element, removes the hidden attribute and scrolls to the match. Support is still limited (Chrome 102 and Firefox 148, not Safari); elsewhere the content simply stays hidden, so reserve it for supplementary text.

    <style>

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

    [inert] { opacity: .45; }

    section { border: 1px solid #cfd8dc; padding: 0 12px; margin-top: 8px; }

    .found { background: #fff3e0; }

    </style>

    <search>

    <form action="/help/search">

    <label>Search help <input name="q" type="search" value="refund"></label> <button>Go</button>

    </form>

    </search>

    <section inert>

    <h3>Your orders (loading…)</h3>

    <button>Cancel order</button> <a href="/orders">View all</a>

    </section>

    <section>

    <h3>Returns</h3>

    <p>Return any item within 30 days.</p>

    <p id="refunds" hidden="until-found">Refunds reach your card within 5 business days.</p>

    </section>

    <script>

    const extra = document.getElementById('refunds');

    extra.addEventListener('beforematch', () => extra.classList.add('found'));

    location.hash = '#refunds'; // fragment navigation, like following a link to /help#refunds

    </script>

    Listing 2.37: A help page combining a search landmark, an inert panel and until-found content Figure 2.34: Browser output of Listing 2.37The orders panel is faded and its controls cannot receive focus. The refund sentence started hidden, but navigating to #refunds fired beforematch (which highlighted it) and revealed it. Under the hood: hidden="until-found" maps to content-visibility: hidden instead of display: none, so the element keeps its box and the browser can search its text without painting it. If your CSS sets display: none, contents or inline on it, it can no longer be revealed. Pitfalls: move focus out before making its container inert, or keyboard users are stranded; and never use hidden="until-found" for content that must stay private, since it is searchable by design.

    2.7 Web Components

    Web components are the browser's own component model: you define a new HTML tag once and use it in any page, with any framework or none. The term covers three standards. Custom elements register a JavaScript class for a hyphenated tag name; shadow DOM gives the element a private subtree with scoped styles; templates and slots supply the markup and place the element's children. Section 2.2.5 introduced the last two, so this section covers the lifecycle, server-rendered shadow roots and the libraries that remove the boilerplate.Components built this way outlive framework fashions: GitHub, Adobe (Spectrum Web Components) and Microsoft (Fluent UI Web Components) ship them, and all major frameworks can consume them; React 19 passes every test in the Custom Elements Everywhere suite. Section 8.4.9 covers the imperative attachShadow() API.

    2.7.1 Custom Elements and Their Lifecycle

    A custom element is a class that extends HTMLElement, registered with customElements.define(name, class). The name must start with a lowercase letter and contain a hyphen (user-card), so it can never collide with a future built-in tag, and it can be defined only once. Autonomous custom elements have been Baseline since January 2020. Avoid the other form, customized built-in elements (<button is="fancy-button">): Chrome and Firefox support it, but Safari has said it never will.The browser calls up to five lifecycle callbacks on your class. The listing logs them while an element is created, upgraded, changed, moved, adopted by another document and removed.

    class LifeLogger extends HTMLElement {

    static observedAttributes = ['status']; // only these attributes trigger the callback

    constructor() { super(); console.log('constructor'); }

    connectedCallback() { console.log(`connected parent=<${this.parentNode.localName}>`); }

    disconnectedCallback() { console.log('disconnected'); }

    adoptedCallback() { console.log('adopted'); }

    attributeChangedCallback(name, oldValue, newValue) {

    console.log(`attributeChanged ${name}: ${oldValue} -> ${newValue}`);

    }

    }

    const el = document.createElement('life-logger'); // not defined yet: a plain HTMLElement

    document.body.append(el);

    el.setAttribute('status', 'draft');

    console.log('-- define --');

    customElements.define('life-logger', LifeLogger); // upgrade runs the callbacks now

    console.log('-- change attribute --');

    el.setAttribute('status', 'ready');

    console.log('-- move --');

    document.body.prepend(document.createElement('main'));

    document.querySelector('main').append(el);

    console.log('-- adopt into a new document --');

    document.implementation.createHTMLDocument().body.append(el);

    console.log('-- remove --');

    el.remove();

    Listing 2.38: Logging every lifecycle callback of a custom element

    -- define --

    constructor

    attributeChanged status: null -> draft

    connected parent=<body>

    -- change attribute --

    attributeChanged status: draft -> ready

    -- move --

    disconnected

    connected parent=<main>

    -- adopt into a new document --

    disconnected

    adopted

    connected parent=<body>

    -- remove --

    disconnected

    Output of Listing 2.38 Notice the upgrade: the element existed before its definition, so define() ran the constructor, replayed attributeChangedCallback for the observed attribute already present, then called connectedCallback. The same happens to tags parsed before your script loads. A move is a disconnect plus a connect, which resets state such as running animations; Chrome 133 and Firefox 144 add parent.moveBefore(el, ref), which calls connectedMoveCallback() instead, but Safari does not support it yet.Figure 2.35: The custom element lifecycle and the event that triggers each callbackA custom control can join forms like a native <input>. Set static formAssociated = true, call this.attachInternals(), and use the returned ElementInternals to submit a value (setFormValue()) and report validity (setValidity()); <label>, disabled and form reset then work too. Its states set holds custom states that CSS matches with :state(). Form-associated custom elements are Baseline Widely available since September 2025, and :state() is Baseline since May 2024.

    <style>

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

    star-rating { font-size: 28px; color: #b0bec5; cursor: pointer; }

    star-rating:state(rated) { color: #ef6c00; }

    </style>

    <form id="f">

    <label for="r">Rate this book:</label> <star-rating id="r" name="stars" value="4"></star-rating>

    <button>Send</button> <output id="o"></output>

    </form>

    <script>

    customElements.define('star-rating', class extends HTMLElement {

    static formAssociated = true;

    #internals = this.attachInternals();

    constructor() {

    super();

    this.addEventListener('click', (e) => (this.value = Number(e.target.dataset.n)));

    }

    connectedCallback() { this.value = Number(this.getAttribute('value')); }

    formResetCallback() { this.value = 0; }

    set value(v) {

    this.innerHTML = [1, 2, 3, 4, 5]

    .map((n) => `<span data-n="${n}">${n <= v ? '★' : '☆'}</span>`).join('');

    this.#internals.setFormValue(String(v));

    this.#internals.setValidity(v ? {} : { valueMissing: true }, 'Pick a rating');

    if (v) this.#internals.states.add('rated'); else this.#internals.states.delete('rated');

    }

    });

    const f = document.getElementById('f');

    f.onsubmit = (e) => {

    e.preventDefault();

    document.getElementById('o').value = new URLSearchParams(new FormData(f));

    };

    f.requestSubmit(); // submit once for the screenshot

    </script>

    Listing 2.39: A star rating that submits with its form, validates, and exposes a custom state Figure 2.36: Browser output of Listing 2.39The output shows the submitted form data, stars=4, produced by an element the browser had never heard of. Under the hood: Callbacks do not run in the middle of a DOM operation: they are pushed onto a custom element reaction queue that is flushed as the operation returns, so your code never sees a half-finished tree. Pitfalls. Do not read attributes or add children in the constructor; the spec forbids it. connectedCallback can run many times, so keep one-time setup (like the click listener above) in the constructor and stop timers in disconnectedCallback. Hide not-yet-upgraded tags with :not(:defined) { visibility: hidden; }.

    2.7.2 Declarative Shadow DOM

    The components in Sections 2.2.5 and 2.7.1 create their shadow roots in JavaScript, so nothing appears until the script has run, and server-rendered HTML cannot contain them. Declarative shadow DOM (DSD) fixes both: a <template shadowrootmode="open"> as the first child of an element tells the HTML parser to attach a shadow root to that parent and put the template's content inside; the template itself never enters the DOM. DSD reached Baseline in February 2024 and became Widely available in August 2026.

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

    <user-badge>

    <template shadowrootmode="open">

    <style>

    :host { display: flex; gap: 10px; align-items: center; padding: 8px 14px;

    border: 1px solid #cfd8dc; border-radius: 999px; }

    .dot { width: 34px; height: 34px; border-radius: 50%; background: #1565c0; }

    button { all: unset; cursor: pointer; color: #1565c0; }

    </style>

    <span class="dot"></span> <slot>Anonymous</slot> <button part="follow">Follow</button>

    </template>

    Ada Lovelace

    </user-badge>

    <user-badge>Grace Hopper</user-badge>

    <script>

    customElements.define('user-badge', class extends HTMLElement {

    constructor() {

    super();

    if (!this.shadowRoot) { // no server-rendered root: build one on the client

    this.attachShadow({ mode: 'open' }).innerHTML = '<slot></slot> <button>Follow</button>';

    }

    this.shadowRoot.querySelector('button').addEventListener('click', (e) => {

    e.target.textContent = 'Following';

    });

    }

    });

    document.querySelector('user-badge').shadowRoot.querySelector('button').click();

    </script>

    Listing 2.40: Server-rendered cards with declarative shadow roots, upgraded later by JavaScript Figure 2.37: Browser output of Listing 2.40The first badge rendered, with scoped styles and a slotted name, before any script ran; the script then hydrated it by attaching a click handler to the existing button. The second badge had no template, so the constructor built a plain root on the client. Every server-rendered instance needs its own template. Three more boolean attributes mirror attachShadow() options: shadowrootdelegatesfocus (focusing the host focuses the first focusable element inside), shadowrootclonable (cloneNode() copies the root) and shadowrootserializable (getHTML({ serializableShadowRoots: true }) outputs it). Under the hood: Only the parser creates declarative roots: setting shadowRootMode on an existing <template> does nothing, and innerHTML ignores DSD. To insert DSD markup from script, use setHTMLUnsafe() or Document.parseHTMLUnsafe() (Baseline since September 2025), with trusted or sanitized input only. If a component calls attachShadow() with the same mode on a host that already has a declarative root, the browser clears that root and returns it instead of throwing. You rarely hand-write DSD; server renderers emit it. Lit's `@lit-labs/ssr` (https://github.com/lit/lit/tree/main/packages/labs/ssr) (4.1.0, still a Labs package) renders Lit components in Node (with Eleventy, Astro, Next.js and Nuxt integrations), and @lit-labs/ssr-client hydrates them.

    2.7.3 Libraries

    Hand-written custom elements repeat the same chores: syncing attributes and properties, re-rendering, escaping values. Libraries automate them and still output standard custom elements.Lit (https://github.com/lit/lit) (Google, BSD-3-Clause, npm install lit, version 3.3.3) adds three things to HTMLElement: reactive properties that schedule a batched update when they change, a render() method returning an html tagged template, and static styles scoped to the shadow root. Only the template parts whose values changed are updated (the template cloning technique of Section 2.2.5). The prebuilt lit-core.min.js bundle used below is about 6 KB gzipped.

    <script type="module">

    import { LitElement, html, css }

    from 'https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js';

    class LikeButton extends LitElement {

    static properties = { count: { type: Number, reflect: true }, label: {} };

    static styles = css`

    button { font: 16px system-ui; padding: 6px 14px; border: 1px solid #1565c0;

    border-radius: 999px; background: #e3f2fd; color: #1565c0; }

    :host([count="0"]) button { background: #fff; }`;

    constructor() { super(); this.count = 0; this.label = 'Like'; }

    render() {

    return html`<button @click=${this.#like}>♥ ${this.label} · ${this.count}</button>`;

    }

    #like() {

    this.count++;

    this.dispatchEvent(new CustomEvent('liked', { detail: this.count, bubbles: true }));

    }

    }

    customElements.define('like-button', LikeButton);

    const b = document.querySelector('#b');

    await b.updateComplete;

    b.renderRoot.querySelector('button').click(); // two clicks for the screenshot

    b.renderRoot.querySelector('button').click();

    </script>

    <like-button></like-button> <like-button id="b" label="Helpful"></like-button>

    Listing 2.41: A Lit component with a reactive property, scoped styles and a custom event Figure 2.38: Browser output of Listing 2.41Because count has reflect: true, the second element now carries count="2" and the :host([count="0"]) rule no longer matches it. In TypeScript you would write @property({ type: Number }) count = 0; instead of static properties. In plain JavaScript, set defaults in the constructor as shown: a class field would shadow Lit's property accessor and break updates.Stencil (https://github.com/stenciljs/core) (Ionic, MIT, version 4.45.0) is a compiler, not a runtime library. You write TypeScript classes with decorators and JSX; the build emits standard custom elements, lazy-loaded bundles, type definitions and wrappers for React, Angular and Vue. Ionic Framework's own components are built with it. Create a project with npm init stencil and run npm start.

    import { Component, Prop, State, Event, EventEmitter, h } from '@stencil/core';

    @Component({ tag: 'star-meter', styleUrl: 'star-meter.css', shadow: true })

    export class StarMeter {

    @Prop() max = 5; // public attribute/property: <star-meter max="10">

    @State() value = 0; // internal state: changing it re-renders

    @Event() rated: EventEmitter<number>; // emits a 'rated' DOM event

    render() {

    return Array.from({ length: this.max }, (_, i) => (

    <button onClick={() => { this.value = i + 1; this.rated.emit(i + 1); }}>

    {i < this.value ? '★' : '☆'}

    </button>

    ));

    }

    }

    Listing 2.42: src/components/star-meter/star-meter.tsx: a Stencil component The compiler turns the decorators into observed attributes, accessors and a small virtual DOM renderer. Choose Stencil to publish one design system to several frameworks, and Lit for a small runtime with no mandatory build.Shoelace, a popular MIT-licensed set of ready-made components built with Lit, is sunset: its repository is archived at version 2.20.1 and development continues as Web Awesome (https://github.com/shoelace-style/webawesome), published by the makers of Font Awesome. Web Awesome 3.0 shipped in October 2025 (3.12.0 at the time of writing), uses the wa- prefix, and is themed through CSS custom properties and ::part(). Its Core components are free and MIT licensed; the paid Pro subscription adds data grids, charts, extra themes and page patterns. To try it, link https://ka-f.webawesome.com/webawesome@3.12.0/styles/themes/default.css, load webawesome.loader.js from the same folder as a module script, and write <wa-button variant="brand">Save</wa-button>. Under the hood: The loader queries the page for :not(:defined) elements whose names start with wa-, dynamically imports components/<name>/<name>.js for each, and keeps a MutationObserver running so tags added later load on demand. With npm (npm install @awesome.me/webawesome), import components individually instead. Library What it is License Strengths Weaknesses Lit 3.3 Base class and templating BSD-3-Clause Tiny, no build needed, SSR (Labs) You build every widget Stencil 4.45 Compiler for component libraries MIT Framework wrappers, lazy loading Build step, own decorators Web Awesome 3.12 Ready-made components on Lit MIT Core, paid Pro Polished widgets, theming Advanced widgets cost money FAST Element 3.0 Microsoft's base class MIT Powers Fluent UI Web Components Smaller community Table 2.19: Web component libraries compared (September 2026)

    2.8 Security

    A page is rarely built from your own code alone. It pulls Bootstrap from a CDN, analytics from a vendor, a map from another company and comments typed by strangers. Every one of those inputs is a way in. The browser's baseline defense is the same-origin policy: a script from https://shop.example cannot read the DOM, cookies or network responses of https://bank.example. An origin is the triple of scheme, host and port, so http://shop.example and https://shop.example:8443 are both different origins from https://shop.example.On top of that policy, HTML and HTTP give you element attributes and response headers, each aimed at a class of attack: crossorigin and CORS for controlled cross-origin reads, integrity against tampered CDN files, referrerpolicy against URLs leaking through the Referer header, nonces and Content Security Policy against cross-site scripting (XSS), Permissions Policy against embeds misusing powerful features, and sandboxed iframes and Trusted Types for untrusted frames and DOM-based XSS. XSS, in which an attacker gets their JavaScript to run in your origin, is the most damaging front-end bug, because the injected code can do anything your own code can.None of these replaces server-side validation, CSRF tokens or SameSite cookies, and all of them assume HTTPS, which Section 1.6.5 sets up. Treat them as defense in depth: when an escaping bug lets markup through, a strict policy still stops the payload from running.

    2.8.1 crossorigin (CORS)

    The same-origin policy blocks a script from reading cross-origin responses, but it never stopped a page from embedding them: <img>, <script src> and stylesheets have loaded from any host since the 1990s. Cross-Origin Resource Sharing (CORS) lets a server declare, with response headers, that a response may also be read by other origins; the browser enforces the decision. CORS relaxes the same-origin policy and never protects a server: curl and server-side code ignore it.Every cross-origin fetch() is a CORS request. For elements, the crossorigin attribute switches the fetch from the old "no-cors" embedding mode into CORS mode. It is valid on <script>, <link>, <img>, <audio> and <video>. The value anonymous (also an empty or invalid value) sends no cookies cross-origin; use-credentials sends them, and the server must then allow credentials explicitly.Leaving the attribute off has concrete consequences: an image drawn into a <canvas> taints it, so getImageData() throws a SecurityError; an exception in a cross-origin script reaches window.onerror only as "Script error."; and integrity (Section 2.8.2) cannot be checked. Module scripts always use CORS, which is why they fail when a CDN omits the header.A simple request uses GET, HEAD or POST with only safelisted headers, and a Content-Type of at most text/plain, multipart/form-data or application/x-www-form-urlencoded: requests an HTML form could already send. The browser sends it and checks Access-Control-Allow-Origin on the response. Anything else, such as PUT, a JSON body or an Authorization header, first triggers a preflight, an OPTIONS request asking permission.Figure 2.39: A CORS preflight followed by the actual requestIf step 2 lacks a matching header, step 3 is never sent and fetch() rejects with a TypeError. The browser caches a preflight for Access-Control-Max-Age seconds: 5 by default, capped at 2 hours in Chromium and 24 hours in Firefox. The demo makes three real requests:

    <pre id="log" style="font: 14px/1.6 Consolas, monospace; margin: 12px"></pre>

    <script>

    const log = (s) => (document.getElementById('log').textContent += s + '\n');

    const pkg = 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/package.json';

    async function attempt(label, url, options) {

    try {

    const data = await (await fetch(url, options)).json();

    log(`${label}: OK, read version ${data.version}`);

    } catch (err) {

    log(`${label}: ${err.name}: ${err.message}`);

    }

    }

    (async () => {

    await attempt('1. Simple GET to jsDelivr ', pkg);

    await attempt('2. GET with X-Demo header ', pkg, { headers: { 'X-Demo': '1' } });

    await attempt('3. GET to example.com ', 'https://example.com/');

    })();

    </script>

    Listing 2.43: A simple request, a failed preflight and a missing CORS header Figure 2.40: Browser output of Listing 2.43jsDelivr sends Access-Control-Allow-Origin: *, so request 1 succeeds. Request 2's custom header forces a preflight, and jsDelivr's OPTIONS response does not list X-Demo in Access-Control-Allow-Headers. Request 3 reaches a server with no CORS headers. The console shows the precise reason, but JavaScript gets only a generic error, so a page cannot use CORS failures to probe other servers.On the server, the cors (https://github.com/expressjs/cors) middleware for Express (MIT, npm install cors) takes an allowlist, app.use(cors({ origin: ['https://app.example'], credentials: true, maxAge: 7200 })), echoes the matching origin, answers preflights and adds Vary: Origin so caches keep per-origin responses apart. The client then calls fetch(url, { credentials: 'include' }). Warning: With credentials the browser rejects Access-Control-Allow-Origin: *, so some servers reflect whatever Origin arrives. That lets every website read your users' authenticated data. Compare against an allowlist, and never trust Origin: null, which sandboxed frames and file: pages send. A common interview question is whether CORS stops CSRF. It does not: a cross-site form POST is sent, with cookies, before any CORS check; CORS only hides the response. Use SameSite cookies and CSRF tokens.

    2.8.2 integrity (Subresource Integrity)

    A CDN serves the same file to millions of sites, which makes it an attractive target: alter one file and every site that loads it runs the attacker's code. Subresource Integrity (SRI) pins the exact bytes you tested. The integrity attribute holds a base64-encoded hash prefixed with its algorithm (sha256-, sha384- or sha512-). The browser hashes the downloaded file and compares; on a mismatch the response becomes a network error, so the script never runs and the stylesheet never applies. These are the published hashes for Bootstrap 5.3.8:

    <link rel="stylesheet"

    href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"

    integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB"

    crossorigin="anonymous">

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"

    integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"

    crossorigin="anonymous"></script>

    Listing 2.44: Loading Bootstrap 5.3.8 from jsDelivr with Subresource Integrity integrity works on <script> and on <link> with rel="stylesheet", preload or modulepreload. A cross-origin file must be fetched in CORS mode, so crossorigin="anonymous" is required and the CDN must send Access-Control-Allow-Origin (Section 2.8.1). To compute a hash yourself, pipe the file through openssl dgst -sha384 -binary | openssl base64 -A and prefix the result with sha384-; for the Bootstrap CSS file it prints exactly the value above. srihash.org (https://www.srihash.org/) does the same in the browser.The demo loads the real stylesheet with its correct hash and the real bundle with one character of the hash changed, which is exactly what the browser sees when a file has been altered:

    <link rel="stylesheet"

    href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"

    integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB"

    crossorigin="anonymous">

    <div class="p-3">

    <div class="alert alert-success py-2 mb-2">CSS hash matched: Bootstrap styles applied.</div>

    <div class="alert alert-danger py-2 mb-0" id="js">Waiting for the script...</div>

    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"

    integrity="sha384-XKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"

    crossorigin="anonymous"

    onerror="js.textContent = 'JS hash mismatch: blocked. typeof bootstrap: ' + typeof bootstrap">

    </script>

    Listing 2.45: A matching stylesheet hash and a tampered script hash Figure 2.41: Browser output of Listing 2.45 Under the hood: A hash can be compared only after the last byte arrives, so the browser holds back the file until it has fully downloaded. You may list several space-separated hashes: the browser uses only the strongest algorithm present and accepts the file if any hash of that algorithm matches, which lets two builds coexist during a rollout. Two newer mechanisms extend SRI. Import maps accept an "integrity" object mapping module URLs to hashes, covering modules loaded by import statements that have no tag. The Integrity-Policy: blocked-destinations=(script) response header blocks every script lacking integrity metadata; it works in Chrome 138, Firefox 145 and Safari 26 but is not yet Baseline, so start with Integrity-Policy-Report-Only.SRI suits only files that never change: load pinned URLs such as bootstrap@5.3.8, never a "latest" alias. It cannot protect dynamic services either. The polyfill.io domain changed owners in February 2024, and in June it was found injecting redirects to betting sites into more than 100,000 websites; because the service generated a different bundle per browser, nobody could have pinned it with a hash. Self-host critical scripts, or load immutable, versioned files and hash them.

    2.8.3 referrerpolicy

    When a page loads an image, runs a fetch or follows a link, the browser may add a Referer request header naming the page the request came from. (The misspelling dates from the original HTTP specification; the newer Referrer-Policy header and referrerpolicy attribute are spelled correctly.) Servers use it for analytics and hotlink protection, but it also leaks: https://shop.example/reset?token=9f2c41 tells every third-party image, font and analytics host a secret. The fragment and any user:password@ part are never sent; the path and query string can be. A referrer policy decides how much goes out, here for a request from that reset page: Policy To same origin To another HTTPS origin To an HTTP URL no-referrer Nothing Nothing Nothing no-referrer-when-downgrade Full URL Full URL Nothing origin Origin only Origin only Origin only origin-when-cross-origin Full URL Origin only Origin only same-origin Full URL Nothing Nothing strict-origin Origin only Origin only Nothing strict-origin-when-cross-origin (default) Full URL Origin only Nothing unsafe-url Full URL Full URL Full URL Table 2.20: What each referrer policy sends ("origin only" is https://shop.example/) With no policy, browsers apply strict-origin-when-cross-origin. Until Chrome 85 switched in August 2020, the common default was no-referrer-when-downgrade, which sent full URLs, query strings included, to every HTTPS site; many older tutorials still describe that behavior.You can set a policy with the Referrer-Policy response header, a <meta name="referrer"> tag, the referrerpolicy attribute on <a>, <area>, <img>, <iframe>, <link> and <script>, the rel="noreferrer" link type, or fetch()'s referrerPolicy option. The most specific setting wins.

    <!-- Document-wide; the header "Referrer-Policy: same-origin" does the same -->

    <meta name="referrer" content="same-origin">

    <!-- A partner's pixel may learn which site, but not which page -->

    <img src="https://stats.partner.example/p.gif" referrerpolicy="strict-origin" alt="">

    <!-- rel="noreferrer" omits Referer and also implies noopener -->

    <a href="https://forum.example/thread/88" target="_blank" rel="noreferrer">Discuss</a>

    Listing 2.46: Referrer policies at document and element level The header alone accepts comma-separated fallbacks with the preferred policy last, because the browser applies the last value it understands; attributes and <meta> take one value. Tip: Reset-password, invitation and magic-login pages carry secrets in the URL. Serve them with Referrer-Policy: no-referrer and load no third-party resources on them. Pitfalls: some payment callbacks and hotlink filters check Referer, so no-referrer can break an embedded widget. And never treat Referer as proof of origin: extensions and proxies strip it, and any non-browser client can forge it.

    2.8.4 nonce

    A Content Security Policy (Section 2.8.5) that forbids inline scripts is the strongest defense against XSS, but real pages have inline code: a configuration object, an analytics snippet, a theme switch that must run before first paint. The nonce attribute ("number used once") approves those specific blocks. The server generates a fresh random value for every response and prints it both in the policy, as 'nonce-VALUE', and on each approved <script> or <style>. Injected markup cannot know this response's value, so it does not run. Use at least 128 bits from a cryptographically secure generator, base64-encoded, new on every page load; in Node.js that is crypto.randomBytes(16).toString('base64'). A fixed nonce in a template is no better than 'unsafe-inline', and neither is a nonced page replayed from a CDN cache, so send such pages with Cache-Control: no-store or private.Frameworks automate this: Next.js generates the nonce in its request proxy, extracts it from the Content-Security-Policy header while rendering and adds it to its own scripts. The demo uses a <meta> policy so it works as a static file. It contains a nonced script, a plain inline script, a guessed nonce and an onclick.

    <!doctype html>

    <meta http-equiv="Content-Security-Policy" content="script-src 'nonce-Q2hhcHRlcjJub25jZQ'">

    <ul id="out" style="font: 15px system-ui"></ul>

    <script nonce="Q2hhcHRlcjJub25jZQ">

    const say = (t) => out.insertAdjacentHTML('beforeend', `<li>${t}</li>`);

    say('Nonced script ran. getAttribute("nonce") returns "' +

    document.currentScript.getAttribute('nonce') + '"');

    document.addEventListener('securitypolicyviolation', (e) =>

    say(`Blocked (${e.effectiveDirective}) at line ${e.lineNumber}`));

    </script>

    <script>say('Injected script ran');</script>

    <script nonce="guess123">say('Guessed nonce ran');</script>

    <button onclick="say('Inline handler ran')" id="b">Button</button>

    <script nonce="Q2hhcHRlcjJub25jZQ">b.click();</script>

    Listing 2.47: Only the script carrying the nonce runs Figure 2.42: Browser output of Listing 2.47 Under the hood: When the policy arrives in an HTTP header, the browser hides every nonce once the element is connected: getAttribute('nonce') returns an empty string and only the element.nonce property keeps the value. This stops injected CSS from stealing it with selectors such as script[nonce^="a"]. The demo's <meta> policy does not trigger hiding, one more reason to prefer the header. Nonces do not cover inline event handlers or javascript: URLs; move that code into nonced scripts with addEventListener(). For static pages that cannot be generated per request, list the SHA-256 hash of each inline script instead ('sha256-...', the same base64 format as Section 2.8.2); Chrome's console message for a blocked inline script includes its hash, ready to copy into the policy.

    2.8.5 Content Security Policy

    Content Security Policy (CSP) is a response header that tells the browser which scripts, styles, images, frames and connections a document may use. If an attacker slips <script> or <img onerror=...> into your HTML, a good policy stops it from executing. A policy is a semicolon-separated list of directives, each with a source list. CSP has been Baseline Widely available since 2016; the current specification is CSP Level 3. Directive What it controls default-src Fallback for every fetch directive not listed script-src, style-src, img-src, font-src Scripts, stylesheets, images, fonts connect-src fetch(), XHR, WebSocket, EventSource frame-src, object-src Frames; <object> and <embed> base-uri, form-action <base href>; where forms may submit frame-ancestors Which sites may frame this page (clickjacking) report-to Reporting endpoint for violations require-trusted-types-for DOM XSS sinks (Section 2.8.7) Table 2.21: The most useful CSP directives Sources are keywords in single quotes or bare hosts: 'self' (same origin), 'none', https://cdn.example, *.example.com, https:, 'nonce-…' and 'sha256-…' (Section 2.8.4), 'unsafe-inline' (any inline code), 'unsafe-eval' (eval() and new Function()) and 'wasm-unsafe-eval' (WebAssembly only). Older references list plugin-types and navigate-to; neither is in Level 3, and report-uri is deprecated in favor of report-to.Early policies listed trusted hosts, such as script-src 'self' 'unsafe-inline' https://apis.google.com .... In 2016 Google engineers analyzed 26,011 distinct policies from 1.68 million hosts and found 94.72% of them bypassable: 'unsafe-inline' lets injected code run, and big allowlisted domains host JSONP endpoints and old library versions that attackers can repurpose. The same paper introduced 'strict-dynamic', the basis of today's recommended strict CSP:

    Reporting-Endpoints: csp="https://example.com/csp-reports"

    Content-Security-Policy-Report-Only:

    script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';

    object-src 'none'; base-uri 'none'; frame-ancestors 'self';

    report-to csp; report-uri https://example.com/csp-reports

    Listing 2.48: A strict policy rolled out in report-only mode first The header is sent on one line; it is wrapped here for reading. The nonce approves your scripts, and 'strict-dynamic' extends that trust to scripts they create with document.createElement('script'), so tag managers and lazy-loaded bundles work without a host list. https: and 'unsafe-inline' are fallbacks for old browsers: any browser that understands nonces ignores 'unsafe-inline', and one that understands 'strict-dynamic' ignores https:. object-src 'none' disables plugins, base-uri 'none' stops an injected <base> from redirecting relative script URLs, and frame-ancestors replaces X-Frame-Options. Warning: A <meta http-equiv="Content-Security-Policy"> tag ignores frame-ancestors, report-to, report-uri and sandbox, and protects only content parsed after it. Use the header in production. Roll out in stages: send the -Report-Only header, collect reports, fix your inline handlers, then enforce. Helmet (https://github.com/helmetjs/helmet) (MIT, npm install helmet) sets CSP and a dozen other security headers in Express with app.use(helmet()), and Google's CSP Evaluator (https://csp-evaluator.withgoogle.com/) (Apache-2.0) flags bypassable allowlists before you deploy. A frequent interview question asks why 'unsafe-inline' in script-src is pointless against XSS; the answer is that a typical XSS payload is inline script.

    2.8.6 Permissions Policy (formerly Feature Policy)

    Powerful browser features such as the camera, microphone, geolocation, payment requests, USB and fullscreen are controlled per document. Permissions Policy lets you switch them off for your own page and decide which embedded frames may even ask for them. It limits what compromised or third-party code can do: an ad frame that is denied geolocation cannot trigger a location prompt, however it is written. The mechanism was introduced as Feature Policy with a Feature-Policy header; it was renamed, and the header syntax changed to structured fields.The old Feature-Policy: camera 'none'; geolocation 'self' https://maps.example is written today as Permissions-Policy: camera=(), geolocation=(self "https://maps.example"). In the header, * allows every origin, () disables the feature everywhere, self means the page's own origin, and other origins appear in double quotes. Frames need a second grant through the allow attribute, whose syntax is the old one: unquoted origins, keywords in single quotes, default 'src' (the frame's own origin). A cross-origin frame can use a feature only if both the top-level policy and its allow list permit it.

    <!-- Page served with: Permissions-Policy: geolocation=(self "https://maps.example"), camera=() -->

    <iframe src="https://maps.example/embed" allow="geolocation" title="Store locator"></iframe>

    <!-- Asking for the camera fails: the page's header disabled it everywhere -->

    <iframe src="https://chat.example/widget" allow="camera; microphone" title="Support chat"></iframe>

    Listing 2.49: Delegating features to specific frames Support differs between the two halves. The Permissions-Policy header is implemented only in Chromium-based browsers (Chrome 88 onward for most features); Firefox and Safari ignore it. The allow attribute works in all engines for the features each one implements (Chrome 60, Firefox 74, Safari 11.1). Append ;report-to=endpoint to a directive to receive violation reports through the Reporting API. Tip: Deny what you never use. A header such as Permissions-Policy: camera=(), microphone=(), geolocation=(), usb=(), payment=() costs nothing and shrinks what an injected script or rogue embed can reach. Section 2.2.4 shows a typical allow list for a video embed.

    2.8.7 Sandboxed iframes and Trusted Types

    An empty sandbox attribute on an <iframe> (demonstrated in Section 2.2.4) gives the frame an opaque origin and blocks scripts, forms, popups, modal dialogs, downloads and top-level navigation. Each token lifts one restriction: Token Re-enables allow-scripts JavaScript execution allow-same-origin The real origin: cookies, storage, same-origin access allow-forms Form submission allow-popups, allow-modals, allow-downloads New windows; alert(); downloads allow-top-navigation-by-user-activation Navigating the top page after a click Table 2.22: Common iframe sandbox tokens Also defined are allow-popups-to-escape-sandbox, allow-pointer-lock, allow-orientation-lock, allow-presentation, allow-top-navigation and allow-top-navigation-to-custom-protocols. Grant the minimum: a code playground needs sandbox="allow-scripts" on a separate origin, never allow-scripts allow-same-origin on your own, which lets the frame remove its sandbox. For files you serve rather than frame, such as user uploads, the CSP header Content-Security-Policy: sandbox applies the same restrictions to the whole document.Most modern XSS is DOM-based: your own JavaScript passes attacker-controlled text to an injection sink such as innerHTML, document.write(), eval() or a script's src. Trusted Types, Baseline since February 2026 (Chrome 83, Firefox 148, Safari 26), makes those sinks refuse plain strings once the CSP directive require-trusted-types-for 'script' is present. Code must pass a TrustedHTML, TrustedScript or TrustedScriptURL object created by a named policy, and the trusted-types directive lists which policy names may exist. Security review then shrinks from every innerHTML in the codebase to the few policy functions.

    <!doctype html>

    <meta http-equiv="Content-Security-Policy"

    content="require-trusted-types-for 'script'; trusted-types comments">

    <ul id="log" style="font: 15px system-ui"></ul>

    <script>

    const say = (t) => log.append(Object.assign(document.createElement('li'), { textContent: t }));

    const input = 'Nice post! <img src=x onerror="alert(document.cookie)">';

    const box = document.createElement('p');

    try { box.innerHTML = input; } catch (e) { say(`Plain string: ${e.name}`); }

    const policy = trustedTypes.createPolicy('comments', {

    createHTML: (s) => s.replaceAll('&', '&amp;').replaceAll('<', '&lt;'),

    });

    box.innerHTML = policy.createHTML(input);

    say(`Policy output accepted, rendered as text: ${box.textContent}`);

    try { trustedTypes.createPolicy('other', {}); } catch (e) { say(`Unlisted policy: ${e.name}`); }

    </script>

    Listing 2.50: Strings rejected, policy output accepted Figure 2.43: Browser output of Listing 2.50Real policies usually sanitize instead of escaping. DOMPurify (https://github.com/cure53/DOMPurify) (Apache-2.0 or MPL-2.0, npm install dompurify) parses the markup, removes scripts and event handlers, and with { RETURN_TRUSTED_TYPE: true } returns TrustedHTML directly. A policy named default is called automatically whenever a plain string reaches a sink, which helps migrate legacy code. Deploy it like CSP: start with Content-Security-Policy-Report-Only: require-trusted-types-for 'script' and fix each reported sink.

    2.9 Special References

    This section is the chapter's lookup shelf. You will come back to it when a validator complains about an unescaped ampersand, when a server answers 415, or when an interviewer asks why PUT is idempotent and POST is not.The first five subsections cover markup; the rest cover URL encoding, MIME types, media queries in attributes and the HTTP methods, headers and status codes your pages travel with. Tables list the entries you meet in practice.

    2.9.1 Global Attributes

    Global attributes are valid on every HTML element, from <html> to <span>, although some only affect elements a user can see or type into. Event handler attributes such as onclick are allowed everywhere too. Attribute Purpose id, class, style, title ID, CSS classes, inline CSS, tooltip text lang, dir, translate Language, text direction, translation opt-out hidden, inert Hide (until-found too); disable a subtree tabindex, accesskey, autofocus Tab order, shortcut key, focus on load contenteditable, draggable Editing (true, plaintext-only); dragging spellcheck, autocapitalize, autocorrect Typing aids for editable text inputmode, enterkeyhint Virtual keyboard layout and Enter-key label popover, nonce Popover; CSP nonce (Sections 2.6.1, 2.8.4) data-* Custom data; read as element.dataset strings slot, part, exportparts, is Shadow DOM, custom elements (no is in Safari) itemscope, itemprop and other item* Microdata for search engines (Chapter 10) role, aria-* Accessibility semantics (WAI-ARIA spec) Table 2.23: Global attributes

    <p id="ship" data-ship-id="324" hidden>Cruiser X3</p>

    <p contenteditable="plaintext-only" style="border: 1px dashed #888">Click to edit me.</p>

    <p lang="ar" dir="rtl">مرحبا بالعالم!</p>

    <script>

    document.body.append('dataset.shipId = ' + document.getElementById('ship').dataset.shipId);

    </script>

    Listing 2.51: Global attributes at work Figure 2.44: Browser output of Listing 2.51hidden is only a default display: none in the browser's style sheet, so an author rule that sets display overrides it; add [hidden] { display: none !important; } to your reset. Microdata still works, but Google recommends JSON-LD in a <script type="application/ld+json"> block, which keeps structured data out of your markup (Chapter 10). The dropzone attribute in older references only ever shipped with a webkit prefix and was removed from the standard in 2017.

    2.9.2 DOCTYPE Variants

    Old DOCTYPEs named a document type definition (DTD) for validators, but browsers never read it. They use the DOCTYPE as a switch: a modern one selects standards mode, a missing or old one selects quirks mode, which emulates Netscape Navigator 4 and Internet Explorer 5 so old pages keep their layout. HTML5 chose the shortest string that triggers standards mode everywhere, case-insensitive. DOCTYPE Year Rendering mode <!DOCTYPE html> 2008+ No-quirks (standards) HTML 4.01 or XHTML 1.0 Strict; XHTML 1.1 1999-2001 No-quirks XHTML 1.0 or HTML 4.01 (with DTD URL) Transitional/Frameset 1999-2000 Limited-quirks HTML 4.01 Transitional/Frameset, no DTD URL 1999 Quirks HTML 2.0 or 3.2; no DOCTYPE 1995-97 Quirks Table 2.24: DOCTYPEs and the rendering mode the HTML parser assigns Limited-quirks ("almost standards") mode keeps only two line-height quirks, which stop an image alone in a table cell from leaving a gap below it. Quirks mode adds more: <html> and <body> stretching to fill the viewport, table quirks, hex colors without # and lengths without units. document.compatMode reports "CSS1Compat" for standards and "BackCompat" for quirks.

    <html>

    <body style="font: 16px system-ui; margin: 12px">

    <div style="width: 300; background: #e3f2fd; border: 2px solid #1565c0">

    width: 300 (no unit) works

    </div>

    <script>document.body.append('document.compatMode = ' + document.compatMode);</script>

    </body>

    </html>

    Listing 2.52: A page without a DOCTYPE renders in quirks mode Figure 2.45: Browser output of Listing 2.52Any text before the DOCTYPE other than whitespace and comments, such as a stray character from a template, makes the parser miss it and drops the page into quirks mode.

    2.9.3 Characters and Entities

    Save every page as UTF-8 and declare <meta charset="utf-8"> in the first 1024 bytes: the HTML standard requires UTF-8, and W3Techs finds it on 99.1% of sites. You can then type é or 中 directly and need a character reference only to escape markup, insert invisible characters or write symbols your keyboard lacks. A reference can be named (&copy;), decimal (&#169;) or hexadecimal (&#xA9;, matching code point U+00A9); numeric forms work for any character. Char Reference Char Reference Char Reference & < > &amp; &lt; &gt; © ® ™ &copy; &reg; &trade; × ÷ &times; &divide; " ' &quot; &apos; € £ &euro; &pound; ± − &plusmn; &minus; no-break space &nbsp; ° § &deg; &sect; ≤ ≥ ≠ &le; &ge; &ne; soft hyphen &shy; • … &bull; &hellip; ← → &larr; &rarr; zero-width space &#x200B; – — &ndash; &mdash; ✓ ½ &check; &frac12; Table 2.25: Character references you will actually use Escape < and every & in text, and the quote character inside a quoted attribute. The forgiving parser hides bugs: it still recognizes 106 old names such as &copy and &not without the semicolon.

    <p>Text: ?sort=asc&copy=2 and I'm &notit;</p>

    <p>Escaped: ?sort=asc&amp;copy=2 and I'm &amp;notit;</p>

    <p>Same character three ways: &copy; &#169; &#xA9;</p>

    Listing 2.53: Unescaped ampersands and the legacy entity trap Figure 2.46: Browser output of Listing 2.53Inside an attribute such as href="?a=1&copy=2", the parser leaves a semicolon-less name alone when = or a letter follows, a historical exception for old query strings. Do not rely on it. Frameworks such as React and Vue escape text for you; the risk returns when you bypass them with innerHTML (see Section 2.8).

    2.9.4 Diacritical Marks

    A diacritic is a mark added to a letter, such as the accent in é. Unicode can store é as the single precomposed character U+00E9, or as e followed by U+0301 COMBINING ACUTE ACCENT. Combining marks live mainly in the block U+0300 to U+036F (decimal 768 to 879); place one after any character with a numeric reference. Marks stack, which is how "Zalgo" text piles them above and below a letter. Mark Reference Result Mark Reference Result Grave, acute a&#x300; a&#x301; à á Caron, cedilla c&#x30C; c&#x327; č ç Circumflex, tilde a&#x302; n&#x303; â ñ Dot below, ring a&#x323; a&#x30A; ạ å Diaeresis u&#x308; ü Long stroke a&#x336; a̶ Table 2.26: Common combining marks (U+0300 block)

    <p style="font: 22px Georgia, serif">

    Vie&#x323;&#x302;t Nam &middot; s&#x336;t&#x336;r&#x336;i&#x336;k&#x336;e&#x336; &middot;

    Z&#x351;&#x352;&#x353;a&#x300;&#x301;&#x302;&#x303;l&#x308;&#x30A;g&#x327;o&#x336;</p>

    Listing 2.54: Stacking combining marks on base letters Figure 2.47: Browser output of Listing 2.54The two encodings look identical but compare as different strings, so a search for "café" can miss "café". Normalize input before comparing or storing it: combined.normalize('NFC') composes, 'NFD' decomposes, and str.normalize('NFD').replace(/\p{M}/gu, '') turns "Crème brûlée" into the search key "Creme brulee". To count characters as a reader sees them, use Intl.Segmenter (Baseline since April 2024) rather than length. Strip marks only for search keys and slugs, never for display: in Vietnamese or Czech the mark changes the word.

    2.9.5 Language Codes

    The lang attribute (and hreflang on links, srclang on <track>) takes a BCP 47 language tag: a primary language, usually a two-letter ISO 639-1 code, then optional hyphen-separated subtags for script (ISO 15924, Hant) and region (ISO 3166-1 or a UN number, TW, 419). Tags are case-insensitive but conventionally written zh-Hant-TW, and every valid subtag is listed in the IANA Language Subtag Registry. Use the shortest correct tag: fr unless the regional variant really differs. Language Tag Language Tag Language Tag English (US, UK) en-US en-GB Arabic, Persian ar fa Chinese (Simplified) zh-Hans Spanish (Latin Am.) es es-419 Hebrew he (not iw) Chinese (Traditional) zh-Hant Portuguese (Brazil) pt pt-BR Hindi, Bengali hi bn Japanese, Korean ja ko French (Canada) fr fr-CA Urdu, Turkish ur tr Vietnamese, Thai vi th German, Italian de it Russian, Ukrainian ru uk Indonesian id (not in) Dutch, Polish nl pl Swahili sw Malay, Filipino ms fil Table 2.27: Common language tags (iw and in were deprecated in 1989) Screen readers pick a voice from the tag and browsers use it for hyphenation, spell checking and :lang(). For Chinese, Japanese and Korean it also selects glyphs, because Unicode unifies Han characters each locale draws differently:

    <div style="font: 32px sans-serif">

    <div lang="ja">骨 次 册 写 真 (ja)</div>

    <div lang="zh-Hans">骨 次 册 写 真 (zh-Hans)</div>

    <div lang="ko">骨 次 册 写 真 (ko)</div>

    </div>

    Listing 2.55: The same code points under three lang values Figure 2.48: Browser output of Listing 2.55On multilingual sites, add <link rel="alternate" hreflang="fr-CA" href="..."> for every translation. Google requires a language before any region (en-GB, never GB), reserves x-default for the fallback page, and detects a page's language from its text, not from lang.

    2.9.6 URL Encoding

    A URL may contain only a limited set of ASCII characters, and some of them (/ ? # & =) give it structure. Any other byte is percent-encoded as % plus two hex digits, and non-ASCII text is first converted to UTF-8 bytes, so é becomes %C3%A9. Older tables that map é to %E9 show Windows-1252 bytes, which modern servers misread. Only A-Z a-z 0-9 - . _ ~ (RFC 3986 "unreserved") are safe unencoded everywhere. Char Encoded Char Encoded Char Encoded space %20 (or + in forms) & %26 / %2F " %22 + %2B : %3A # %23 , %2C = %3D % %25 ; %3B ? %3F @ %40 [ ] %5B %5D é / € %C3%A9 / %E2%82%AC Table 2.28: Percent-encodings of reserved and common characters In JavaScript, encodeURIComponent() escapes everything except A-Z a-z 0-9 - _ . ! ~ * ' ( ), so use it for a single query value or path segment. encodeURI() leaves & and ? alone, which corrupts values containing them. Better still, let URL and URLSearchParams do it; they use form encoding, in which a space becomes +. Decode exactly once: decoding twice turns an attacker's %253C into <.

    const q = 'café & crème/50% off?';

    console.log(encodeURIComponent(q));

    console.log(encodeURI(`https://example.com/search?q=${q}`)); // & splits the query: bug

    const url = new URL('https://example.com/search');

    url.searchParams.set('q', q);

    console.log(url.href);

    Listing 2.56: encodeURIComponent, encodeURI and URLSearchParams compared

    caf%C3%A9%20%26%20cr%C3%A8me%2F50%25%20off%3F

    https://example.com/search?q=caf%C3%A9%20&%20cr%C3%A8me/50%25%20off?

    https://example.com/search?q=caf%C3%A9+%26+cr%C3%A8me%2F50%25+off%3F

    Output of Listing 2.56

    2.9.7 MIME Types

    A MIME type (media type) tells the receiver what a sequence of bytes is: type/subtype, optionally with parameters, as in text/html; charset=utf-8. Browsers act on the Content-Type response header, not on the file extension, so a misconfigured server can stop a script from loading. In HTML you write MIME types in type on <source> and <link>, accept on file inputs and enctype on forms. IANA keeps the official registry. Extension MIME type Extension MIME type .html text/html .png image/png .css text/css .jpg .jpeg image/jpeg .js .mjs text/javascript .webp image/webp .json application/json .avif image/avif .webmanifest application/manifest+json .svg image/svg+xml .wasm application/wasm .woff2 font/woff2 .txt text/plain .mp4 .webm video/mp4 video/webm .pdf application/pdf .mp3 audio/mpeg Unknown binary application/octet-stream .zip application/zip HTML form, default application/x-www-form-urlencoded Form with files multipart/form-data Table 2.29: Common MIME types on the web Since RFC 9239 (2022), text/javascript is the correct type for JavaScript; application/javascript is an obsolete alias. Browsers are strict where a wrong type is dangerous: a module script with a non-JavaScript type is refused, WebAssembly.instantiateStreaming() expects application/wasm, and X-Content-Type-Options: nosniff stops the browser from sniffing (guessing a type from the bytes), so an uploaded text/plain file never runs as script.

    2.9.8 Media Queries in HTML

    The media attribute takes the same media queries as CSS @media (Chapter 4). It works on <link>, <style>, <meta name="theme-color"> and on <source> inside <picture>, <video> and <audio>. A query combines an optional media type (all, print or screen; tv, handheld and the rest are deprecated) with features in parentheses, joined by and, not and or; a comma separates alternative queries. Range syntax such as (600px <= width < 1024px) replaces min-/max- prefixes since Chrome 104, Firefox 63 and Safari 16.4. Feature Tests width, height, aspect-ratio, orientation Viewport size and shape resolution Pixel density, e.g. (resolution >= 2dppx) hover, pointer, any-hover, any-pointer Mouse versus touch input prefers-color-scheme, prefers-contrast Light or dark theme, contrast setting prefers-reduced-motion, forced-colors Motion and high-contrast settings color-gamut, dynamic-range Wide-gamut and HDR screens display-mode, scripting Installed web app; JavaScript available Table 2.30: Media features you will use most (device-width and device-height are deprecated)

    <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0d1117">

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

    <picture>

    <source srcset="hero-dark.avif" media="(prefers-color-scheme: dark)">

    <img src="hero.jpg" alt="Harbor at dawn" width="1200" height="600">

    </picture>

    <video controls>

    <source src="portrait.mp4" media="(orientation: portrait)">

    <source src="landscape.mp4">

    </video>

    Listing 2.57: Media queries in HTML attributes The first matching <source> wins. media on video sources was restored in 2023 (Chrome 120, Firefox 120; Safari never dropped it), but unlike <picture>, a video picks its source once and ignores later viewport changes. A stylesheet whose query does not match is still downloaded, at low priority, but does not block rendering.

    2.9.9 HTTP Request Methods

    Two properties from RFC 9110 decide how clients, proxies and caches treat a method. A safe method is read-only, so crawlers may call it freely. An idempotent one has the same effect whether it runs once or ten times, so a client may retry it: PUT /users/7 repeated leaves the same user, POST /orders repeated creates two orders. Method Purpose Safe Idempotent Body GET, HEAD Read; headers only Yes Yes No QUERY Read, query in body Yes Yes Yes POST Create or process No No Yes PUT Replace a resource No Yes Yes PATCH Modify part of it No No Yes DELETE Remove a resource No Yes Rarely OPTIONS Ask what is allowed Yes Yes Rarely CONNECT, TRACE Tunnel; echo test No, Yes No, Yes No Table 2.31: HTTP request methods (IANA registry) QUERY, defined by RFC 10008 in June 2026, is a safe, cacheable request that carries its query in the body, for searches too long for a URL; check your framework, proxy and CDN before relying on it. An HTML <form> sends only GET or POST, while fetch() sends anything except the forbidden CONNECT, TRACE and TRACK. A cross-origin PUT, PATCH or DELETE first triggers an OPTIONS preflight (Section 2.8.1). Tip: Interview question: "Is PATCH idempotent?" Not by definition. A JSON Patch add to /items/- appends to the array again on every retry.

    2.9.10 HTTP Request Headers

    Request headers carry the client's context: what formats it accepts, who the user is, what it has cached. Names are case-insensitive (HTTP/2 requires lowercase). fetch(url, { headers }) can add your own, but the browser controls forbidden headers such as Host, Cookie, Referer, Origin and any Sec- or Proxy- name, so scripts cannot forge them. Header Purpose and example Host, User-Agent Target host (required in HTTP/1.1); browser ID Accept, Accept-Language Preferred formats and languages Accept-Encoding Decodable compression: gzip, deflate, br, zstd Authorization, Cookie Credentials: Bearer <token>; stored cookies Content-Type, Content-Length Format and size of the request body If-None-Match, If-Modified-Since Conditional request; may get 304 Range Part of a resource: bytes=0-1023 Origin, Referer Requesting origin; referring page (Section 2.8) Sec-Fetch-Site, Sec-Fetch-Dest Fetch metadata: cross-site? script or page? Forwarded, X-Forwarded-For Client IP as recorded by proxies Table 2.32: Common HTTP request headers Fetch metadata lets a server reject a suspicious request before doing any work, such as a cross-site POST that none of your pages would send. DNT is deprecated in favor of Sec-GPC (Global Privacy Control), Pragma in favor of Cache-Control, and the standard Forwarded (RFC 7239) replaces X-Forwarded-For. Trust either only when your own proxy sets it.

    2.9.11 HTTP Response Headers

    Response headers describe the body, tell caches how long to keep it and switch on browser security features. You set them in your server or CDN configuration. Header Purpose and example Content-Type, Content-Encoding Body format and compression (br, gzip) Content-Disposition Download: attachment; filename="a.pdf" Cache-Control Caching: max-age=31536000, immutable ETag, Last-Modified, Vary Validators for 304; headers that pick a variant Location, Retry-After Redirect target; wait time for 429/503 Set-Cookie id=9f2c; Secure; HttpOnly; SameSite=Lax Access-Control-Allow-Origin Origins that may read it (Section 2.8.1) Strict-Transport-Security HTTPS only: max-age=63072000 Content-Security-Policy Allowed sources for scripts etc. (Section 2.8.5) X-Content-Type-Options nosniff: never guess the type Link Early hints: </app.css>; rel=preload; as=style Table 2.33: Common HTTP response headers Older references list headers that are now useless or harmful. X-XSS-Protection is deprecated and can itself open XSS holes; use CSP instead. X-Frame-Options still blocks framing, but its ALLOW-FROM value makes browsers ignore the whole header, so use CSP frame-ancestors. Expires is ignored when Cache-Control: max-age is present, and RFC 9111 obsoleted Warning. Finally, strip version numbers from Server and drop X-Powered-By: they only help attackers find vulnerable software.

    2.9.12 HTTP Status Codes

    Every response starts with a three-digit status code whose first digit gives its class: 1xx informational, 2xx success, 3xx redirection, 4xx client error, 5xx server error. A client treats an unknown code like the x00 code of its class, so an unfamiliar 499 is handled as 400. Status codes Meaning in practice 101 Switching Protocols, 103 Early Hints WebSocket upgrade; early preload hints 200 OK, 201 Created, 204 No Content Success; new resource; empty body 206 Partial Content Answer to a Range request 301 Moved Permanently, 308 Permanent Redirect Permanent move; 308 keeps the method 302 Found, 307 Temporary Redirect Temporary move; 307 keeps the method 303 See Other, 304 Not Modified GET after POST; cached copy valid 400 Bad Request, 422 Unprocessable Content Malformed; well-formed but invalid 401 Unauthorized, 403 Forbidden Not logged in; logged in but not allowed 404 Not Found, 410 Gone Missing; permanently removed 405 Method Not Allowed, 415 Unsupported Media Type See Allow; wrong Content-Type 429 Too Many Requests Rate limited; see Retry-After 500 Internal Server Error, 503 Service Unavailable Server bug; overloaded or down 502 Bad Gateway, 504 Gateway Timeout Proxy got a bad or no upstream reply Table 2.34: HTTP status codes you will meet (IANA registry) For 301 and 302, browsers historically turned a POST into a GET on the redirected request, which RFC 9110 still allows; 307 and 308 forbid it. Answering a form POST with 303 (the Post/Redirect/Get pattern) stops a reload from submitting twice. RFC 9110 (2022) renamed 413 "Payload Too Large" and 422 "Unprocessable Entity" to the names above, and reserved 418 "I'm a teapot", an April Fools' joke from RFC 2324. Tip: fetch() rejects only on network failure, never on a 404 or 500. Check response.ok (true for 200 to 299) and throw yourself: if (!response.ok) throw new Error('HTTP ' + response.status);.

    2.10 Accessibility

    Accessibility means that people who are blind, have low vision, are deaf, cannot use a mouse, or have cognitive disabilities can use your page. It is also widely neglected. The WebAIM Million 2026 report, which runs automated tests on the home pages of the top one million websites, found detectable WCAG failures on 95.9% of them, with 56.1 errors per page on average. The most common were low-contrast text (83.9% of pages), images without alternative text (53.1%), inputs without labels (51%), empty links (46.3%), empty buttons (30.6%) and a missing lang attribute (13.5%). Each is a one-line fix in HTML.It is increasingly the law, too. The European Accessibility Act, Directive (EU) 2019/882, has applied since 28 June 2025 to consumer services such as e-commerce, online banking, e-books and passenger transport information across the EU; microenterprises providing services (fewer than 10 staff, at most EUR 2 million turnover) are exempt. In the United States, the ADA Title II rule requires WCAG 2.1 Level AA for state and local government sites and apps, from 26 April 2027 for entities serving 50,000 people or more. Both, like most such rules, point at WCAG.A screen reader never sees your pixels. The browser derives an accessibility tree from the DOM and CSS, in which each node has a role (button, link, heading), a name ("Search"), states (expanded, checked, disabled) and a value. It exposes that tree through the operating system's accessibility API, which the assistive technology queries. Chrome DevTools shows each element's computed role and name in the Accessibility pane of the Elements panel.Figure 2.49: From markup to speech: the accessibility tree and platform APIsThe Web Content Accessibility Guidelines rest on four principles: content must be perceivable, operable, understandable and robust. Under them sit 13 guidelines and 86 testable success criteria at Level A (31), AA (24) or AAA (31). Laws almost always require Level AA, meaning all 55 A and AA criteria. WCAG 2.2 became a W3C Recommendation on 5 October 2023 and the international standard ISO/IEC 40500:2025 in October 2025. It dropped 4.1.1 Parsing, because browsers now repair malformed markup consistently, and added nine criteria: Criterion Level Requirement 2.4.11 Focus Not Obscured (Minimum) AA Sticky bars never fully hide focus 2.4.12 Focus Not Obscured (Enhanced) AAA No part of focus is hidden 2.4.13 Focus Appearance AAA Large, high-contrast focus ring 2.5.7 Dragging Movements AA Single-pointer alternative to dragging 2.5.8 Target Size (Minimum) AA Targets 24 by 24 CSS px, or spaced 3.2.6 Consistent Help A Help in the same place on every page 3.3.7 Redundant Entry A Never ask for the same data twice 3.3.8 Accessible Authentication (Minimum) AA No memory test to log in; allow paste 3.3.9 Accessible Authentication (Enhanced) AAA No image-recognition test either Table 2.35: Success criteria new in WCAG 2.2 WCAG 3 remains a W3C Working Draft (latest March 2026) with a scored model instead of pass/fail levels. No law references it yet, so build to WCAG 2.2 AA.WAI-ARIA 1.2 (a W3C Recommendation since June 2023) adds attributes that change the accessibility tree without changing appearance or behavior. The specification calls it a supplement to native semantics, not a replacement, and the W3C's four rules of ARIA use say the same: use a native element whenever one exists; do not change native semantics (no <h2 role="tab">); make every ARIA control keyboard-operable; and never put aria-hidden="true" or role="presentation" on a focusable element. role="button" on a <div> adds no focus, no Enter or Space handling and no disabled state. ARIA is often misused: in the WebAIM Million, pages with ARIA averaged 59.1 errors against 42 on pages without it.Every control needs an accessible name. The browser tries, in order, aria-labelledby, aria-label, native mechanisms (<label>, alt, <caption>, <legend>), the element's text content, and finally title or placeholder. Prefer visible text, and remember that aria-label on a button or link replaces its text. Attribute Typical use aria-label, aria-labelledby Icon buttons; a dialog named by its heading aria-describedby Hint or error text under a field aria-expanded, aria-controls Disclosure buttons, menus, accordions aria-pressed, aria-current="page" Toggle buttons; the current navigation link aria-invalid, aria-required Form validation (Section 2.5.4) role="status", role="alert", aria-live Announcing changes without moving focus aria-hidden="true" Decorative icons next to text Table 2.36: The ARIA attributes you will use most The listing combines them: a named icon button, a disclosure menu that reports its state, a field with a linked hint and error, and a status message. The script opens the menu and submits the empty field for the screenshot.

    <style>

    body { font: 15px system-ui; margin: 12px; } button { font: inherit; padding: 6px 12px; }

    button:focus, input:focus { outline: 3px solid #1565c0; outline-offset: 2px; }

    [aria-invalid="true"] { border: 2px solid #c62828; } #err { color: #c62828; }

    </style>

    <button aria-label="Search"><svg aria-hidden="true" width="16" height="16">

    <circle cx="7" cy="7" r="5" fill="none" stroke="#000" stroke-width="2"/>

    <path d="M11 11l4 4" stroke="#000" stroke-width="2"/></svg></button>

    <button id="acct" aria-expanded="false" aria-controls="menu">Account</button>

    <ul id="menu" hidden>

    <li><a href="/profile">Profile</a></li> <li><a href="/logout">Sign out</a></li>

    </ul>

    <form id="f" novalidate>

    <label for="email">Email</label>

    <input id="email" type="email" required aria-describedby="hint err">

    <span id="hint">Used only for receipts.</span> <p id="err"></p>

    <button>Subscribe</button> <span role="status" id="status"></span>

    </form>

    <script>

    const acct = document.getElementById('acct'), menu = document.getElementById('menu');

    acct.onclick = () => {

    const open = acct.getAttribute('aria-expanded') !== 'true';

    acct.setAttribute('aria-expanded', open); menu.hidden = !open;

    };

    document.getElementById('f').onsubmit = (e) => {

    e.preventDefault();

    const email = document.getElementById('email'), ok = email.checkValidity();

    email.setAttribute('aria-invalid', !ok);

    document.getElementById('err').textContent = ok ? '' : 'Enter an email address.';

    document.getElementById('status').textContent = ok ? 'Subscribed.' : '1 error in the form.';

    if (!ok) email.focus();

    };

    acct.click(); document.querySelector('#f button').click(); // show the states

    </script>

    Listing 2.58: Accessible names, states and a live region Figure 2.50: Browser output of Listing 2.58A screen reader announces the icon as "Search, button", the next control as "Account, button, expanded", and the field as an invalid, required "Email" edit box followed by its hint and error. The role="status" span is a polite live region, read out after the current announcement. Put live regions in the initial HTML and change only their text: a region inserted together with its message is often missed.Automated checkers catch missing names, contrast failures and invalid ARIA in seconds, but Deque, maker of axe-core, estimates they find about 57% of WCAG issues. Whether alt text is meaningful or a custom widget really works needs a human with a keyboard and a screen reader. Tool Licence, price Best for axe-core (https://github.com/dequelabs/axe-core) MPL-2.0, free Test engine for Playwright, Cypress, Jest Lighthouse (https://github.com/GoogleChrome/lighthouse) Apache-2.0, free Scored audit in DevTools (runs axe-core) Pa11y (https://github.com/pa11y/pa11y) LGPL-3.0, free Command-line scans of many URLs WAVE extension (WebAIM) Free; API paid per credit Error icons overlaid on the page NVDA (https://github.com/nvaccess/nvda) GPL-2.0, free Windows screen reader testing VoiceOver Built into macOS, iOS Safari and iPhone testing JAWS (Freedom Scientific) Commercial Widely used in enterprises Table 2.37: Accessibility testing tools axe-core runs inside the page: it walks the DOM, computes roles and names with its own implementation of the W3C algorithms, and measures contrast against the colors actually painted behind the text. This demo loads it from jsDelivr (in a project, npm install axe-core) and lists the WCAG A and AA violations in a broken snippet.

    <div id="bad" style="font: 15px system-ui">

    <img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='20' cy='20'

    r='18' fill='%232e7d32'/%3E%3C/svg%3E" width="40" height="40">

    <input type="text" placeholder="Name"> <button><svg width="16" height="16"></svg></button>

    <span style="color: #aaa">Low-contrast fine print</span>

    </div>

    <ol id="out" style="font: 14px system-ui"></ol>

    <script src="https://cdn.jsdelivr.net/npm/axe-core@4.13.0/axe.min.js"></script>

    <script>

    axe.run('#bad', { runOnly: ['wcag2a', 'wcag2aa'] }).then(({ violations }) => {

    document.getElementById('out').innerHTML = violations

    .map((v) => `<li><b>${v.id}</b> (${v.impact}): ${v.help}</li>`).join('');

    });

    </script>

    Listing 2.59: Running axe-core against a broken snippet Figure 2.51: Browser output of Listing 2.59Note what axe did not report: the placeholder gives the input an accessible name, so it passes, even though a placeholder that vanishes on typing is a poor label. Only a human catches that, so build a routine:
  • Run axe-core in end-to-end tests (@axe-core/playwright or cypress-axe) so regressions fail the build.

  • Put the mouse away and Tab through every page: focus must be visible, logical, never trapped and never hidden under a sticky header.

  • Listen. In NVDA (free), NVDA+F7 lists links, headings and landmarks; on a Mac, Command-F5 starts VoiceOver and Control+Option+U opens its Rotor with the same lists. Then zoom to 400% and check nothing is cut off.

  • Warning: Overlay widgets that promise one-line compliance cannot repair missing labels or broken widgets in your source, and can conflict with users' own assistive technology. Fix the HTML instead.

    2.11 Metadata, SEO and Structured Data

    Your page has several audiences that never render it the way a person does. Search engine crawlers read the <title>, the description and the canonical URL to decide what to index and how to present it. Social networks and chat apps fetch the page when someone pastes a link and build a preview card from Open Graph tags. Search engines and AI assistants read JSON-LD structured data to learn that a page is a product with a price or an article with an author. All of this lives in markup you write once, mostly inside <head> (Section 2.2.2).This section covers the HTML side. Chapter 10 explains how search engines use these signals for ranking, rich results and AI answers.

    2.11.1 Meta Tags That Matter

    Section 2.2.2 lists the standard <meta> names. For search, four things matter most: the title, the description, the canonical link and the robots rules. The listing is a search-ready <head> for an article page; the figure after it shows where each tag can surface in a search result.

    <head>

    <meta charset="utf-8">

    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Accessible Forms in HTML: Labels, Hints and Errors | Northwind Dev Blog</title>

    <meta name="description"

    content="Label inputs, link hints and announce errors so everyone can complete your forms.">

    <link rel="canonical" href="https://blog.northwind.example/accessible-forms">

    <meta name="robots" content="max-image-preview:large">

    <link rel="icon" href="/favicon.svg" type="image/svg+xml">

    </head>

    Listing 2.60: A search-ready head for an article page Figure 2.52: Where head metadata can surface in a search result
  • <title> heads Google's list of sources for the title link in results, followed by the visible page title, headings, og:title and anchor text pointing at the page. Put the topic first, the site name last, and make every title unique.

  • description feeds the snippet under the title. Google builds snippets mainly from page content and uses the description when it summarizes the page better, so write one or two sentences a person would click on.

  • rel="canonical" names the preferred URL when the same content is reachable at several addresses (tracking parameters, http and https, print versions). Google treats it as a strong signal, not a command; only a redirect is stronger. Always use an absolute URL.

  • robots sets indexing rules: noindex keeps a page out of results, nofollow tells crawlers not to follow its links, and max-image-preview:large allows large image previews. The HTTP header X-Robots-Tag does the same for PDFs and images.

  • Warning: <meta name="keywords"> has no effect on Google Search indexing or ranking, and a noindex page must remain crawlable: if robots.txt blocks the URL, the crawler never sees the tag and the URL can still appear in results.

    2.11.2 Open Graph and Social Cards

    When someone pastes your URL into Facebook, LinkedIn or a chat app, a crawler fetches the page and builds a preview card from its Open Graph tags. Facebook created the Open Graph protocol, and because it builds on RDFa, its tags use property rather than name. Four properties are required: og:title, og:type, og:image and og:url. Add og:description, og:site_name and the image's og:image:width, og:image:height and og:image:alt, so the crawler can lay out the card without downloading the image first.X (formerly Twitter) reads its own twitter: tags but falls back to og:title, og:description and og:image, so one extra tag is usually enough: twitter:card with summary (small square thumbnail) or summary_large_image (full-width image). The demo contains a typical tag set, and its script plays the crawler: it reads the tags back and draws the card a chat app would show.

    <head>

    <meta property="og:type" content="article">

    <meta property="og:title" content="Accessible Forms in HTML: Labels, Hints and Errors">

    <meta property="og:description" content="Label inputs, link hints and announce errors.">

    <meta property="og:url" content="https://blog.northwind.example/accessible-forms">

    <meta property="og:image" content="https://blog.northwind.example/og/accessible-forms.png">

    <meta property="og:image:width" content="1200">

    <meta property="og:image:height" content="630">

    <meta property="og:image:alt" content="The words Accessible Forms on a blue gradient">

    <meta property="og:site_name" content="Northwind Dev Blog">

    <meta name="twitter:card" content="summary_large_image">

    <style>

    body { font: 14px system-ui; margin: 12px; }

    .card { width: 400px; border: 1px solid #cfd8dc; border-radius: 12px; overflow: hidden; }

    .img { aspect-ratio: 1200 / 630; display: grid; place-items: center; color: #fff;

    font: bold 30px system-ui; background: linear-gradient(135deg, #1565c0, #6a1b9a); }

    .meta { padding: 8px 12px; } .meta small { color: #607d8b; } .meta p { margin: 2px 0; }

    </style>

    </head>

    <div class="card"><div class="img">Accessible Forms</div>

    <div class="meta"><small id="site"></small><p><b id="title"></b></p><p id="desc"></p></div>

    </div>

    <script>

    const og = (p) => document.querySelector(`meta[property="og:${p}"]`).content;

    // The image area is a CSS stand-in: blog.northwind.example does not exist.

    document.getElementById('site').textContent = `${og('site_name')} · ${new URL(og('url')).host}`;

    document.getElementById('title').textContent = og('title');

    document.getElementById('desc').textContent = og('description');

    </script>

    Listing 2.61: Open Graph tags and the card built from them Figure 2.53: Browser output of Listing 2.61Practical rules for the image:
  • Use an absolute https URL. Facebook recommends at least 1200 by 630 pixels (a 1.91:1 ratio) and rejects files over 8 MB; X accepts JPG, PNG, WebP and GIF under 5 MB. Keep text large and away from the edges.

  • Generate one image per article rather than reusing the logo. In Next.js, an opengraph-image.tsx file returns an ImageResponse (from next/og) built from JSX; underneath, Vercel's Satori (https://github.com/vercel/satori) library (MPL-2.0) converts HTML and CSS to SVG.

  • Serve the tags in the initial HTML instead of injecting them with client-side JavaScript. Platforms cache what they scraped, so after a change, re-scrape with the Facebook Sharing Debugger (developers.facebook.com/tools/debug) or the LinkedIn Post Inspector (linkedin.com/post-inspector).

  • 2.11.3 Structured Data: JSON-LD and schema.org

    Structured data states facts about a page in a machine-readable vocabulary, usually schema.org (https://schema.org/), whose founding companies are Google, Microsoft, Yahoo and Yandex; it reached version 30.0 in March 2026. You can embed it as Microdata or RDFa attributes on visible elements, but Google recommends JSON-LD: a <script type="application/ld+json"> block that the browser ignores and that stays separate from your markup, so template changes rarely break it.

    <script type="application/ld+json">

    {

    "@context": "https://schema.org",

    "@type": "BlogPosting",

    "headline": "Accessible Forms in HTML: Labels, Hints and Errors",

    "image": ["https://blog.northwind.example/og/accessible-forms.png"],

    "datePublished": "2026-09-01T08:00:00+02:00",

    "dateModified": "2026-09-10T14:30:00+02:00",

    "author": { "@type": "Person", "name": "Ana Trujillo", "url": "https://northwind.example/ana" },

    "publisher": { "@type": "Organization", "name": "Northwind Dev Blog" }

    }

    </script>

    Listing 2.62: JSON-LD for a blog post @context selects the vocabulary, @type names the schema.org class, and nested objects describe related entities. Google's Article documentation has no required properties for Article, NewsArticle or BlogPosting; add every property that applies, with ISO 8601 dates that include a time zone. Every value must match content visible on the page. Valid markup makes a page eligible for rich results, but Google does not guarantee that they appear. Check syntax against the vocabulary with the Schema Markup Validator (validator.schema.org) and Google eligibility with the Rich Results Test (search.google.com/test/rich-results); Section 10.9.3 covers both, and Section 10.8 compares JSON-LD with microformats.

    2.11.4 Sitemaps and robots.txt

    Two plain files at the site root complete the picture. /sitemap.xml lists the canonical URLs you want crawled, each with an optional <lastmod> date; one file may hold at most 50,000 URLs or 50 MB uncompressed, and Google ignores <priority> and <changefreq>. /robots.txt, standardized as RFC 9309 in September 2022, tells crawlers which paths not to fetch (User-agent: * then Disallow: /cart/) and where the sitemap lives (Sitemap: https://example.com/sitemap.xml). It controls crawling, not indexing: a disallowed URL can still appear in results, so use noindex (Section 2.11.1) to hide a page. Sections 10.3.13 and 10.7.9 build and test both files, and Section 10.10.2 covers rules for AI crawlers.

    2.12 Miscellaneous Techniques

    A blank square in the browser tab, a map pasted as a screenshot, comments shipped to every visitor, an unclosed tag that breaks in one browser: these finishing jobs separate a working page from a production page. This section covers icons, embedded maps, minification, validation, and the Emmet shorthand that speeds up typing markup.

    2.12.1 Favicons and App Icons

    A favicon is the small image beside the page title in a tab or bookmark. Browsers still request /favicon.ico when a page declares no icon, hence the old advice to drop a 16x16 ICO in the site root. Today icons also appear on home screens, in app launchers and in search results, yet four <link> lines and a short manifest cover every current browser.

    <link rel="icon" href="/favicon.ico" sizes="32x32">

    <link rel="icon" href="/icon.svg" type="image/svg+xml">

    <link rel="apple-touch-icon" href="/apple-touch-icon.png"><!-- 180x180 PNG -->

    <link rel="manifest" href="/manifest.webmanifest">

    Listing 2.63: A modern favicon set in the document head The 32x32 ICO serves older browsers and tools such as RSS readers that only request /favicon.ico. The SVG, sharp at any size, is used by Chrome and Edge 80+, Firefox 41+ and Safari 26+. iPhones use the 180x180 apple-touch-icon; Android and installed web apps read 192 and 512 px PNGs from the manifest.Without sizes="32x32" Chrome may pick the ICO over the SVG, which must be served as image/svg+xml. An SVG favicon can carry its own CSS, including a dark-mode media query:

    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">

    <style>

    rect { fill: #1565c0; } path { stroke: #fff; }

    @media (prefers-color-scheme: dark) { rect { fill: #90caf9; } path { stroke: #0d2a4d; } }

    </style>

    <rect width="32" height="32" rx="7"/>

    <path d="M12 7v18h10" fill="none" stroke-width="4"/>

    </svg>

    Listing 2.64: icon.svg: one vector favicon that adapts to dark mode Preview an icon at the sizes it really appears. This page draws the same shapes at 16, 32 and 48 px on light and dark strips:

    <svg width="0" height="0" style="position:absolute"><symbol id="i" viewBox="0 0 32 32">

    <rect width="32" height="32" rx="7" fill="var(--bg)"/>

    <path d="M12 7v18h10" fill="none" stroke="var(--fg)" stroke-width="4"/></symbol></svg>

    <style>

    body { margin: 0; }

    p { display: flex; align-items: end; gap: 20px; margin: 0; padding: 10px; font: 14px sans-serif; }

    .light { --bg: #1565c0; --fg: #fff; background: #f1f3f4; }

    .dark { --bg: #90caf9; --fg: #0d2a4d; background: #202124; color: #e8eaed; }

    </style>

    <p class="light"></p><p class="dark"></p>

    <script>

    for (const p of document.querySelectorAll("p")) p.innerHTML = [16, 32, 48]

    .map((s) => `<svg width="${s}" height="${s}"><use href="#i"/></svg>`).join("") + "Lamp Shop";

    </script>

    Listing 2.65: Previewing a favicon at real sizes in both color schemes Figure 2.54: Browser output of Listing 2.65The manifest (Section 9.4.13) lists the PNGs in its "icons" array; mark a 512 px icon whose artwork keeps a safe zone with "purpose": "maskable" so Android may crop it to any shape.Browsers cache favicons aggressively, so rename a replaced icon (icon-v2.svg). Generate the ICO and PNGs with RealFaviconGenerator (https://realfavicongenerator.net/) or the open-source sharp (https://github.com/lovell/sharp) library in Node.

    2.12.2 Embedding Google Maps

    The quickest route needs no account: open the place on Google Maps, choose Share, switch to the Embed a map tab, pick a size and click Copy HTML. You get an <iframe> whose src carries an encoded pb= parameter. For maps you build in code, the Maps Embed API takes a readable query string instead. It is free with unlimited requests, but every request needs an API key:

    <iframe width="600" height="340" style="border:0" loading="lazy" allowfullscreen

    title="Map of the Eiffel Tower"

    referrerpolicy="strict-origin-when-cross-origin"

    src="https://www.google.com/maps/embed/v1/place?key=YOUR_API_KEY&q=Eiffel+Tower,Paris">

    </iframe>

    Listing 2.66: A lazy-loaded map from the Maps Embed API Other modes are view, directions, streetview and search. The key is visible in your HTML, so in the Google Cloud console restrict it to your site (https://*.example.com) and to the Maps Embed API alone. loading="lazy" (supported in all major browsers) defers the heavy frame until the reader scrolls near it. Custom markers and drawing need the Maps JavaScript API, which requires billing on the project; the keyless open-source alternative is Leaflet (https://github.com/Leaflet/Leaflet) with OpenStreetMap tiles.

    2.12.3 Code Compression

    Minification strips what the browser does not need: comments, whitespace, optional tags, long local names. Online minifier pages suit a one-off file; production sites minify in the build, on every deploy. Vite (Section 1.8) minifies production builds by default, using Oxc for JavaScript and Lightning CSS for CSS. For a plain static site, three open-source command-line tools do the job:

    npm install --save-dev terser lightningcss-cli html-minifier-terser

    npx terser src/app.js --compress --mangle --toplevel -o dist/app.js

    npx lightningcss --minify src/style.css -o dist/style.css

    npx html-minifier-terser --collapse-whitespace --remove-comments --remove-optional-tags \

    src/index.html -o dist/index.html

    Listing 2.67: Minifying JavaScript, CSS and HTML from the command line Given a 159-byte app.js that defines addTax(price, rate = 0.08) and logs addTax(19.99), Terser inlines the single-use function, renames its parameters and drops the comment, leaving 66 bytes that still print 21.59: console.log(function(o,n=.08){return(o*(1+n)).toFixed(2)}(19.99)); Output of Listing 2.67 Tool Minifies Licence Terser (https://github.com/terser/terser) JavaScript BSD-2-Clause esbuild (https://github.com/evanw/esbuild) (Go) JavaScript, CSS MIT Lightning CSS (https://github.com/parcel-bundler/lightningcss) (Rust) CSS MPL-2.0 html-minifier-terser (https://github.com/terser/html-minifier-terser) HTML MIT Table 2.38: Open-source minifiers Under the hood: Terser parses code into an abstract syntax tree, applies safe rewrites (constant folding, dead-code removal, inlining) and prints the smallest equivalent text. The server then gzips or Brotli-compresses the minified file for transfer; the two savings stack. Minified code is not protection: the DevTools { } pretty-print button reformats it instantly. Generate source maps (--source-map) so your own stack traces stay readable.

    2.12.4 Validation

    Browsers never reject bad HTML; the parser silently repairs it, not always the way you meant. A validator reports those mistakes first. The W3C's Nu Html Checker runs at validator.w3.org/nu (https://validator.w3.org/nu/), where you paste markup, upload a file or enter a URL. It also works as a web service you can call from a script or CI job. Every line of this page renders without complaint, yet it holds six errors (the optional <head> and <body> tags are legally omitted):

    <!doctype html>

    <html lang="en">

    <meta charset="utf-8"><title>Order form</title>

    <img src="logo.png">

    <p>Ship to: <b>Home</p></b>

    <input type="number" min="1" max="abc">

    <center>Thanks!</center>

    Listing 2.68: bad.html: a page that renders but is invalid

    curl -H "Content-Type: text/html; charset=utf-8" --data-binary @bad.html \

    "https://validator.w3.org/nu/?out=gnu"

    Listing 2.69: Posting a file to the Nu checker and printing one error per line

    :4.1-4.20: error: An "img" element must have an "alt" attribute, except under certain

    conditions.

    For details, consult guidance on providing text alternatives for images.

    :5.20-5.23: error: End tag "p" seen, but there were open elements.

    :5.13-5.15: error: Unclosed element "b".

    :5.24-5.27: error: No "b" element in scope but a "b" end tag seen.

    :6.1-6.39: error: Bad value "abc" for attribute "max" on element "input": Expected a minus

    sign or a

    digit but saw "a" instead.

    :7.1-7.8: error: The "center" element is obsolete. Use CSS instead.

    Output of Listing 2.69 Positions are line.column ranges; out=json returns machine-readable results. To keep pages off a public server, run the same open-source checker (validator/validator (https://github.com/validator/validator)) locally: npm install vnu-jar and java -jar node_modules/vnu-jar/build/dist/vnu.jar bad.html (Java 17 or later), or the ghcr.io/validator/validator Docker image. Validation checks syntax, not accessibility, so pair it with Lighthouse (Section 1.3.3).

    2.12.5 Emmet Abbreviations

    Emmet turns a CSS-selector-like abbreviation into markup. It is built into VS Code with no extension (and exists as a plugin for most other editors): type an abbreviation in an HTML, JSX or CSS file and press Tab. ! alone expands to a full HTML5 boilerplate with charset, viewport and <title>. Abbreviation Expands to nav>ul>li*3>a[href=#]{Item $} A nav list of three links: Item 1, Item 2, Item 3 div.card>h2.title+p <div class="card"> holding <h2 class="title"> then <p> ul>li.item$$*2 Two <li> elements with classes item01, item02 (header>nav)+main+footer Grouping: header (with nav), main and footer as siblings p>lorem8 A paragraph of 8 placeholder words m10-20 / df (CSS) margin: 10px 20px; / display: flex; Table 2.39: Emmet operators: > child, + sibling, * repeat, $ counter, . class, # id, [] attribute, {} text Emmet: Wrap with Abbreviation in the Command Palette wraps a selection: select three lines, enter ul>li*, and each line becomes its own list item. The engine is the open-source emmetio/emmet (https://github.com/emmetio/emmet) package, which you can also call from Node as expand("ul>li*3").

    2.13 Performance Hints in Markup

    The browser's preload scanner reads ahead in your HTML and starts downloads early, but it cannot know that the hero image is named in a CSS file, that a font lives on another origin, or that most visitors click "Pricing" next. A few attributes tell it. Used sparingly, they speed up Largest Contentful Paint and can make the next navigation instant; overused, they steal bandwidth from what the page needs (Section 9.10 measures the results).A plain <script src> stops the HTML parser while the file downloads and runs. defer downloads in parallel and runs the scripts in document order after parsing finishes, just before DOMContentLoaded. async also downloads in parallel but runs each script the moment it arrives, in no guaranteed order. Module scripts (type="module") are deferred by default. Neither attribute affects inline classic scripts.Figure 2.55: How a classic, deferred and async script share time with the HTML parserUse defer (or a module) for application code that touches the DOM, and async for independent scripts such as analytics that must not wait for, or be waited on by, anything else. Hint What the browser does Baseline status dns-prefetch Resolves a host name early Newly available (2025) preconnect Opens DNS + TCP + TLS to an origin Widely available preload Fetches a file this page needs, at high priority Widely available modulepreload Fetches and compiles a module and its imports Widely available prefetch Fetches a likely next-page file at low priority Limited: not in Safari fetchpriority Raises or lowers a single request's priority Newly available (2024) Table 2.40: Resource hints and priority attributes

    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

    <link rel="dns-prefetch" href="https://analytics.example.com">

    <link rel="preload" href="/img/hero.avif" as="image" type="image/avif" fetchpriority="high">

    <link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>

    <link rel="modulepreload" href="/js/chart.js"><!-- imported by app.js -->

    <link rel="prefetch" href="/js/checkout.js">

    <script type="module" src="/js/app.js"></script>

    <script async src="https://analytics.example.com/tag.js"></script>

    Listing 2.70: Head hints for a page with a Google Font, a CSS background hero and a module app preload needs as so the browser applies the right priority, headers and cache key, and fonts need crossorigin even on your own origin because fonts are always fetched in CORS mode; a mismatch downloads the file twice. Chrome warns in the console when a preloaded file is still unused about 3 seconds after load. An <img> already in the HTML needs no preload, just fetchpriority="high". Limit preconnect to the two or three origins you certainly use.The Speculation Rules API goes further than prefetch: a JSON block tells the browser which pages to fetch, or fully prerender in a hidden tab, before the user clicks. When the click comes, the prerendered page is swapped in and appears instantly.

    <script type="speculationrules">

    {

    "prerender": [{

    "where": { "and": [

    { "href_matches": "/*" },

    { "not": { "href_matches": "/logout" } },

    { "not": { "selector_matches": "[rel~=nofollow]" } }

    ] },

    "eagerness": "moderate"

    }],

    "prefetch": [{ "urls": ["/pricing"] }]

    }

    </script>

    Listing 2.71: Prerendering same-site links on hover, but never the logout link where rules match links in the document; urls rules list addresses and start immediately by default. The eagerness levels in Chrome on desktop are conservative (on pointer down), moderate (after a 200 ms hover), eager (10 ms hover) and immediate; on mobile, Chrome uses viewport heuristics instead of hover. Chrome keeps at most two hover-triggered speculations, discarding the oldest. Warning: Speculation Rules are Chromium-only (Chrome and Edge 109+); Firefox ignores them and Safari 26.2 has prefetch only, behind a flag. That makes them a safe progressive enhancement, but a prerendered page really runs: exclude URLs with side effects (logout, add-to-cart via GET), and delay analytics until document.prerendering is false, listening for the prerenderingchange event. A Content Security Policy must allow the block with 'inline-speculation-rules' (or a hash or nonce) in script-src. Check support with HTMLScriptElement.supports("speculationrules"), and watch speculations succeed or fail in DevTools under Application > Background services > Speculative loads.

    2.14 HTML Tooling and Libraries

    Not every interactive page needs a framework. htmx and Alpine.js extend HTML with attributes (Section 8.10 compares them with other lightweight DOM libraries); Cheerio and jsdom handle HTML without a browser; template engines generate it.htmx (https://github.com/bigskysoftware/htmx) (0BSD licence) lets any element issue a request and swap the HTML response into the page. hx-get/hx-post choose the request, hx-trigger the event (a click for buttons by default), hx-target where the response goes and hx-swap how (innerHTML by default). The server returns HTML fragments, so there is no client-side rendering code. This complete app needs only Node:

    import { createServer } from "node:http";

    let likes = 0;

    const page = () => `<!doctype html>

    <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js"></script>

    <button hx-post="/like" hx-target="#count">Like</button>

    <span id="count">${likes} likes</span>`;

    createServer((req, res) => {

    res.setHeader("Content-Type", "text/html; charset=utf-8");

    if (req.method === "POST" && req.url === "/like") {

    likes++;

    return res.end(`<strong>${likes}</strong> likes`); // an HTML fragment, not JSON

    }

    res.end(page());

    }).listen(3000);

    Listing 2.72: server.mjs: a like button with htmx 2 and Node's built-in http module Run node server.mjs and open http://localhost:3000: each click sends POST /like with an HX-Request: true header and the span becomes 1 likes, 2 likes and so on. htmx 2.0.10 is npm's latest; htmx 4.0 is released under the next tag so 2.x sites are not upgraded by accident.Alpine.js (https://github.com/alpinejs/alpine) (MIT) keeps state in the markup: x-data declares a reactive object, @click (short for x-on:click) handles events, x-text binds text and x-show toggles visibility. The script needs defer. The demo clicks both buttons once before the capture:

    <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.17.3/dist/cdn.min.js"></script>

    <div x-data="{ qty: 1, open: false }" style="font: 16px sans-serif">

    <button @click="qty++">Add one</button>

    <button @click="open = !open" x-text="open ? 'Hide details' : 'Show details'"></button>

    <p>Quantity: <strong x-text="qty"></strong>, total <strong x-text="'$' + qty * 39"></strong></p>

    <p x-show="open">Desk lamp, $39 each, ships in 2 days.</p>

    </div>

    <script>

    addEventListener("load", () => document.querySelectorAll("button").forEach((b) => b.click()));

    </script>

    Listing 2.73: An Alpine.js quantity picker with a details toggle Figure 2.56: Browser output of Listing 2.73Cheerio (https://github.com/cheeriojs/cheerio) (MIT) parses HTML into a tree with a jQuery-style API. It runs no scripts, which makes it fast for scraping, rewriting static builds or checking generated markup:

    import * as cheerio from "cheerio";

    const $ = cheerio.load(`<ul>

    <li class="item" data-price="39">Desk lamp</li>

    <li class="item sale" data-price="25">Clip light</li>

    </ul>`);

    const items = $(".item").map((i, el) => `${$(el).text()}: $${$(el).data("price")}`).get();

    console.log(items);

    $(".sale").append(" (sale)").attr("data-price", "20");

    console.log($.html(".sale"));

    Listing 2.74: Extracting and editing HTML with Cheerio 1.2 (npm install cheerio)

    [ 'Desk lamp: $39', 'Clip light: $25' ]

    <li class="item sale" data-price="20">Clip light (sale)</li>

    Output of Listing 2.74 jsdom (https://github.com/jsdom/jsdom) (MIT, version 30) instead implements the DOM and many Web APIs in JavaScript, with events and optional script execution (new JSDOM(html, { runScripts: "dangerously" })); Jest and Vitest use it to test DOM code. It has no layout engine, so use Playwright when you need real rendering.A template engine merges data into HTML on the server or at build time. All four below escape <, > and & by default, blocking cross-site scripting; use the raw form only for trusted HTML. Engine Loop over items Raw output Licence Handlebars (https://github.com/handlebars-lang/handlebars.js) {{#each items}}{{this}}{{/each}} {{{ html }}} MIT Nunjucks (https://github.com/mozilla/nunjucks) {% for i in items %}{{ i }}{% endfor %} {{ html | safe }} BSD-2-Clause EJS (https://github.com/mde/ejs) <% items.forEach(i => { %><%= i %><% }) %> <%- html %> Apache-2.0 Pug (https://github.com/pugjs/pug) each i in items then indented li= i li!= html MIT Table 2.41: Template engines for generating HTML in Node Handlebars keeps logic out of templates, Nunjucks (Jinja2-style) adds inheritance and macros, EJS is plain JavaScript inside tags, and Pug replaces tags with indentation.