MENU
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>
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.
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));
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.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 overlap2.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 trees2.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.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>
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.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>
<!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>
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.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>© 2026 Trail Notes</small></footer>
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.
<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>
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.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).<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>
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>
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>
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.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>
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: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.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.<!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&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>
Figure 2.13: Browser output of Listing 2.12mailto: accepts subject, cc, bcc and body parameters, percent-encoded
and joined with & 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.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.<!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>
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 <, and add overflow-x: auto for long lines. For code, nest <code> inside <pre>; highlighters look for pre > code.
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.<!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 <span> (broken)</h2>
<ul><li><span>שר×</span> - 2nd place</li><li><span>Ø£ØÙ د</span>: 3 posts</li></ul>
<h2>With <bdi> (correct)</h2>
<ul><li><bdi>שר×</bdi> - 2nd place</li><li><bdi>Ø£ØÙ د</bdi>: 3 posts</li></ul>
<h2>With <bdo dir="rtl"></h2>
<ul><li><bdo dir="rtl">Hello, world!</bdo></li></ul>
</body>
</html>
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.<!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>
Figure 2.16: Browser output of Listing 2.152.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.<!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> > <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>
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.
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>
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.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.
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.
<!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>
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.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>
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">.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>
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.
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>
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"'.# 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
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.<!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>
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".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.<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>
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);
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
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>
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.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).<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>
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
week: true | datetime: text
2026-09-15T00:00:00.000Z
"" NaN
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>
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.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.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());
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
<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>
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.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>
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.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).
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>
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>
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.<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>
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>
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.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>
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.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();
-- 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
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>
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.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>
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).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>
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>
));
}
}
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>
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' }).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>
<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>
Figure 2.41: Browser output of Listing 2.452.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:<!-- 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>
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>
Figure 2.42: Browser output of Listing 2.472.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.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
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>
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:<!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('&', '&').replaceAll('<', '<'),
});
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>
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.<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>
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.<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>
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 (©), decimal (©) or hexadecimal (©, matching code point U+00A9); numeric forms work for any character.<p>Text: ?sort=asc©=2 and I'm ¬it;</p>
<p>Escaped: ?sort=asc&copy=2 and I'm &notit;</p>
<p>Same character three ways: © © ©</p>
Figure 2.46: Browser output of Listing 2.53Inside an attribute such as href="?a=1©=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.<p style="font: 22px Georgia, serif">
Việt Nam · s̶t̶r̶i̶k̶e̶ ·
Z͓͑͒à́̂̃l̈̊ģo̶</p>
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.<div style="font: 32px sans-serif">
<div lang="ja">骨 次 å å ç (ja)</div>
<div lang="zh-Hans">骨 次 å å ç (zh-Hans)</div>
<div lang="ko">骨 次 å å ç (ko)</div>
</div>
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.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);
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
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.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.<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>
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.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.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.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.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:<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>
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.<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>
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.
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>
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.
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>
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>
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">
<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>
<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>
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>
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
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>
curl -H "Content-Type: text/html; charset=utf-8" --data-binary @bad.html \
"https://validator.w3.org/nu/?out=gnu"
: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.
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>.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.<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>
<script type="speculationrules">
{
"prerender": [{
"where": { "and": [
{ "href_matches": "/*" },
{ "not": { "href_matches": "/logout" } },
{ "not": { "selector_matches": "[rel~=nofollow]" } }
] },
"eagerness": "moderate"
}],
"prefetch": [{ "urls": ["/pricing"] }]
}
</script>
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);
<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>
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"));
[ 'Desk lamp: $39', 'Clip light: $25' ]
<li class="item sale" data-price="20">Clip light (sale)</li>