6. Browser APIs: DOM, Style and Events
Chapters 3 to 5 treated JavaScript as a language. This chapter is about what the browser gives that language to work with: the DOM, a live tree of objects that represents the page, the APIs that read and change its styles and geometry, and the event system that turns user actions into function calls. It ends with the browser features that every non-trivial front end relies on, including storage, routing, background threads and observers. Frameworks such as React (Chapter 14) are built on these APIs, so understanding them explains both how frameworks work and how to debug them when they do not.
<a id="61-the-dom-model-and-document-manipulation"></a>
6.1 The DOM Model and Document Manipulation
This subchapter explains how the browser represents an HTML document as a tree of objects, and how to find, read, create, change and observe the nodes of that tree efficiently and safely.
DOM Tree, Nodes and Node Types
An HTML file is just text. JavaScript cannot do anything useful with a long string of angle brackets: finding "the third paragraph" or "every button inside the form" would mean writing a parser. So the browser parses the text once and builds an in-memory object model of the document. Every tag, every piece of text and every comment becomes an object with properties and methods, and these objects are linked to each other in a tree. This is the Document Object Model (DOM): a standard, language-neutral API (maintained by WHATWG as the DOM Living Standard) that represents a document as a tree of nodes.
An analogy: the HTML file is the architect's blueprint, and the DOM is the building constructed from it. Once the building exists, you renovate the building, not the blueprint. Changing the DOM changes what the user sees. The original HTML source is not modified, which is why "View Source" and the Elements panel of DevTools can show different things.
From markup to tree. Consider this document:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Shop</title>
</head>
<body>
<h1 class="title">Cart</h1>
<!-- items go here -->
<p>Total: <strong>42 EUR</strong></p>
</body>
</html>
The parser produces this tree (#text nodes that contain only whitespace are shown as "\n "):
Document
├── DocumentType (html)
└── Element <html lang="en">
├── Element <head>
│ ├── Text "\n "
│ ├── Element <title>
│ │ └── Text "Shop"
│ └── Text "\n "
├── Text "\n "
└── Element <body>
├── Text "\n "
├── Element <h1 class="title">
│ └── Text "Cart"
├── Text "\n "
├── Comment " items go here "
├── Text "\n "
├── Element <p>
│ ├── Text "Total: "
│ └── Element <strong>
│ └── Text "42 EUR"
└── Text "\n "
Three things in this tree surprise most beginners:
- Text is a separate node. The
<h1>element does not contain the string "Cart" directly. It has a childTextnode whose value is "Cart". - Whitespace between tags becomes text nodes too. The line breaks and indentation in the source are preserved as
Textnodes. This matters when you navigate withfirstChildorchildNodes(see Traversing the DOM Tree). - The parser repairs and completes markup. Missing
<html>,<head>or<body>tags are created automatically, a<tbody>is inserted into tables that lack one, and unclosed tags are closed. The DOM reflects the repaired document, not your source.
Node types. Every node has a nodeType number and a nodeName string:
| Node type | nodeType | Constant | nodeName | Example |
|---|---|---|---|---|
| Element | 1 | Node.ELEMENT_NODE | Upper-case tag name, such as "P" | <p>, <div> |
| Text | 3 | Node.TEXT_NODE | "#text" | The characters inside an element |
| Comment | 8 | Node.COMMENT_NODE | "#comment" | <!-- ... --> |
| Document | 9 | Node.DOCUMENT_NODE | "#document" | document |
| DocumentType | 10 | Node.DOCUMENT_TYPE_NODE | "html" | <!DOCTYPE html> |
| DocumentFragment | 11 | Node.DOCUMENT_FRAGMENT_NODE | "#document-fragment" | template.content, shadow roots |
Attributes (Attr, type 2) are also technically nodes, but they are not children of the element and are not part of the tree. Types 4, 5, 6, 7 and 12 exist for XML and are rarely seen in HTML pages.
The interface hierarchy. DOM objects are instances of a hierarchy of interfaces (classes, in JavaScript terms). Methods defined higher up are available to everything below:
EventTarget addEventListener, dispatchEvent
└── Node parentNode, childNodes, appendChild, textContent
├── Document getElementById, createElement
├── DocumentFragment (lightweight container, see later topic)
├── CharacterData data, length
│ ├── Text
│ └── Comment
└── Element id, classList, querySelector, getAttribute
├── HTMLElement style, dataset, hidden, click()
│ ├── HTMLInputElement value, checked, validity
│ ├── HTMLAnchorElement href, download
│ └── ... (one per tag)
└── SVGElement (Chapter 7)
You can verify the chain in the console with the prototype chain (4.3):
const input = document.createElement("input");
input instanceof HTMLInputElement; // true
input instanceof HTMLElement; // true
input instanceof Element; // true
input instanceof Node; // true
input instanceof EventTarget; // true: every node can receive events
// Walk the prototype chain explicitly
let proto = Object.getPrototypeOf(input);
while (proto) {
console.log(proto.constructor.name); // HTMLInputElement, HTMLElement, Element, Node, EventTarget, Object
proto = Object.getPrototypeOf(proto);
}
Node versus Element. Many APIs exist in two flavours: one that works with all nodes (including text and comments) and one that works with elements only. childNodes returns every child node, while children returns only element children. Most of the time you want the element version.
const p = document.querySelector("p");
p.childNodes.length; // 2: Text "Total: " and <strong>
p.children.length; // 1: only <strong>
for (const node of p.childNodes) {
console.log(node.nodeType, node.nodeName, JSON.stringify(node.textContent));
}
// 3 "#text" "Total: "
// 1 "STRONG" "42 EUR"
The DOM is live. The objects you hold are the real page. Changing a property updates the screen on the next rendering opportunity (5.1), and changes made by other code (another script, a browser extension, the user editing a contenteditable region) are visible through the same objects.
Key Takeaways
- The DOM is a tree of objects built by the browser from the HTML source. Scripts read and change the DOM, never the source file.
- Elements, text, comments and the document itself are all nodes, distinguished by
nodeTypeandnodeName. - Text is stored in separate
Textchild nodes, and whitespace between tags also producesTextnodes. - The parser repairs markup (implicit
<head>,<body>,<tbody>), so the tree may differ from the source. - DOM interfaces form an inheritance chain:
EventTarget,Node,Element,HTMLElement, then one interface per tag. - Prefer element-only APIs (
children,firstElementChild) unless you specifically need text or comment nodes.
Practice
- (Beginner) Write a table in HTML without a
<tbody>and inspect it in the Elements panel. Then logdocument.querySelector("table").children[0].tagNameand explain the result.- (Intermediate) Write a function
describe(node, depth = 0)that prints the tree under a node with indentation, showingnodeNamefor elements and the trimmed text for non-empty text nodes, and skipping whitespace-only text nodes. Run it ondocument.body.- (Interview) "What is the difference between the HTML source of a page and its DOM?" Hint: consider parser error correction, scripts that modify the page after loading, and what "View Source" shows compared with DevTools.
The window and document Objects
Every script running in a page needs a starting point: something that represents "this browser tab" and something that represents "this page". The browser provides two root objects for that. window represents the browsing context (the tab or frame, its viewport, its history, its timers), and document represents the loaded page (the root of the DOM tree). The APIs hanging off window that are not about the document (location, history, screen, navigator) are traditionally called the BOM, the Browser Object Model.
window (global object, one per tab or iframe)
├── document the DOM tree of the current page
├── location current URL; assigning it navigates
├── history session history (6.4)
├── navigator browser and device info, clipboard, geolocation (6.4)
├── screen physical screen size and colour depth
├── localStorage per-origin storage (6.4)
├── innerWidth / innerHeight, scrollX / scrollY, devicePixelRatio
└── setTimeout, fetch, alert, requestAnimationFrame, ...
window is the global object. In a browser, window is the object that holds global variables and functions. This is why you can write setTimeout(...) instead of window.setTimeout(...), and document instead of window.document. The standard name that works in every environment (browser, worker, Node.js) is globalThis.
// In a classic (non-module) script:
var appName = "Shop"; // var at top level creates a property on window
let version = 2; // let/const live in the global scope but NOT on window
function init() {} // function declarations also become window properties
console.log(window.appName); // "Shop"
console.log(window.version); // undefined
console.log(window.init === init); // true
console.log(globalThis === window); // true in the main thread
In an ES module (3.4), top-level var and function declarations are scoped to the module and do not create window properties. This is one of the reasons modules avoid accidental global pollution.
Named access on window. For historical reasons, every element with an id is also exposed as a global variable of the same name:
<button id="save">Save</button>
<script>
console.log(save); // <button id="save">: works, but do not rely on it
console.log(window.save); // same element
</script>
This is fragile: a global variable with the same name shadows it, and ids such as name or location collide with real window properties. Always select elements explicitly (next topic).
Useful window members.
| Member | Purpose |
|---|---|
innerWidth, innerHeight | Viewport size in CSS pixels, including the scrollbar |
scrollX, scrollY, scrollTo() | Page scroll position and programmatic scrolling (6.2) |
devicePixelRatio | Device pixels per CSS pixel (2 or 3 on high-density screens) |
location.href, location.assign(), location.reload() | Read or change the current URL |
matchMedia(query) | Evaluate a CSS media query from JavaScript |
getComputedStyle(el) | Final style values of an element (6.2) |
open(), close(), opener | Open and control other windows (subject to popup blocking) |
alert(), confirm(), prompt() | Blocking modal dialogs (avoid in production; use <dialog>) |
// Respond to a media query from JavaScript
const dark = window.matchMedia("(prefers-color-scheme: dark)");
console.log(dark.matches); // true or false right now
dark.addEventListener("change", (e) => {
console.log("dark mode is now", e.matches); // fires when the OS setting changes
});
The document object. document is the root Document node of the tree and the factory for new nodes. Its most used members:
| Member | Returns |
|---|---|
document.documentElement | The <html> element |
document.head, document.body | The <head> and <body> elements |
document.title | The page title (read and write) |
document.URL, document.referrer | Current URL and the page that linked here |
document.readyState | "loading", "interactive" (DOM parsed) or "complete" (all resources loaded) |
document.activeElement | The element that currently has focus |
document.forms, document.images, document.links | Live collections of those elements |
document.createElement(), createTextNode() | Factories for new nodes |
document.cookie | Cookies as a single string (Chapter 10) |
When is the DOM ready? A script can only find elements that the parser has already created. A classic script in the <head> runs before <body> exists:
<head>
<script>
console.log(document.querySelector("h1")); // null: <h1> has not been parsed yet
</script>
</head>
<body>
<h1>Hello</h1>
</body>
There are three standard fixes. The first is the modern default:
<!-- 1. defer (or type="module", which defers automatically): runs after parsing -->
<script src="app.js" defer></script>
<!-- 2. Put the script at the end of <body> -->
<!-- 3. Wait for the DOMContentLoaded event (6.3) -->
<script>
document.addEventListener("DOMContentLoaded", () => {
console.log(document.querySelector("h1")); // <h1>Hello</h1>
});
</script>
Frames have their own pair. Each <iframe> has its own window and document, accessible via iframe.contentWindow and iframe.contentDocument, but only if the iframe is same-origin. Cross-origin frames can only communicate through postMessage (Chapter 17 covers the security model).
Key Takeaways
windowrepresents the tab or frame and is the global object.documentrepresents the page and is the root of the DOM.- Top-level
varand function declarations in classic scripts becomewindowproperties.let,constand module code do not. - Elements with an
idare exposed as globals. Never rely on this: select elements explicitly. documentprovides shortcuts (body,head,documentElement,activeElement) and node factories (createElement).- A script can only access elements that have already been parsed. Use
defer, modules orDOMContentLoaded.
Practice
- (Beginner) In the console of any page, log
document.readyState,document.title,window.innerWidthandwindow.devicePixelRatio. Resize the window and zoom in, then log them again and explain what changed.- (Intermediate) Write a script placed in the
<head>(withoutdefer) that logs the number of<p>elements at three moments: immediately, onDOMContentLoadedand onload. Add a large image to the page and explain the differences between the timestamps.- (Interview) "What is the difference between
window,documentandglobalThis?" Hint: think about which one is the global object, which one is a node in the tree, and which name also works inside a Web Worker or Node.js.
Element Selection Methods
Before you can change an element, you need a reference to it. The DOM offers two generations of selection methods. The older ones (getElementById, getElementsByTagName, getElementsByClassName) each search by one specific criterion. The newer ones (querySelector, querySelectorAll) accept any CSS selector, so everything you learned about selectors in Chapter 2 becomes a query language for JavaScript.
getElementById. Returns the unique element with that id, or null. It exists only on document (ids are unique per document) and is the fastest lookup because browsers keep an index of ids.
const form = document.getElementById("checkout"); // no "#" prefix
if (form === null) {
throw new Error("checkout form missing");
}
querySelector and querySelectorAll. Accept any CSS selector. querySelector returns the first matching element in document order (or null). querySelectorAll returns a static NodeList of every match.
const firstButton = document.querySelector("button");
const submit = document.querySelector("form#checkout button[type='submit']");
const required = document.querySelectorAll("input:required:not(:disabled)");
const odds = document.querySelectorAll("tbody tr:nth-child(odd)");
required.forEach((input) => input.classList.add("highlight")); // NodeList has forEach
// NodeList has no map/filter: convert to an array first
const names = Array.from(required, (input) => input.name);
const empty = [...required].filter((input) => input.value === "");
Searching inside an element. All these methods (except getElementById) also exist on elements, and then they only search that element's descendants. This is faster and avoids accidental matches elsewhere on the page.
const card = document.querySelector(".product-card");
const price = card.querySelector(".price"); // only inside this card
One subtlety: the selector is still matched against the whole document. Only the results are restricted to descendants. Use :scope to anchor the selector to the element itself:
<div id="outer">
<div class="item">A
<div class="item">B</div>
</div>
</div>
const outer = document.getElementById("outer");
outer.querySelectorAll("div .item").length; // 2: "div" can match outer itself
outer.querySelectorAll(":scope > .item").length; // 1: only direct children of outer
Live versus static collections. The older plural methods return live collections that update automatically when the document changes. querySelectorAll returns a snapshot.
const liveItems = document.getElementsByClassName("item"); // live HTMLCollection
const staticItems = document.querySelectorAll(".item"); // static NodeList
console.log(liveItems.length, staticItems.length); // 3 3
const extra = document.createElement("li");
extra.className = "item";
document.querySelector("ul").append(extra);
console.log(liveItems.length, staticItems.length); // 4 3: only the live one changed
Live collections cause a classic bug when you remove elements while iterating:
const items = document.getElementsByClassName("item"); // live
// BUG: removing an element shifts the collection, so every other item is skipped
for (let i = 0; i < items.length; i++) {
items[i].remove();
}
// Fix: iterate over a static copy
for (const item of Array.from(items)) {
item.remove();
}
Testing elements: matches and closest. Two methods test a selector against an element instead of searching for one:
element.matches(selector)returnstrueif the element itself matches.element.closest(selector)walks up from the element (including itself) and returns the nearest ancestor that matches, ornull. It is essential for event delegation (6.3).
const button = document.querySelector("button.delete");
button.matches(".delete"); // true
button.matches("form button"); // true if inside a form
const row = button.closest("tr"); // the table row containing the button
const id = button.closest("[data-id]")?.dataset.id; // nearest ancestor with data-id
Escaping awkward ids and classes. Ids may legally start with a digit or contain characters that are special in CSS (., :). querySelector("#1st") throws a SyntaxError. Use CSS.escape:
const userId = "42:admin";
document.querySelector(`#${CSS.escape(userId)}`); // "#42\:admin" is a valid selector
document.getElementById(userId); // no escaping needed here
Comparison.
| Method | Argument | Returns | Live? | Available on |
|---|---|---|---|---|
getElementById(id) | Id without # | Element or null | n/a | document only |
getElementsByClassName(names) | Space-separated classes | HTMLCollection | Live | document, elements |
getElementsByTagName(tag) | Tag name or "*" | HTMLCollection | Live | document, elements |
getElementsByName(name) | name attribute | NodeList | Live | document only |
querySelector(sel) | Any CSS selector | Element or null | n/a | document, elements, fragments |
querySelectorAll(sel) | Any CSS selector | NodeList | Static | document, elements, fragments |
closest(sel) | Any CSS selector | Ancestor Element or null | n/a | Elements |
Performance in practice. Selection is rarely the bottleneck in real applications. What matters is not selecting the same elements repeatedly inside loops or frequently fired handlers: select once, store the reference, and reuse it.
Key Takeaways
querySelectorandquerySelectorAllaccept any CSS selector and are the default choice.getElementByIdremains the fastest lookup by id.- Calling a selection method on an element restricts results to its descendants. Use
:scopeto anchor the selector itself. getElementsBy*methods return live collections.querySelectorAllreturns a static snapshot.- Never remove elements while iterating a live collection. Copy it to an array first.
matchestests an element,closestfinds the nearest matching ancestor.- Use
CSS.escapewhen building selectors from data.
Practice
- (Beginner) Given a list of products with classes
productandon-sale, select all products on sale with onequerySelectorAllcall and log their names usingArray.fromwith a mapping function.- (Intermediate) Write a function
$$(selector, root = document)that returns a real array of matching elements, and a functionfindRow(button)that returns thedata-idof the table row containing a button. Test both against a table with nested elements inside the cells.- (Interview) "What is the difference between a live
HTMLCollectionand a staticNodeList, and when can it cause a bug?" Hint: write a loop that modifies the document while iterating, and trace what happens to the indices.
Traversing the DOM Tree
Selection searches the whole tree for elements that match a pattern. Often you already have one element and need its neighbours: the parent row of a clicked cell, the next item in a list, the label just before an input. Traversal properties move through the tree one step at a time along its links, like moving through a family tree with "parent", "first child" and "next sibling".
The two families of navigation properties. Each direction exists twice: once for all nodes (including text and comments) and once for elements only.
parentNode / parentElement
^
|
previousSibling +---------------+ nextSibling
previousElementSibling | current node | nextElementSibling
<-------------+---------------+------------->
| |
firstChild | | lastChild
firstElementChild v v lastElementChild
[ child ][ child ][ child ]
childNodes (all nodes, live NodeList)
children (elements only, live HTMLCollection)
| All nodes | Elements only |
|---|---|
parentNode | parentElement |
childNodes | children |
firstChild, lastChild | firstElementChild, lastElementChild |
previousSibling, nextSibling | previousElementSibling, nextElementSibling |
| (none) | childElementCount |
Why the element-only versions exist. Because whitespace creates text nodes, the "all nodes" properties often return something you did not expect:
<ul id="menu">
<li>Home</li>
<li>Shop</li>
</ul>
const menu = document.getElementById("menu");
menu.firstChild; // #text "\n ": the line break before the first <li>
menu.firstElementChild; // <li>Home</li>
const home = menu.firstElementChild;
home.nextSibling; // #text "\n "
home.nextElementSibling; // <li>Shop</li>
home.parentNode === menu; // true
parentNode and parentElement differ only at the top of the tree: for <html>, parentNode is document while parentElement is null, because document is not an element.
Common traversal patterns.
// All siblings of an element, excluding itself
function siblings(el) {
return [...el.parentElement.children].filter((child) => child !== el);
}
// Index of an element among its siblings (e.g. which tab was clicked)
function indexInParent(el) {
return [...el.parentElement.children].indexOf(el);
}
// All ancestors up to <html>
function ancestors(el) {
const result = [];
for (let node = el.parentElement; node; node = node.parentElement) {
result.push(node);
}
return result;
}
// Is an element inside another? (also true when a === b)
const insideDialog = dialog.contains(document.activeElement);
Recursive walking. To visit every node below a root (depth-first, in document order), recursion on childNodes is the simplest approach (3.3 covers recursion):
// Count words in all text nodes under a root, skipping <script> and <style>
function countWords(node) {
if (node.nodeType === Node.TEXT_NODE) {
return node.data.split(/\s+/).filter(Boolean).length;
}
if (node.nodeType === Node.ELEMENT_NODE && /^(SCRIPT|STYLE)$/.test(node.nodeName)) {
return 0;
}
let total = 0;
for (const child of node.childNodes) {
total += countWords(child);
}
return total;
}
console.log(countWords(document.body));
TreeWalker: a built-in iterator. For large trees, or when you need to filter by node type, document.createTreeWalker gives you an iterator that does not recurse on the JavaScript stack:
// Highlight every occurrence of a word in the text of <main>
const walker = document.createTreeWalker(
document.querySelector("main"),
NodeFilter.SHOW_TEXT, // visit text nodes only
{
acceptNode(node) { // optional extra filter
return node.data.includes("JavaScript")
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_SKIP;
},
}
);
const matches = [];
while (walker.nextNode()) {
matches.push(walker.currentNode); // collect first: do not mutate while walking
}
console.log(`${matches.length} text nodes mention JavaScript`);
Comparing positions. a.compareDocumentPosition(b) returns a bit mask describing where b is relative to a (before, after, containing, contained). It is rarely needed, but useful for sorting nodes in document order:
nodes.sort((a, b) =>
a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
);
Key Takeaways
- Traversal properties move one step at a time: parent, children, first and last child, previous and next sibling.
- Each property has an "all nodes" form and an "elements only" form. Prefer the element form to avoid whitespace text nodes.
childNodesandchildrenare live collections.containstests ancestry, andclosestfinds a matching ancestor.- For full-tree visits, recurse on
childNodesor use aTreeWalkerwith aNodeFilter.
Practice
- (Beginner) Given a
<ul>with five<li>items, start from the third item and log the text of its previous and next element siblings, its parent's tag name and its index among its siblings.- (Intermediate) Write a function
textNodesContaining(root, word)using aTreeWalker, then wrap each occurrence of the word in a<mark>element. Hint:Text.splitText(offset)splits a text node in two.- (Interview) "Why does
element.firstChildsometimes return a text node when the first thing inside the element is a tag?" Hint: consider what the parser does with the characters between the opening tag and the first child tag.
Creating, Inserting and Removing Nodes
Most interfaces change after the page loads: items are added to a cart, notifications appear, rows are deleted. All of this comes down to three operations on the tree: creating nodes, attaching them at a position, and detaching them. The modern DOM API for this is short and readable. Older methods still appear in a lot of code, so you need to recognise both.
Creating nodes. document.createElement(tag) creates an element that is not yet in the document. It is invisible until you attach it.
const item = document.createElement("li"); // a detached <li>
item.className = "cart-item";
item.textContent = "Coffee beans"; // creates a child Text node
item.dataset.sku = "CB-500"; // data-sku="CB-500"
const note = document.createTextNode(" (gift)"); // an explicit Text node
item.append(note);
console.log(item.isConnected); // false: not in the document yet
document.querySelector("#cart").append(item);
console.log(item.isConnected); // true
Modern insertion methods. These methods accept any number of nodes and strings (strings are inserted as text, never parsed as HTML):
el.before(...)
<div id="el"> <- el
el.prepend(...)
...existing children...
el.append(...)
</div>
el.after(...)
| Method | Inserts | Notes |
|---|---|---|
parent.append(...nodes) | As last children | Most common |
parent.prepend(...nodes) | As first children | |
el.before(...nodes) | As previous siblings | |
el.after(...nodes) | As next siblings | |
el.replaceWith(...nodes) | In place of el | el is detached |
el.remove() | Nothing (detaches el) | |
parent.replaceChildren(...nodes) | Replaces all children | With no arguments, empties the element |
const list = document.querySelector("#todo");
list.append("Last item as text", document.createElement("hr")); // mixed nodes and strings
list.prepend(makeItem("Urgent"));
const second = list.children[1];
second.before(makeItem("Inserted before the second"));
second.replaceWith(makeItem("Replacement"));
list.replaceChildren(); // remove every child in one call
function makeItem(text) {
const li = document.createElement("li");
li.textContent = text;
return li;
}
Legacy methods. These are called on the parent and take a single node:
| Legacy | Modern equivalent |
|---|---|
parent.appendChild(node) | parent.append(node) |
parent.insertBefore(node, ref) | ref.before(node) (or parent.append if ref is null) |
parent.replaceChild(newNode, old) | old.replaceWith(newNode) |
parent.removeChild(node) | node.remove() |
One difference is useful: appendChild returns the appended node, while append returns undefined.
insertAdjacent* methods. These insert relative to an element using one of four position strings, which match the diagram above: "beforebegin", "afterbegin", "beforeend", "afterend".
const card = document.querySelector(".card");
card.insertAdjacentText("afterbegin", "New: "); // text, never parsed
card.insertAdjacentElement("afterend", document.createElement("hr"));
card.insertAdjacentHTML("beforeend", "<small>Updated</small>"); // parses HTML: see the sanitization topic
insertAdjacentHTML parses a string and inserts the resulting nodes without destroying the existing children. That makes it safer for performance than innerHTML +=, which re-parses and rebuilds everything (next topics). It carries the same injection risk as innerHTML, though.
Inserting an existing node moves it. A node can only have one parent. Appending a node that is already in the document moves it. It is not copied:
const a = document.querySelector("#list-a");
const b = document.querySelector("#list-b");
b.append(a.firstElementChild); // moved from list A to list B; list A loses it
Copying with cloneNode. To duplicate a node, clone it. cloneNode(true) copies the whole subtree, while cloneNode() copies only the node itself. Clones keep attributes but not event listeners added with addEventListener, and they keep duplicate ids, which you must fix.
const original = document.querySelector("#row-template-example");
const copy = original.cloneNode(true); // deep clone
copy.id = "row-2"; // ids must stay unique
original.after(copy);
Removing nodes and memory. remove() detaches a node from the tree. If your code still holds a reference to it (in a variable, an array, or a closure), it stays in memory, as a "detached DOM node" that you can find in DevTools heap snapshots. Release references to elements you remove, and remove event listeners on long-lived objects that point at them (4.4 covered memory leaks).
Building many nodes. Every insertion into the live document can invalidate layout. Insert in a single operation where possible. append with spread arguments, or a DocumentFragment (later topic), builds the nodes off-document first:
const products = [{ name: "Tea" }, { name: "Coffee" }, { name: "Cocoa" }];
const items = products.map((p) => {
const li = document.createElement("li");
li.textContent = p.name;
return li;
});
document.querySelector("#products").append(...items); // one insertion
For very large arrays, spread can exceed the engine's argument limit (tens of thousands of arguments), so use a fragment instead.
Key Takeaways
createElementmakes detached nodes. They appear only once they are inserted into the document.append,prepend,before,after,replaceWith,removeandreplaceChildrenare the modern API. They accept several nodes and strings.- Strings passed to these methods are inserted as text, never parsed as HTML.
- Inserting a node that is already in the tree moves it. Use
cloneNode(true)to copy it (listeners are not copied). - Removed nodes stay in memory while references to them exist.
- Build nodes first and insert them in one operation to minimise layout work.
Practice
- (Beginner) Build a to-do list with an input and an "Add" button. Each added item must contain the text and a "Delete" button that removes only that item. Use
createElement,appendandremoveonly.- (Intermediate) Write a function
moveSelected(from, to)that moves every checked item from one list to another, preserving order, and a "Duplicate" action that clones an item with a new unique id. Verify that cloned delete buttons do not work until you attach listeners, and explain why.- (Interview) "What happens if you
appendan element that is already somewhere else in the document?" Hint: think about how many parents a node can have, and what that implies for drag-and-drop between lists.
Attributes, Properties and Dataset
An HTML attribute and a DOM property often have the same name: <input value="5"> and input.value. It is tempting to assume they are the same thing, but they are not, and confusing them causes real bugs, particularly with form fields. The distinction is easiest to grasp as initial configuration versus current state. Attributes are what the HTML author wrote, stored as strings. Properties are the live state of the JavaScript object, with real types.
Attribute API. These methods work on the attribute strings:
const link = document.querySelector("a");
link.getAttribute("href"); // "/docs" exactly as written in the HTML
link.setAttribute("target", "_blank");
link.hasAttribute("download"); // false
link.removeAttribute("title");
link.toggleAttribute("hidden"); // add if missing, remove if present
for (const attr of link.attributes) { // all attributes as Attr objects
console.log(attr.name, attr.value);
}
Properties. Most standard attributes are reflected as properties of the element object, but the property may have a different name, type or value:
const link = document.querySelector('a[href="/docs"]');
link.getAttribute("href"); // "/docs" (string as authored)
link.href; // "https://example.com/docs" (resolved absolute URL)
const box = document.querySelector('input[type="checkbox"]');
box.getAttribute("checked"); // "" or null (string or absent)
box.checked; // true or false (boolean)
const td = document.querySelector("td[colspan]");
td.getAttribute("colspan"); // "2" (string)
td.colSpan; // 2 (number)
| Attribute | Property | Difference |
|---|---|---|
class | className (string), classList (6.2) | class is a reserved word in old JavaScript |
for (on <label>) | htmlFor | Same reason |
href, src | href, src | Property returns the resolved absolute URL |
checked, disabled, hidden | Same names | Property is a boolean |
tabindex, colspan | tabIndex, colSpan | Property is a number, camelCase |
style | style | Property is a CSSStyleDeclaration object (6.2) |
value (on <input>) | defaultValue | value property is the current value |
aria-label | ariaLabel | ARIA reflection, supported in modern browsers |
The value trap. For form fields, the value attribute is only the initial value. Once the user types, the value property changes and the attribute does not:
<input id="qty" value="1">
const qty = document.getElementById("qty");
// The user types "3" into the field...
qty.value; // "3": current state
qty.getAttribute("value"); // "1": still the initial value
qty.defaultValue; // "1": the property that reflects the attribute
// Form reset restores the attribute value
qty.form?.reset(); // qty.value is "1" again
The same split exists for checkboxes: the checked attribute is defaultChecked, and the checked property is the current state. Always use properties to read and set the current state of form controls.
Boolean attributes. For boolean attributes, presence means true, whatever the value. This is a frequent source of bugs:
button.setAttribute("disabled", "false"); // STILL disabled: the attribute is present
button.disabled = false; // correct: removes the attribute
button.removeAttribute("disabled"); // also correct
button.toggleAttribute("disabled", isBusy); // add or remove based on a boolean
Custom data with data-* and dataset. HTML reserves attributes that start with data- for your own data. They are valid on any element, ignored by the browser, and accessible through the dataset property, which converts kebab-case names to camelCase:
<article id="p1" data-product-id="1042" data-price="19.90" data-in-stock>
Coffee grinder
</article>
const product = document.getElementById("p1");
product.dataset.productId; // "1042" (data-product-id)
product.dataset.price; // "19.90" always a string
Number(product.dataset.price); // 19.9 convert explicitly
"inStock" in product.dataset; // true (data-in-stock="" is present)
product.dataset.lastViewed = Date.now(); // creates data-last-viewed="1727..."
delete product.dataset.inStock; // removes data-in-stock
dataset values are always strings, so store numbers and booleans carefully, and avoid storing large or structured data in the DOM. Keep that in JavaScript (a Map keyed by element or id works well). Data attributes are also selectable from CSS, which makes them good for state that styling depends on:
[data-status="error"] { border-color: crimson; }
Non-standard attributes. Arbitrary attributes such as <div tooltip="..."> are not reflected as properties: div.tooltip is undefined, and getAttribute("tooltip") works but the markup is invalid HTML. Use data-tooltip instead.
Key Takeaways
- Attributes are the authored strings in the HTML. Properties are the typed, live state of the DOM object.
- Many attributes are reflected as properties with different names (
className,htmlFor) or types (boolean, number, absolute URL). - For form controls,
valueandcheckedproperties are the current state. The attributes are the defaults (defaultValue,defaultChecked). - A boolean attribute is true whenever it is present, even with the value
"false". Set the property or remove the attribute instead. - Use
data-*attributes anddatasetfor custom data. Values are always strings.
Practice
- (Beginner) Create an
<input value="hello">, type something else into it, then loginput.value,input.getAttribute("value")andinput.defaultValue. Callform.reset()and log them again.- (Intermediate) Render a product grid from an array of objects, storing each product's id and price in
data-*attributes. Add buttons that sort the grid by price by readingdatasetand re-appending the elements in the new order.- (Interview) "Why doesn't
setAttribute('disabled', 'false')enable a button?" Hint: look up how HTML defines boolean attributes, and compare with thedisabledproperty.
innerHTML, textContent and Sanitization
Creating every node with createElement is safe but verbose. Assigning a string of HTML is much shorter, and the browser's parser does the work. That convenience comes with the most common security vulnerability on the web: cross-site scripting (XSS), where data controlled by an attacker is interpreted as markup or script. Choosing the right property for each job is mostly a question of whether you want your string to be parsed as HTML or treated as text.
The four content properties.
| Property | Reads | Writes | Parses HTML? | Layout-aware? |
|---|---|---|---|---|
innerHTML | Serialised HTML of the children | Replaces children by parsing the string | Yes | No |
outerHTML | Serialised HTML including the element itself | Replaces the element itself | Yes | No |
textContent | Concatenated text of all descendant text nodes | Replaces children with one text node | No | No |
innerText | Text as rendered (respects CSS, line breaks) | Replaces children with text and <br> | No | Yes (forces layout) |
<div id="box">Hello <b>world</b><span style="display:none"> hidden</span></div>
const box = document.getElementById("box");
box.innerHTML; // 'Hello <b>world</b><span style="display:none"> hidden</span>'
box.textContent; // "Hello world hidden" includes hidden text, no layout needed
box.innerText; // "Hello world" only rendered text, triggers layout
textContent for text. When the string is meant to be text (a user name, a comment, a product title, anything that came from a server or user input), use textContent. The browser never interprets it:
const comment = '<img src=x onerror="alert(document.cookie)">';
output.textContent = comment; // safe: displays the characters literally
output.innerHTML = comment; // XSS: the broken image triggers onerror, running the attacker's code
Note that <script> tags inserted with innerHTML are not executed. That is not a protection, because event handler attributes such as onerror, onload and onfocus are executed, as are javascript: URLs in links.
innerHTML replaces everything. Assigning innerHTML destroys all existing child nodes and creates new ones. This has consequences beyond security:
const list = document.querySelector("#list");
const firstItem = list.firstElementChild;
firstItem.addEventListener("click", onClick);
list.innerHTML += "<li>New</li>"; // serialise, concatenate, re-parse ALL children
firstItem.isConnected; // false: the old <li> was destroyed and recreated
// Its listener is gone, any typed input text is lost, focus is lost, and the
// whole list was rebuilt just to add one item.
Use append or insertAdjacentHTML("beforeend", ...) to add content without rebuilding the rest.
Building markup from data safely. If you do build HTML strings (for templates, or code that predates the DOM methods), every interpolated value must be escaped. Escaping converts the five special characters into entities:
function escapeHTML(value) {
return String(value)
.replaceAll("&", "&") // must be first
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
const user = { name: "<b>Eve</b>", bio: '"><img src=x onerror=alert(1)>' };
card.innerHTML = `
<h2>${escapeHTML(user.name)}</h2>
<p title="${escapeHTML(user.bio)}">Bio</p>
`;
Escaping is context-dependent. It protects element content and quoted attribute values. It does not make a value safe inside href (a javascript: URL contains no special characters), inside a <script> block, inside an event handler attribute, or inside CSS. For URLs, validate the scheme:
function safeUrl(url) {
const parsed = new URL(url, location.href);
return ["http:", "https:", "mailto:"].includes(parsed.protocol) ? parsed.href : "#";
}
The tagged template literal from Chapter 3 that escapes every interpolated value is a tidy way to apply escaping consistently.
Sanitization: when you must accept HTML. Sometimes the content genuinely is HTML: a rich-text editor's output, a Markdown preview, an email body. Escaping would destroy the formatting. Instead, you sanitize it: parse it, keep an allowlist of harmless elements and attributes, and remove everything else (scripts, event handlers, javascript: URLs, <iframe>, and so on). Do not write your own sanitizer, because the HTML parser has many surprising behaviours that attackers exploit. Use a maintained library:
import DOMPurify from "dompurify";
const dirty = '<p>Hi <a href="javascript:alert(1)">click</a><img src=x onerror=alert(2)></p>';
preview.innerHTML = DOMPurify.sanitize(dirty);
// <p>Hi <a>click</a><img src="x"></p> dangerous attributes removed
Browsers are also adding native support. The HTML Sanitizer API provides element.setHTML(string), which parses and sanitizes in one step, and Trusted Types lets a site enforce, through Content Security Policy, that dangerous sinks such as innerHTML only accept values produced by approved sanitizing functions. Check current browser support before relying on either without a fallback. Chapter 17 covers XSS defences in depth.
Choosing the right tool.
| Situation | Use |
|---|---|
| Display a plain string (from the user, a server or a URL) | textContent |
| Build a few elements with dynamic data | createElement and textContent |
| Insert a large static fragment written by you | <template> (next topic) or innerHTML with a constant string |
| Build HTML from data in a string template | Escape every value, validate URLs |
| Display user-provided HTML | Sanitize (DOMPurify or setHTML) |
| Read visible text for copying or display | innerText (sparingly, as it forces layout) |
Key Takeaways
innerHTMLparses its string as HTML. Assigning untrusted data to it is an XSS vulnerability, even though inserted<script>tags do not run.textContenttreats strings as text and is the safe default for dynamic content.innerTextreflects rendered text and forces a layout calculation.textContentincludes hidden text and is cheap.innerHTML +=destroys and rebuilds all children, losing listeners, state and focus.- Escape values in HTML templates, validate URL schemes separately, and sanitize with a library when HTML input is required.
Practice
- (Beginner) Build a comment box that displays submitted comments. Implement it once with
innerHTMLand test the payload<img src=x onerror="alert('xss')">, then fix it withtextContent.- (Intermediate) Write a tagged template function
htmlthat escapes every interpolated value except values wrapped in araw()marker object, and use it to render a list of user profiles that includes a link built with a validated URL.- (Interview) "If
<script>tags inserted withinnerHTMLdo not execute, why isinnerHTMLstill dangerous?" Hint: list other places in HTML where the browser runs JavaScript or follows URLs.
DocumentFragment and Template Elements
Two problems keep returning when you build UI with the DOM API. First, inserting many nodes one by one into the live document is wasteful, because each insertion modifies the visible tree. Second, building a complex structure (a card with an image, a title, a price and three buttons) with createElement calls is long, hard to read, and separates the markup from the HTML where designers expect to find it. DocumentFragment solves the first problem and <template> solves the second, and they work together.
DocumentFragment: a temporary container. A fragment is a lightweight node with no parent that is never part of the document. You build a subtree inside it, then insert the fragment. At that moment its children move into the document and the fragment itself becomes empty, so it leaves no wrapper element behind.
Before insertion After list.append(fragment)
fragment <ul id="list">
├── <li>Tea</li> ├── (existing items)
├── <li>Coffee</li> ├── <li>Tea</li>
└── <li>Cocoa</li> ├── <li>Coffee</li>
└── <li>Cocoa</li>
fragment (now empty)
const names = ["Tea", "Coffee", "Cocoa"];
const fragment = document.createDocumentFragment(); // or: new DocumentFragment()
for (const name of names) {
const li = document.createElement("li");
li.textContent = name;
fragment.append(li); // cheap: the fragment is not rendered
}
document.querySelector("#list").append(fragment); // one insertion into the live DOM
console.log(fragment.childNodes.length); // 0
Modern browsers are good at batching DOM changes made in the same task, so the performance gain over appending directly is often small. The real problems appear when you read layout between insertions (see Reflow, Repaint and DOM Performance). Fragments are still the clearest way to express "build everything, then insert once", and they work with any number of nodes (unlike append(...array)).
<template>: inert markup. A <template> element holds HTML that the parser processes but does not render or activate. Its content is not displayed, images inside it are not downloaded, scripts are not run, and it is not found by document.querySelector. It is a blueprint you clone whenever you need a new instance.
<template id="product-card">
<article class="card">
<img class="card-img" alt="">
<h3 class="card-title"></h3>
<p class="card-price"></p>
<button class="card-buy" type="button">Add to cart</button>
</article>
</template>
<section id="catalog"></section>
The template's children live in template.content, which is a DocumentFragment. Clone it deeply, fill in the data with safe properties, and insert it:
const template = document.getElementById("product-card");
const catalog = document.getElementById("catalog");
const products = [
{ id: 1, title: "Grinder", price: 49.9, image: "/img/grinder.jpg" },
{ id: 2, title: "Kettle", price: 35, image: "/img/kettle.jpg" },
];
const fragment = document.createDocumentFragment();
for (const product of products) {
const card = template.content.cloneNode(true); // deep copy of the fragment
// Query inside the clone BEFORE inserting it: afterwards the fragment is empty
card.querySelector(".card").dataset.id = product.id;
card.querySelector(".card-img").src = product.image;
card.querySelector(".card-img").alt = product.title;
card.querySelector(".card-title").textContent = product.title; // text, never parsed
card.querySelector(".card-price").textContent =
new Intl.NumberFormat("en", { style: "currency", currency: "EUR" }).format(product.price);
fragment.append(card);
}
catalog.append(fragment);
This pattern combines the readability of HTML with the safety of textContent. It is the vanilla-JavaScript equivalent of a component, and it is how many lightweight libraries render lists.
A common mistake: keeping a reference to the clone. After catalog.append(card), the variable card refers to an empty fragment. If you need the inserted element later (for example, to attach listeners), grab it before inserting:
const clone = template.content.cloneNode(true);
const article = clone.firstElementChild; // the <article> itself
catalog.append(clone);
article.querySelector(".card-buy").addEventListener("click", addToCart); // works
Even better, use event delegation (6.3) on #catalog, so new cards need no listeners at all.
importNode versus cloneNode. document.importNode(template.content, true) also produces a deep copy, with the copied nodes owned by document. For templates in the same document, both work. The difference matters for custom elements and for templates that come from another document.
Templates and Web Components. <template> is one of the building blocks of Web Components, together with custom elements and Shadow DOM. A custom element typically clones a template into its shadow root, and shadow roots are themselves DocumentFragment subclasses (nodeType 11).
Key Takeaways
- A
DocumentFragmentis an off-document container. Appending it moves its children into the document and leaves it empty. - Fragments express "build everything, then insert once" and avoid repeated live insertions.
- A
<template>holds inert markup: it is parsed but not rendered, its images do not load and its scripts do not run. - Clone
template.contentwithcloneNode(true), fill it withtextContentand properties, then insert it. - Query and grab references inside a cloned fragment before inserting it.
Practice
- (Beginner) Render 1,000 numbered
<li>items into a list, once by appending each item directly and once through aDocumentFragment. Measure both withperformance.now()and explain why the difference is small or large.- (Intermediate) Create a
<template>for a table row with name, email and a "Remove" button. Load an array of users and render the table from the template, then add a search box that re-renders only the rows that match, usingreplaceChildren.- (Interview) "Why would you use a
<template>element instead of a hidden<div>containing the same markup?" Hint: think about what the browser does with images, scripts, ids andquerySelectorresults in each case.
Reflow, Repaint and DOM Performance
The DOM API makes it look as if changing an element is a cheap property assignment. Behind each change, the browser may have to recalculate styles, recompute the size and position of many elements, and redraw pixels. Section 1.1 introduced the rendering pipeline. This topic explains how your JavaScript interacts with it, and the single most common performance mistake in DOM code: forcing the browser to do that work many times in a single frame.
The pipeline, revisited. After a script changes the DOM or styles, the browser normally does the following work once, when the current task has finished and it is time to render:
JavaScript ──> Style ──> Layout ──> Paint ──> Composite
(changes) (which (size and (draw (assemble layers
rules position pixels on the GPU)
apply) of boxes) into
layers)
- Style recalculation: works out which CSS rules apply to each changed element.
- Layout (also called reflow): computes geometry (widths, heights, positions). A change to one element can affect its parent, siblings and children, so layout often touches large parts of the page.
- Paint (also called repaint): fills in the pixels of text, colours, borders and shadows.
- Composite: combines the painted layers, applying
transformandopacityon the GPU.
What each kind of change costs.
| Change | Style | Layout | Paint | Composite |
|---|---|---|---|---|
width, height, margin, padding, font-size, top, left, adding or removing nodes, changing text | Yes | Yes | Yes | Yes |
color, background-color, box-shadow, visibility, outline | Yes | No | Yes | Yes |
transform, opacity (on an element with its own layer) | Yes | No | No | Yes |
This is why animations should use transform and opacity (6.2 and Chapter 2): they skip layout and paint entirely.
Lazy rendering and forced synchronous layout. Browsers are lazy on purpose. When you change a style, they mark the layout as dirty and wait, so that ten changes in one task cost one layout. The laziness breaks when your code reads a value that depends on layout. To return an accurate answer, the browser must run style and layout immediately, synchronously, in the middle of your script. This is a forced synchronous layout (or forced reflow).
Properties and methods that force layout when the layout is dirty include:
offsetWidth,offsetHeight,offsetTop,offsetLeftclientWidth,clientHeight,clientTop,clientLeftscrollWidth,scrollHeight,scrollTop,scrollLeftgetBoundingClientRect(),getClientRects()getComputedStyle()(when reading layout-dependent properties)innerText,focus(),scrollIntoView(),window.scrollY
Layout thrashing. One forced layout is fine. The problem is interleaving reads and writes in a loop, which forces a layout on every iteration:
const boxes = document.querySelectorAll(".box");
// BAD: read, write, read, write... one forced layout per box
for (const box of boxes) {
const width = box.parentElement.offsetWidth; // READ: forces layout (layout is dirty)
box.style.width = `${width / 2}px`; // WRITE: makes the layout dirty again
}
With 500 boxes, this triggers 500 layouts in one task. The timeline in the DevTools Performance panel shows a wall of purple "Layout" bars, each flagged with a "Forced reflow" warning.
Thrashing: [R L W][R L W][R L W][R L W] ... x500 (L = full layout)
Batched: [R R R R ... R][W W W W ... W] -> one layout at render time
Fix 1: batch reads, then writes.
// GOOD: all reads first (at most one layout), then all writes (no layout until render)
const widths = [...boxes].map((box) => box.parentElement.offsetWidth); // reads
boxes.forEach((box, i) => {
box.style.width = `${widths[i] / 2}px`; // writes
});
Fix 2: schedule writes for the next frame. When reads and writes come from different parts of the code (separate components, separate handlers), you cannot easily reorder them. Defer writes with requestAnimationFrame (5.1), so all of them run together just before the next render:
function onScroll() {
const y = window.scrollY; // read now
requestAnimationFrame(() => {
header.classList.toggle("compact", y > 80); // write in the next frame
});
}
Libraries such as fastdom formalise this by queuing all reads and all writes and flushing them in two phases per frame.
Other techniques.
// 1. Change classes, not many individual style properties
card.classList.add("expanded"); // one style invalidation; CSS does the rest
// 2. Hide, modify, show: changes to display:none subtrees do not cause layout
list.hidden = true;
rebuildHugeList(list); // no layout while hidden
list.hidden = false; // one layout
// 3. Cache layout values that do not change
const headerHeight = header.offsetHeight; // read once, outside the hot path
/* 4. Limit how far a layout change can spread */
.widget {
contain: layout paint; /* this box's internals cannot affect the outside */
}
/* 5. Skip rendering work for off-screen sections entirely */
.feed-item {
content-visibility: auto; /* layout and paint only when near the viewport */
contain-intrinsic-size: auto 300px; /* placeholder size to keep the scrollbar stable */
}
Measuring. Do not optimise by guesswork. Record a trace in the DevTools Performance panel while interacting with the page. Long purple Layout blocks with a red corner and the warning "Forced reflow is a likely performance bottleneck" point to the exact line of JavaScript that caused the read. The Rendering drawer's "Paint flashing" option highlights repainted areas in green.
Key Takeaways
- After DOM and style changes, browsers run style, layout, paint and composite. Layout is usually the most expensive step.
- Browsers postpone layout until it is needed. Reading geometry (
offsetWidth,getBoundingClientRect,scrollTop) while layout is dirty forces it to happen synchronously. - Alternating reads and writes in a loop causes layout thrashing. Batch all reads before all writes, or defer writes with
requestAnimationFrame. - Animate
transformandopacity, toggle classes instead of setting many styles, and usecontainandcontent-visibilityto limit work. - Confirm problems with the Performance panel before optimising.
Practice
- (Beginner) Create 300
.boxelements and run the "BAD" loop above. Record it in the Performance panel and find the forced reflow warnings. Then apply the batched version and compare the total scripting and rendering time.- (Intermediate) Build a "masonry" layout that positions cards by reading each card's height and writing
transform: translate(...)values. Implement it first with interleaved reads and writes, then with a read phase and a write phase, and measure the difference with 1,000 cards.- (Interview) "What is layout thrashing and how do you avoid it?" Hint: explain why reading a geometry property is not free when there are pending style changes, and describe at least two ways to group reads and writes.
MutationObserver
Sometimes code needs to react when the DOM changes, but it does not control the code that makes the change. Examples include a third-party widget that injects content, a browser extension that must process newly loaded comments, an accessibility script that labels elements as a framework creates them, or a test that waits for an element to appear. Polling the DOM with setInterval is wasteful and slow to react. The original solution, Mutation Events (DOMNodeInserted and similar), fired synchronously on every single change and made pages extremely slow. They have been deprecated and removed from modern browsers. Their replacement is MutationObserver.
How it works. You create an observer with a callback, then tell it which node to watch and which kinds of change interest you. The browser records matching changes as MutationRecord objects and delivers them in batches, as a microtask (5.1), after the code that made the changes has finished. A loop that adds 1,000 nodes produces a single callback with 1,000 records, not 1,000 callbacks.
const observer = new MutationObserver((records, obs) => {
for (const record of records) {
console.log(record.type, record.target);
}
});
observer.observe(document.querySelector("#feed"), {
childList: true, // children added or removed
subtree: true, // ...anywhere below the target, not just direct children
attributes: true, // attribute changes
attributeFilter: ["class", "data-state"], // ...only these attributes
attributeOldValue: true, // include the previous value
characterData: false, // changes to Text node contents
});
// Later, when no longer needed:
observer.disconnect();
At least one of childList, attributes or characterData must be true, otherwise observe throws a TypeError.
The MutationRecord.
| Property | Meaning |
|---|---|
type | "childList", "attributes" or "characterData" |
target | The node that changed (the parent, for childList) |
addedNodes, removedNodes | NodeLists of nodes added or removed (childList) |
previousSibling, nextSibling | Where the added or removed nodes were |
attributeName | Name of the changed attribute (attributes) |
oldValue | Previous value, if attributeOldValue or characterDataOldValue was requested |
Example: enhance elements as they appear. Suppose a chat widget injects messages, and you want to convert every timestamp to the reader's local time:
function localiseTimes(root) {
// root may be the added element itself or contain matching elements
const times = root.matches?.("time[datetime]")
? [root]
: root.querySelectorAll?.("time[datetime]") ?? [];
for (const time of times) {
time.textContent = new Date(time.dateTime).toLocaleTimeString();
}
}
const chat = document.querySelector("#chat");
localiseTimes(chat); // handle existing content first
const observer = new MutationObserver((records) => {
for (const record of records) {
for (const node of record.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) { // skip text and comment nodes
localiseTimes(node);
}
}
}
});
observer.observe(chat, { childList: true, subtree: true });
Example: wait for an element. A promise-based helper, useful in tests and scripts that run before a framework has rendered:
function waitForElement(selector, { root = document, timeout = 5000 } = {}) {
return new Promise((resolve, reject) => {
const existing = root.querySelector(selector);
if (existing) return resolve(existing);
const observer = new MutationObserver(() => {
const el = root.querySelector(selector);
if (el) {
observer.disconnect();
clearTimeout(timer);
resolve(el);
}
});
observer.observe(root, { childList: true, subtree: true });
const timer = setTimeout(() => {
observer.disconnect();
reject(new Error(`Timed out waiting for ${selector}`));
}, timeout);
});
}
const banner = await waitForElement(".cookie-banner");
Pitfalls.
- Infinite loops. If the callback modifies the nodes it observes in a way that matches the options, it triggers itself again. Either check whether the work is already done (for example, a
data-processedflag), or disconnect, modify, and observe again. - Cost of
subtree: trueondocument. Observing the whole document with every option on a busy page creates many records. Observe the smallest subtree possible and filter attributes withattributeFilter. - Batched records lose intermediate states. You see that a node was added and removed, but by the time the callback runs it may already be detached. Check
node.isConnectedif it matters. takeRecords(). Before disconnecting, callobserver.takeRecords()to retrieve and process records that are queued but not yet delivered.
The observer family. MutationObserver is one of several observers with the same shape (constructor with callback, observe, disconnect). IntersectionObserver and ResizeObserver are covered in 6.4.
Key Takeaways
MutationObserverreports DOM changes (children, attributes, text) to a callback without polling.- Records are batched and delivered as a microtask after the changing code finishes, which keeps observation cheap.
- Configure
childList,attributesandcharacterData, plussubtree,attributeFilterand old-value options, to receive only what you need. - Process existing content first, then observe for future changes.
- Avoid callbacks that re-trigger themselves, keep the observed subtree small, and
disconnectwhen done. - It replaces the deprecated, synchronous Mutation Events.
Practice
- (Beginner) Observe a
<ul>forchildListchanges and log the number of added and removed items in each callback. Add three items in one function call and confirm you receive one callback with three records.- (Intermediate) Write a script that adds
target="_blank" rel="noopener"to every external link in a container, including links added later by other code. Make sure your own attribute changes do not cause an infinite loop.- (Interview) "Why were Mutation Events replaced by
MutationObserver?" Hint: compare when each one runs relative to the code that changes the DOM, and how many times each fires for a loop of 1,000 insertions.
<a id="62-css-management-and-manipulation"></a>
6.2 CSS Management and Manipulation
This subchapter covers how JavaScript reads and changes presentation: inline styles, classes, computed values, CSS custom properties, whole stylesheets, element geometry and scrolling, and animations controlled from script.
Inline Styles via the style Property
Chapter 2 established that presentation belongs in CSS. Some values, though, are only known at runtime: the position of an element being dragged, the width of a progress bar, a colour picked by the user. For these, JavaScript writes styles directly onto an element through its style property, which represents the element's inline style (the same thing as the style="..." attribute).
Reading and writing individual properties. element.style is a CSSStyleDeclaration object. CSS property names become camelCase properties, and every value is a string with units:
const bar = document.querySelector(".progress-bar");
bar.style.width = "75%"; // width: 75%
bar.style.backgroundColor = "seagreen"; // background-color -> backgroundColor
bar.style.borderTopLeftRadius = "4px"; // border-top-left-radius
bar.style.cssFloat = "left"; // "float" is a reserved word in old JS
bar.style.width = 75; // IGNORED: a number has no unit
bar.style.width = "75 px"; // IGNORED: invalid CSS, no error is thrown
bar.style.width = `${percent}%`; // build values with template literals
Invalid values are silently discarded and the previous value stays in place. If a style "does not work", log element.style.cssText to see what was actually stored.
The setProperty family. These methods use the real CSS (kebab-case) names and support !important and custom properties:
bar.style.setProperty("background-color", "seagreen");
bar.style.setProperty("width", "75%", "important"); // width: 75% !important
bar.style.setProperty("--fill", "0.75"); // custom properties need setProperty
bar.style.getPropertyValue("--fill"); // "0.75"
bar.style.removeProperty("width"); // remove one inline declaration
Removing an inline style. Assigning an empty string removes the declaration, so the value from the stylesheets applies again:
bar.style.display = "none"; // hide
bar.style.display = ""; // restore whatever CSS says (block, flex, grid...)
This is better than setting display = "block", which would hard-code a value that may be wrong for that element.
Several properties at once with cssText. cssText reads or replaces the whole inline style as a string. Assigning it overwrites all existing inline declarations:
tooltip.style.cssText = "position: absolute; top: 10px; left: 20px;"; // replaces everything
tooltip.style.cssText += "z-index: 10;"; // append instead
// Object.assign is a readable alternative that keeps other declarations
Object.assign(tooltip.style, { top: `${y}px`, left: `${x}px` });
style reads only inline styles. This is the most common misunderstanding. element.style knows nothing about stylesheets:
<style> .card { padding: 16px; color: navy; } </style>
<div class="card" style="margin: 8px">...</div>
const card = document.querySelector(".card");
card.style.margin; // "8px": set inline
card.style.padding; // "": set in a stylesheet, so not visible here
card.style.color; // ""
// To read the value that actually applies, use getComputedStyle (later topic)
Inline styles and the cascade. Inline declarations have higher specificity than any selector, so they override stylesheet rules (except !important ones). This is useful for dynamic values but makes the CSS hard to override later, for example in a dark theme or a media query. Rule of thumb:
| Use inline styles for | Use classes for |
|---|---|
| Continuous, computed values: positions, sizes, percentages, transforms | Discrete states: open/closed, active, error, loading |
| Values that change every frame (dragging, following the pointer) | Anything a designer may want to restyle |
| One-off values specific to one element | Themes, responsive variants |
Even for continuous values, a good pattern is to set a custom property inline and let the stylesheet decide how to use it (see Manipulating CSS Variables from JavaScript).
Content Security Policy. A strict CSP (style-src without 'unsafe-inline') blocks style="..." attributes in markup and setAttribute("style", ...). It does not block setting individual properties through the CSSOM, such as el.style.width = "10px" or el.style.setProperty(...). Code that uses the object API keeps working under strict policies (Chapter 17).
Key Takeaways
element.stylereads and writes the inline style only, using camelCase names and string values with units.- Invalid values are ignored silently. Assigning
""removes a declaration and lets stylesheets apply again. - Use
setPropertyfor kebab-case names,!importantand custom properties.cssTextreplaces the whole inline style. element.styledoes not show values from stylesheets. UsegetComputedStylefor those.- Prefer inline styles for continuous computed values and classes for discrete states.
Practice
- (Beginner) Build a progress bar whose width is set from a range input (
<input type="range">). Display the percentage inside the bar, and change its background colour to red below 20% using onlyelement.style.- (Intermediate) Make an element follow the mouse pointer inside a container by setting
transform: translate(...)onpointermove. Then rewrite it so that JavaScript only sets two custom properties and the stylesheet computes the transform.- (Interview) "Why does
element.style.colorreturn an empty string even though the text is visibly red?" Hint: consider which declarations thestyleobject represents and where the red colour is declared.
classList and Dynamic Class Manipulation
Most visual changes in an interface are switches between a few known states: a menu is open or closed, a field is valid or invalid, a tab is active or not. These states are best described in CSS, where all the related property changes (colour, border, icon, animation) live together, and JavaScript's only job is to say which state the element is in. The tool for that is the element's class list.
className: the old string API. className gives the class attribute as one string. Editing it requires string manipulation, which is error-prone:
el.className; // "btn btn-primary"
el.className += " active"; // easy to forget the leading space
el.className = el.className.replace("active", ""); // also removes "inactive"!
classList: the token API. classList is a DOMTokenList, a set-like view of the classes with safe methods:
const btn = document.querySelector(".btn");
btn.classList.add("active", "large"); // add one or more (duplicates ignored)
btn.classList.remove("large"); // remove (no error if absent)
btn.classList.contains("active"); // true
btn.classList.replace("btn-primary", "btn-danger"); // returns true if replaced
btn.classList.toggle("active"); // remove if present, add if absent; returns new state
btn.classList.toggle("disabled", !isValid); // with a second argument: force add (true) or remove (false)
btn.classList.length; // number of classes
[...btn.classList]; // ["btn", "btn-danger", "active"]
btn.classList.value; // same as className
The second argument of toggle is the most useful form. It ties a class directly to a boolean condition, so there is no need for if/else around add and remove:
form.classList.toggle("has-errors", errors.length > 0);
nav.classList.toggle("sticky", window.scrollY > header.offsetHeight);
Pattern: state in classes, styling in CSS. A disclosure panel (accordion section):
<section class="panel">
<button class="panel-toggle" aria-expanded="false" aria-controls="p1">Shipping</button>
<div class="panel-body" id="p1">Free shipping over 50 EUR.</div>
</section>
.panel-body { display: none; }
.panel.is-open .panel-body { display: block; }
.panel-toggle::after { content: "+"; }
.panel.is-open .panel-toggle::after { content: "-"; }
document.querySelector(".panel-toggle").addEventListener("click", (event) => {
const panel = event.currentTarget.closest(".panel");
const open = panel.classList.toggle("is-open"); // returns the new state
event.currentTarget.setAttribute("aria-expanded", open); // keep assistive tech informed
});
The JavaScript knows nothing about how "open" looks. A designer can change the appearance, add a transition or restyle it for mobile without touching the script.
Classes, attributes or ARIA? Classes are not the only way to express state. The table summarises the common options:
| Mechanism | Example | Best for |
|---|---|---|
| Class | .is-open, .has-error | Purely visual states |
data-* attribute | [data-state="loading"] | States with more than two values |
| ARIA attribute | [aria-expanded="true"], [aria-selected="true"] | States that must be announced by screen readers |
| Native attribute or pseudo-class | [hidden], :disabled, :checked, :invalid, [open] | States the browser already understands |
When an accessible attribute exists, styling it directly ([aria-expanded="true"] + .panel-body) avoids keeping a class and an attribute in sync.
Exclusive states: one active item. For tabs and similar widgets, remove the class from the previous item and add it to the new one:
function activateTab(tabs, index) {
tabs.forEach((tab, i) => {
const selected = i === index;
tab.classList.toggle("active", selected);
tab.setAttribute("aria-selected", selected);
});
}
Restarting a CSS animation. Removing and immediately re-adding a class does nothing, because the browser never sees the intermediate state (both changes happen in the same task). You need to force a style flush between them, or wait a frame:
function shake(el) {
el.classList.remove("shake");
void el.offsetWidth; // forces style and layout: the removal is applied
el.classList.add("shake"); // the animation starts again
}
This is one of the few legitimate uses of a forced layout. The Web Animations API (last topic in this subchapter) offers a cleaner alternative.
Key Takeaways
classListprovidesadd,remove,toggle,containsandreplace, and is safer than editingclassNameas a string.toggle(name, condition)binds a class to a boolean in one call and returns the resulting state.- Express discrete UI states as classes,
data-*or ARIA attributes, and keep their styling in CSS. - Prefer ARIA or native attributes when the state is meaningful to assistive technology.
- Removing and re-adding a class in the same task has no visible effect unless a style flush happens in between.
Practice
- (Beginner) Implement a dark-mode toggle button that toggles a
darkclass on<html>, with all colour changes defined in CSS.- (Intermediate) Build an accessible tab component with three tabs and panels. Clicking a tab activates it and its panel using
classList.togglewith a condition and updatesaria-selected. The left and right arrow keys move between tabs.- (Interview) "Why is toggling a class usually better than setting several style properties from JavaScript?" Hint: consider separation of concerns, the cascade, media queries, transitions and the number of style invalidations.
getComputedStyle and Resolved Values
element.style only reflects inline declarations, but the final appearance of an element is the result of the whole cascade: user-agent styles, every matching rule, inheritance, media queries and inline styles. When JavaScript needs to know the value that actually applies (what font size an element ended up with, whether it is currently display: none, how wide its padding is) it asks the browser with getComputedStyle.
Basic usage.
const card = document.querySelector(".card");
const styles = getComputedStyle(card); // read-only, live CSSStyleDeclaration
styles.display; // "flex"
styles.fontSize; // "16px": always absolute, even if CSS says 1rem
styles.color; // "rgb(0, 0, 128)": even if CSS says "navy"
styles.getPropertyValue("padding-left"); // "16px": kebab-case form
styles.getPropertyValue("--accent"); // custom properties work too
// Pseudo-elements: pass the pseudo-element as the second argument
getComputedStyle(card, "::before").content; // '"New"'
The returned object is live: if the element's styles change later, reading a property again returns the new value. It is also read-only, and assigning to it throws an error.
Which value do you get? Section 2.2 described the stages a value passes through: declared, cascaded, specified, computed, used and actual. getComputedStyle returns what the CSSOM specification calls the resolved value. For most properties, this is the computed value. For properties whose value depends on layout (such as width, height, top, margin and padding), it is the used value, in pixels, after layout.
| CSS declaration | getComputedStyle returns | Why |
|---|---|---|
color: navy | "rgb(0, 0, 128)" | Colours are normalised to rgb() / rgba() |
font-size: 1.25rem | "20px" | Relative lengths resolved to pixels |
width: 50% (in a 600px parent) | "300px" | Used value after layout |
width: auto | "412.5px" | Used value (may be fractional) |
line-height: 1.5 | "24px" or "normal" | Depends on the property and browser |
No declaration for margin-top on a <div> | "0px" | Initial value |
display: none on an ancestor | width returns "auto" | Elements not rendered have no layout |
Common uses.
// 1. Is the element actually visible? (a class or media query may hide it)
function isDisplayed(el) {
return getComputedStyle(el).display !== "none" && el.getClientRects().length > 0;
}
// Modern browsers also offer el.checkVisibility(), which checks display, ancestors and more.
// 2. Read a duration defined in CSS to synchronise JavaScript with a transition
const ms = parseFloat(getComputedStyle(toast).transitionDuration) * 1000; // "0.3s" -> 300
// 3. Read numeric values: parseFloat strips the unit
const paddingX =
parseFloat(getComputedStyle(box).paddingLeft) +
parseFloat(getComputedStyle(box).paddingRight);
// 4. Determine the effective background colour for contrast checks
const bg = getComputedStyle(document.body).backgroundColor; // "rgb(255, 255, 255)"
Shorthand properties. Read longhand properties (paddingLeft, borderTopWidth, marginBlockStart). Whether a shorthand such as border or background returns a combined string differs between browsers and properties, and some return an empty string.
Performance. getComputedStyle is a read from the rendering engine. If styles are dirty, it forces a style recalculation, and if you read a layout-dependent property, a layout too. This makes it one of the triggers of forced synchronous layout described in 6.1. Read once and cache the values, or read before you write.
Typed values: the CSS Typed OM. Parsing strings like "20px" is clumsy. The CSS Typed Object Model offers element.computedStyleMap(), which returns typed objects:
const map = card.computedStyleMap();
const size = map.get("font-size"); // CSSUnitValue { value: 20, unit: "px" }
size.value + 4; // 24: no parseFloat needed
Typed OM is supported in Chromium-based browsers and Safari but not everywhere, so feature-detect it ("computedStyleMap" in Element.prototype) and fall back to getComputedStyle.
Key Takeaways
getComputedStyle(el, pseudo?)returns the resolved style after the whole cascade, as a live, read-only object.- Values are normalised: lengths in pixels, colours in
rgb(), layout-dependent properties as used values. - Read longhand properties, and use
parseFloatto extract numbers from strings with units. - Pass
"::before"or"::after"as the second argument to read pseudo-element styles. - It can force style recalculation and layout, so avoid calling it repeatedly between writes.
Practice
- (Beginner) Give an element
font-size: 1.5eminside a parent withfont-size: 20px. Logel.style.fontSizeandgetComputedStyle(el).fontSizeand explain both results.- (Intermediate) Write a function
waitForTransition(el)that readstransition-durationandtransition-delayfrom the computed style (they may contain several comma-separated values) and returns a promise that resolves after the longest total. Compare with listening for thetransitionendevent.- (Interview) "What is the difference between a computed value and a used value, and which one does
getComputedStylereturn forwidth?" Hint: think about what a percentage width depends on, and when that information becomes available.
Manipulating CSS Variables from JavaScript
CSS custom properties (section 2.3) are variables that live in the cascade: they inherit, can be overridden per element, and change with media queries. That makes them the best bridge between JavaScript and CSS. Instead of JavaScript writing final property values (transform, background, box-shadow), it writes a few input values, and the stylesheet decides how to use them. Think of JavaScript as turning dials on a control panel that the designer built.
Writing and reading custom properties. Custom properties are not exposed as camelCase properties of style. Use setProperty, getPropertyValue and removeProperty with the full name including the two dashes:
const root = document.documentElement; // <html>: variables set here are global
root.style.setProperty("--accent", "#e4572e"); // inline style on <html>
root.style.getPropertyValue("--accent"); // "#e4572e" (inline only)
// The value that actually applies (from any stylesheet or ancestor):
getComputedStyle(root).getPropertyValue("--accent").trim(); // "#e4572e"
root.style.removeProperty("--accent"); // back to the stylesheet value
Custom property values are stored as strings. Older browsers kept the leading whitespace from the declaration (--gap: 8px read back as " 8px"), so .trim() is a cheap safety measure.
Scope follows the cascade. A variable set on an element is visible to it and all its descendants. Set global values on :root (document.documentElement) and local values on a component:
:root { --accent: royalblue; }
.card { border-top: 4px solid var(--accent); }
.button { background: var(--accent); }
// Change one card only; the rest of the page keeps the global value
document.querySelector(".card.promo").style.setProperty("--accent", "crimson");
Pattern: JavaScript provides numbers, CSS provides units and formulas.
.spotlight {
/* JavaScript sets --x and --y as unitless pointer coordinates (0-1) */
background: radial-gradient(
circle at calc(var(--x, 0.5) * 100%) calc(var(--y, 0.5) * 100%),
rgb(255 255 255 / 0.25),
transparent 40%
);
}
.parallax-layer {
/* --depth is set per layer in HTML; --scroll is set by JavaScript */
transform: translateY(calc(var(--scroll, 0) * var(--depth) * -1px));
}
const spotlight = document.querySelector(".spotlight");
spotlight.addEventListener("pointermove", (e) => {
const rect = spotlight.getBoundingClientRect();
spotlight.style.setProperty("--x", (e.clientX - rect.left) / rect.width);
spotlight.style.setProperty("--y", (e.clientY - rect.top) / rect.height);
});
window.addEventListener("scroll", () => {
document.documentElement.style.setProperty("--scroll", window.scrollY);
}, { passive: true });
The script contains no visual decisions. Changing the effect (a stronger gradient, a different parallax direction) only requires editing CSS.
Pattern: user-selected themes. Store the user's choices as variables and persist them (6.4 covers localStorage):
const themeInput = document.querySelector("#accent-picker"); // <input type="color">
themeInput.addEventListener("input", () => {
document.documentElement.style.setProperty("--accent", themeInput.value);
localStorage.setItem("accent", themeInput.value);
});
const saved = localStorage.getItem("accent");
if (saved) document.documentElement.style.setProperty("--accent", saved);
Animating variables: @property. By default, the browser does not know what type a custom property holds, so it cannot interpolate between two values and transitions on it jump instantly. Registering the property with a type makes it animatable. You can register it in CSS (@property) or from JavaScript:
CSS.registerProperty({
name: "--progress",
syntax: "<percentage>", // a typed value: now the browser can interpolate it
inherits: false,
initialValue: "0%",
});
.ring {
transition: --progress 600ms ease-out; /* now actually animates */
background: conic-gradient(seagreen var(--progress), #ddd 0);
}
ring.style.setProperty("--progress", "72%"); // smoothly animates the conic gradient
Performance. Changing a variable invalidates the style of every element that inherits it and uses it. Setting --scroll on <html> on every scroll event forces style recalculation across the whole page if many elements reference it. Set frequently changing variables on the smallest element that needs them, and register non-inherited properties with inherits: false when descendants do not need them.
Key Takeaways
- Set custom properties with
style.setProperty("--name", value). Read the effective value withgetComputedStyle(el).getPropertyValue("--name"). - Variables inherit, so set global values on
document.documentElementand local values on components. - Let JavaScript provide raw numbers and let CSS apply units and formulas with
calc(), keeping visual logic in stylesheets. - Register a typed property with
CSS.registerPropertyor@propertyto make it animatable. - Frequently changing variables should be set on the smallest possible element to limit style recalculation.
Practice
- (Beginner) Create a page with
--accentdefined on:rootand used by buttons, links and borders. Add an<input type="color">that changes it live, and restore the saved value on reload.- (Intermediate) Build a circular progress indicator using a registered
--progressproperty andconic-gradient. Animate it from 0% to a value read from adata-valueattribute when it scrolls into view.- (Interview) "What are the advantages of setting a CSS custom property from JavaScript instead of setting
transformdirectly?" Hint: consider who owns the visual logic, how media queries and themes can reuse the value, and how many elements one write can affect.
The CSSOM and Stylesheet Objects
Just as HTML is parsed into the DOM, CSS is parsed into the CSSOM (CSS Object Model): a tree of objects representing stylesheets and their rules. The previous topics touched individual elements. The CSSOM lets JavaScript work at the level of rules: list them, inspect them, add new ones, disable entire stylesheets. Most applications rarely need this, but it is how CSS-in-JS libraries, theme engines, DevTools and Web Components manage styles.
The structure.
document.styleSheets StyleSheetList (one entry per <link rel=stylesheet> and <style>)
└── CSSStyleSheet href, disabled, media, ownerNode
└── cssRules CSSRuleList
├── CSSStyleRule selectorText ".card", style (CSSStyleDeclaration)
├── CSSMediaRule media "(min-width: 768px)", cssRules (nested rules)
├── CSSKeyframesRule name "fade", cssRules (CSSKeyframeRule)
├── CSSImportRule href, styleSheet
└── CSSFontFaceRule, CSSLayerBlockRule, CSSContainerRule, ...
Inspecting rules.
for (const sheet of document.styleSheets) {
console.log(sheet.href ?? "(inline <style>)", sheet.ownerNode);
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSStyleRule) {
console.log(rule.selectorText, rule.style.cssText);
} else if (rule instanceof CSSMediaRule) {
console.log("@media", rule.conditionText, rule.cssRules.length, "rules");
}
}
} catch (err) {
// Cross-origin stylesheets (e.g. from a CDN without CORS) cannot be read
console.warn("Cannot read rules of", sheet.href, err.name); // "SecurityError"
}
}
Reading cssRules from a stylesheet loaded from another origin throws a SecurityError unless the <link> has crossorigin and the server sends the right CORS headers (5.4). This prevents a page from reading private stylesheets of other sites.
Modifying rules. Changing a rule affects every element that matches its selector, now and in the future, which an inline style cannot do:
const sheet = document.querySelector("#theme").sheet; // the CSSStyleSheet of a <style id="theme">
// Insert a new rule at the end; returns its index
const index = sheet.insertRule(".highlight { outline: 2px solid gold; }", sheet.cssRules.length);
// Modify an existing rule's declarations
const cardRule = [...sheet.cssRules].find((r) => r.selectorText === ".card");
cardRule.style.setProperty("border-radius", "12px");
// Delete a rule by index
sheet.deleteRule(index);
// Disable or enable a whole stylesheet
sheet.disabled = true;
insertRule throws a SyntaxError for invalid CSS. It inserts only one rule per call.
Constructable stylesheets. Creating a <style> element just to get a stylesheet is clumsy. Constructable stylesheets are created directly in JavaScript and attached with adoptedStyleSheets:
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
.toast { position: fixed; bottom: 1rem; right: 1rem; padding: .75rem 1rem; }
.toast.error { background: crimson; color: white; }
`);
// replace() is the asynchronous version and returns a promise
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
// Updating the sheet later updates every document or shadow root that adopted it
sheet.replaceSync(".toast { bottom: 2rem; }");
Their main benefit appears with Web Components. One stylesheet object can be shared by hundreds of shadow roots without being parsed or stored again for each one:
const buttonStyles = new CSSStyleSheet();
buttonStyles.replaceSync("button { font: inherit; padding: .5em 1em; }");
class FancyButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.adoptedStyleSheets = [buttonStyles]; // shared, parsed once
shadow.innerHTML = "<button><slot></slot></button>";
}
}
customElements.define("fancy-button", FancyButton);
When to use which.
| Need | Tool |
|---|---|
| Change one element | element.style or classList |
| Change every element matching a selector, including future ones | Modify or insert a CSSOM rule, or (simpler) a custom property on :root |
| Load or swap a theme file | Add or remove a <link rel="stylesheet">, or toggle sheet.disabled |
| Styles generated at runtime by a library | Constructable stylesheets |
| Encapsulated component styles | adoptedStyleSheets on a shadow root |
Key Takeaways
- The CSSOM represents stylesheets as objects:
document.styleSheetscontainsCSSStyleSheetobjects whosecssRuleshold typed rule objects. insertRule,deleteRuleand rulestyleobjects change styles for every matching element at once.- Cross-origin stylesheets cannot be read without CORS and throw a
SecurityError. - Constructable stylesheets (
new CSSStyleSheet(),replaceSync,adoptedStyleSheets) create and share styles without<style>elements, which is ideal for Web Components. - For most dynamic styling, classes and custom properties are simpler than editing rules.
Practice
- (Beginner) List every rule in the page's stylesheets, printing its type and selector, and handle cross-origin sheets gracefully. Test with a Google Fonts stylesheet on the page.
- (Intermediate) Implement a "high contrast" switch that creates a constructable stylesheet once and adds or removes it from
document.adoptedStyleSheetswithout creating it again.- (Interview) "How would a CSS-in-JS library inject styles generated at runtime, and why might it use
insertRuleinstead of appending text to a<style>element?" Hint: think about parsing cost, how many rules are re-parsed on each change, and what DevTools can display.
Element Geometry, Box Metrics and Scrolling
Positioning a tooltip next to a button, detecting whether the user scrolled to the bottom of a list, or centring a modal all require knowing where elements are and how big they are. The DOM exposes several families of geometry properties, each measuring a different box from a different reference point. Mixing them up is the source of most "off by a few pixels" bugs, so this topic maps each property to the box it measures.
The boxes of an element. Recall the box model from Chapter 2. Assume an element with width: 200px; padding: 10px; border: 5px; overflow: auto, whose content is 600px tall, and a 15px vertical scrollbar:
offsetLeft/offsetTop: position of the border box relative to offsetParent
|
v
+---------------------------------------------+ <- border box
| border (clientLeft/clientTop = 5) | offsetWidth = 5+10+200+10+5 = 230
| +-------------------------------------+---+ | (includes scrollbar)
| | padding | s | |
| | +-----------------------------+ | c | | <- padding box minus scrollbar
| | | content | | r | | clientWidth = 10+185+10 = 205
| | | | | o | | (200 - 15 scrollbar = 185 content)
| | | scrollTop = how far the | | l | |
| | | content is scrolled | | l | |
| | +-----------------------------+ | | |
| +-------------------------------------+---+ |
+---------------------------------------------+
scrollHeight = full height of content + padding, including the hidden overflow
The properties.
| Property | Measures | Notes |
|---|---|---|
offsetWidth, offsetHeight | Border box (content + padding + border + scrollbar) | Integers (rounded) |
offsetTop, offsetLeft | Position relative to offsetParent (nearest positioned ancestor, or <body>) | Integers |
clientWidth, clientHeight | Padding box minus scrollbar: the visible inner area | Integers, 0 for inline elements |
clientTop, clientLeft | Width of the top and left borders | Integers |
scrollWidth, scrollHeight | Full content size including overflow that is scrolled out of view | Integers |
scrollTop, scrollLeft | How far the content is scrolled | Writable, may be fractional |
getBoundingClientRect() | Border box relative to the viewport, after CSS transforms | Fractional DOMRect |
getBoundingClientRect(): the precise, modern choice. It returns a DOMRect with x, y, width, height, top, right, bottom and left, in fractional CSS pixels, relative to the viewport. It accounts for transforms (a scaled element reports its visual size), which the offset* properties do not.
const rect = button.getBoundingClientRect();
// DOMRect { x: 120.5, y: 310, width: 96, height: 40, top: 310, right: 216.5, bottom: 350, left: 120.5 }
// Viewport coordinates change as the page scrolls. For document coordinates, add the scroll offset:
const pageTop = rect.top + window.scrollY;
const pageLeft = rect.left + window.scrollX;
// Is the element fully visible in the viewport right now?
const fullyVisible =
rect.top >= 0 && rect.left >= 0 &&
rect.bottom <= window.innerHeight && rect.right <= window.innerWidth;
For continuous visibility tracking, use IntersectionObserver (6.4) instead of calling this on every scroll event.
Example: position a tooltip below a button.
function showTooltip(anchor, tooltip) {
const a = anchor.getBoundingClientRect(); // READ phase
const tipWidth = tooltip.offsetWidth;
let left = a.left + a.width / 2 - tipWidth / 2; // centred under the anchor
left = Math.max(8, Math.min(left, window.innerWidth - tipWidth - 8)); // keep on screen
tooltip.style.position = "fixed"; // WRITE phase: fixed = viewport coordinates
tooltip.style.top = `${a.bottom + 8}px`;
tooltip.style.left = `${left}px`;
}
Using position: fixed with getBoundingClientRect values keeps everything in the same coordinate system (the viewport). For simple anchoring, CSS anchor positioning increasingly removes the need for this code.
Viewport and document dimensions.
window.innerWidth; // viewport width including the vertical scrollbar
document.documentElement.clientWidth; // viewport width excluding the scrollbar (usable layout width)
document.documentElement.scrollHeight; // total document height
window.scrollY; // vertical scroll of the page (alias: pageYOffset)
window.visualViewport?.height; // visible area on mobile, shrinks with on-screen keyboard or pinch zoom
Scrolling programmatically.
window.scrollTo({ top: 0, behavior: "smooth" }); // back to top
window.scrollBy({ top: window.innerHeight * 0.8 }); // one "page" down
list.scrollTop = list.scrollHeight; // jump a scroll container to the bottom (chat)
section.scrollIntoView({ behavior: "smooth", block: "start" }); // bring an element into view
field.scrollIntoView({ block: "center" }); // centre it vertically
// Has the user scrolled to the bottom of a container? (with a 1px tolerance for rounding)
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 1;
Respect user preferences: CSS scroll-behavior: smooth combined with @media (prefers-reduced-motion: reduce) lets users who dislike motion opt out of smooth scrolling. Fixed headers can cover elements scrolled into view. Fix this in CSS with scroll-margin-top on the targets, not by subtracting pixels in JavaScript.
Remember: geometry reads force layout. Every property in this topic is a layout read. Use the read-then-write discipline from 6.1, and avoid reading geometry inside scroll or pointermove handlers more often than once per frame.
Key Takeaways
offset*measures the border box relative to theoffsetParent,client*measures the visible inner area without borders and scrollbars, andscroll*measures the full scrollable content.getBoundingClientRect()returns fractional, viewport-relative coordinates that include transforms. AddscrollXandscrollYto get document coordinates.document.documentElement.clientWidthexcludes the scrollbar, whilewindow.innerWidthincludes it.- Scroll with
scrollTo,scrollBy,scrollTopandscrollIntoView, and handle fixed headers withscroll-margin-top. - All geometry reads can force a synchronous layout.
Practice
- (Beginner) Create a scrollable
<div>with a border, padding and long content. Log alloffset*,client*andscroll*values and verify each one against the box-model diagram.- (Intermediate) Implement a "scroll to top" button that appears after the user scrolls down 1.5 viewport heights, and a reading-progress bar at the top of the page computed from
scrollY,scrollHeightandclientHeight. Update the bar at most once per animation frame.- (Interview) "What is the difference between
offsetWidth,clientWidth,scrollWidthandgetBoundingClientRect().width?" Hint: draw the box model, mark which parts each one includes, and consider what happens with atransform: scale(2).
The Web Animations API
There are three ways to animate in the browser. CSS transitions and keyframe animations (Chapter 2) are declarative and efficient but hard to control from code: you cannot easily pause one, reverse it from its current point, or know when it finishes. requestAnimationFrame (5.1) gives total control but makes you compute every frame yourself, on the main thread. The Web Animations API (WAAPI) combines the two. You describe keyframes in JavaScript, the browser runs the animation with the same engine as CSS animations (on the compositor for transform and opacity), and you get an Animation object with playback controls and promises.
element.animate(). It takes keyframes and timing options and returns an Animation that starts immediately:
const box = document.querySelector(".box");
const animation = box.animate(
[
{ transform: "translateX(0)", opacity: 1 }, // keyframe at offset 0
{ transform: "translateX(200px)", opacity: 0.5, offset: 0.7 }, // optional explicit offset
{ transform: "translateX(300px)", opacity: 1 }, // keyframe at offset 1
],
{
duration: 800, // milliseconds (CSS uses seconds; WAAPI uses ms)
easing: "ease-in-out", // any CSS easing, including cubic-bezier() and linear()
delay: 100,
iterations: 2, // Infinity for endless
direction: "alternate", // normal, reverse, alternate, alternate-reverse
fill: "forwards", // keep the final keyframe's styles after finishing
}
);
Keyframes can also be written as an object of arrays, which is convenient for simple two-state animations:
box.animate({ opacity: [0, 1], transform: ["scale(0.9)", "scale(1)"] }, 200); // number = duration
Controlling playback.
animation.pause();
animation.play();
animation.reverse(); // plays backwards from the current point
animation.playbackRate = 2; // double speed (negative values play backwards)
animation.currentTime = 400; // seek to 400 ms (scrubbing)
animation.finish(); // jump to the end
animation.cancel(); // remove all effects, back to the original styles
animation.playState; // "idle", "running", "paused" or "finished"
Knowing when it ends. animation.finished is a promise (5.2) that resolves when the animation completes, which makes sequencing straightforward with async/await:
async function showToast(toast) {
toast.hidden = false;
await toast.animate(
[{ transform: "translateY(100%)" }, { transform: "translateY(0)" }],
{ duration: 250, easing: "ease-out" }
).finished;
await new Promise((r) => setTimeout(r, 3000)); // stay visible for 3 seconds
await toast.animate({ opacity: [1, 0] }, 200).finished;
toast.hidden = true;
}
The promise is rejected with an AbortError if the animation is cancelled, so wrap awaits in try/catch when cancellation is possible.
fill: "forwards" and commitStyles(). After an animation finishes, its effect disappears and the element snaps back to its CSS styles. fill: "forwards" keeps the final state, but the animation object stays alive and overrides the element's styles indefinitely, which also blocks later changes. A cleaner approach is to write the final state into the element and cancel the animation:
const anim = panel.animate({ transform: ["none", "translateX(-100%)"] }, { duration: 300, fill: "forwards" });
await anim.finished;
anim.commitStyles(); // writes the current animated values into panel.style
anim.cancel(); // releases the animation
Interrupting smoothly. Because WAAPI knows the current progress, you can reverse an animation that is halfway through, for example a menu that the user closes while it is opening:
let menuAnim = null;
function toggleMenu(open) {
if (menuAnim && menuAnim.playState === "running") {
menuAnim.reverse(); // turn around from the current point
return;
}
menuAnim = menu.animate(
{ clipPath: ["inset(0 0 100% 0)", "inset(0 0 0 0)"] },
{ duration: 300, easing: "ease-out", fill: "both", direction: open ? "normal" : "reverse" }
);
}
Controlling CSS animations from JavaScript. CSS transitions and animations are also exposed as Animation objects (CSSTransition, CSSAnimation):
for (const anim of document.getAnimations()) { // every running animation on the page
anim.playbackRate = 0.1; // slow everything down for debugging
}
spinner.getAnimations()[0]?.pause(); // pause a CSS @keyframes spinner
await Promise.all(card.getAnimations().map((a) => a.finished)); // wait for CSS transitions
Respecting reduced motion. Some users get motion sickness from animations. Check the preference before animating large movements:
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
el.animate(keyframes, { duration: reduceMotion ? 0 : 400 });
Choosing an animation technique.
| Technique | Best for | Control from JS | Runs on compositor |
|---|---|---|---|
| CSS transition | State changes (hover, open/closed) triggered by a class | Limited (events only) | Yes, for transform and opacity |
CSS @keyframes | Decorative, looping or entrance animations | Limited | Yes, for transform and opacity |
| Web Animations API | Interactive, interruptible, sequenced or data-driven animations | Full (pause, seek, reverse, promises) | Yes, for transform and opacity |
requestAnimationFrame | Physics, canvas drawing, values CSS cannot express | Full, but manual | No (main thread) |
Newer extensions let WAAPI animations be driven by scroll position instead of time (ScrollTimeline and ViewTimeline, the JavaScript side of CSS scroll-driven animations). Check browser support before using them.
Key Takeaways
element.animate(keyframes, options)runs a keyframe animation and returns anAnimationobject. Durations are in milliseconds.- The
Animationobject supportsplay,pause,reverse,playbackRate,currentTime,finishandcancel. animation.finishedis a promise, ideal for sequencing withawait.- Prefer
commitStyles()pluscancel()over leavingfill: "forwards"animations alive. getAnimations()exposes CSS transitions and animations as the same objects, so they can be controlled from script.- Animate
transformandopacityfor compositor performance, and honourprefers-reduced-motion.
Practice
- (Beginner) Animate a card that fades and slides in from below when a button is clicked, using
element.animate. Log a message when the animation finishes, using thefinishedpromise.- (Intermediate) Build an image carousel where clicking "Next" while a slide animation is running reverses or finishes the current animation cleanly instead of stacking animations. Add a range input that scrubs a long animation by setting
currentTime.- (Interview) "When would you choose the Web Animations API over a CSS transition, and when over
requestAnimationFrame?" Hint: compare control over playback, who computes intermediate frames, and which thread does the work.
<a id="63-the-event-system"></a>
6.3 The Event System
This subchapter explains how the browser notifies your code about user actions and state changes: how to register handlers, what an event object contains, how events travel through the tree, how to cancel default behaviour, and how to design with delegation and custom events.
Event-Driven Programming Model
A command-line program usually runs from top to bottom: read input, compute, print, exit. A web page cannot work like that. It has no idea when, or whether, the user will click, type, scroll or resize the window, and it must also react to network responses and timers. So browser code is organised the other way around: you register functions that say "when this happens to that element, call me", and then your script ends. The browser waits for things to happen and calls your functions. This is event-driven programming, and it is an instance of inversion of control (5.2): the browser, not your code, decides when your code runs.
An analogy is a hotel front desk with a guest book of requests: "wake me at 7", "tell me when my taxi arrives". The guests (handlers) do not stand in the lobby waiting. The receptionist (the browser) watches for each condition and phones the right guest when it occurs.
Vocabulary.
| Term | Meaning | Example |
|---|---|---|
| Event | A notification that something happened | A click, a key press, a form submission |
| Event type | The name of the kind of event | "click", "keydown", "submit" |
| Event target | The object on which the event occurred | The <button> that was clicked |
| Event listener (handler) | The function called when the event occurs | function onSave(event) { ... } |
| Event object | Information about the occurrence, passed to the listener | Mouse position, key pressed |
| Default action | What the browser does by itself in response | Following a link, submitting a form |
How events fit into the event loop. When the user clicks, the operating system notifies the browser, which works out which element is under the pointer and queues a task (5.1). When the call stack is empty, the event loop runs the task, and the browser calls every relevant listener synchronously, one after another. Each listener runs to completion, and nothing else happens (not even rendering) until all of them have returned.
user clicks ──> browser hit-tests ──> task queued ──> event loop picks task
(which element?) │
v
listener 1 ──> listener 2 ──> ... ──> microtasks ──> render
The practical consequence: a slow listener freezes the page. Keep handlers short and move heavy work elsewhere (5.1 and Web Workers in 6.4).
Three ways to register a handler. The web has accumulated three mechanisms:
<!-- 1. HTML attribute (inline handler): code in a string, runs in global scope -->
<button onclick="save()">Save</button>
// 2. DOM property: exactly one handler per event type per element
const button = document.querySelector("#save");
button.onclick = function (event) { save(); };
button.onclick = function (event) { log(); }; // REPLACES the previous handler
button.onclick = null; // removes it
// 3. addEventListener: any number of listeners, with options (next topic)
button.addEventListener("click", save);
button.addEventListener("click", log); // both run, in registration order
| Mechanism | Multiple handlers | Options (capture, once, passive) | Separation of HTML and JS | Works under strict CSP |
|---|---|---|---|---|
onclick="..." attribute | No | No | No | No (needs 'unsafe-inline') |
el.onclick = fn | No (last one wins) | No | Yes | Yes |
el.addEventListener("click", fn) | Yes | Yes | Yes | Yes |
Inline attributes mix behaviour into markup, execute strings of code with surprising scope rules and are blocked by a strict Content Security Policy. addEventListener is the standard choice. DOM properties remain acceptable for quick scripts where a single handler is intended.
Not only elements. Any EventTarget can emit events: window (resize, scroll, online), document (DOMContentLoaded, visibilitychange), XMLHttpRequest, WebSocket, AbortSignal, IndexedDB requests, Workers and even your own objects (see Custom Events and Event Dispatching). Everything in this subchapter applies to all of them.
Key Takeaways
- Browser code is event-driven: you register listeners, and the browser calls them when events occur.
- Events are delivered as tasks. All listeners for one event run synchronously, to completion, before rendering.
- Long-running listeners block input and rendering, so keep them short.
- Prefer
addEventListener.on*properties allow one handler per type, and inlineon*attributes should be avoided. - Every
EventTarget(window, document, elements, network objects, your own classes) uses the same event model.
Practice
- (Beginner) Register two click handlers on one button with
onclick, then two withaddEventListener. Log which ones run and explain the difference.- (Intermediate) Add a click handler that runs a loop for two seconds, and next to the button add a CSS
:hovereffect and a text input. Click the button and try to type and hover during the two seconds. Describe what you observe in terms of the event loop.- (Interview) "Why are inline event handler attributes such as
onclick=\"...\"considered bad practice?" Hint: consider maintainability, the number of handlers allowed, scope, and Content Security Policy.
Event Registration with addEventListener
addEventListener is the core method of the event system. It looks simple, but its options and the rules for removing listeners determine whether an application stays fast and leak-free.
Signature.
target.addEventListener(type, listener, options);
target.removeEventListener(type, listener, options);
type: the event name, without theonprefix ("click", not"onclick"). Case-sensitive.listener: a function, or an object with ahandleEventmethod.options: an object (or, in old code, a boolean meaningcapture).
| Option | Default | Effect |
|---|---|---|
capture | false | Run during the capture phase instead of the bubble phase (see Capturing, Targeting and Bubbling Phases) |
once | false | Remove the listener automatically after its first call |
passive | false* | Promise not to call preventDefault(), allowing the browser to scroll without waiting |
signal | none | An AbortSignal: when it aborts, the listener is removed |
* Chromium-based browsers and Firefox default passive to true for touchstart, touchmove, wheel and mousewheel listeners registered on window, document or <body>.
const button = document.querySelector("#buy");
button.addEventListener("click", (event) => {
console.log("clicked", event.target);
});
button.addEventListener("click", startTutorial, { once: true }); // only the first click
window.addEventListener("scroll", updateProgressBar, { passive: true });
Duplicate registrations are ignored. Adding the same function with the same type and capture value twice registers it only once. Two different functions with identical code are two listeners, which is why anonymous functions are a problem when you need to remove them.
Removing listeners. removeEventListener must receive the same function reference and the same capture value used when adding:
// BUG: two different function objects; nothing is removed
el.addEventListener("click", () => console.log("hi"));
el.removeEventListener("click", () => console.log("hi"));
// Correct: keep a reference
function onClick() { console.log("hi"); }
el.addEventListener("click", onClick);
el.removeEventListener("click", onClick);
// BUG: capture flag differs; nothing is removed
el.addEventListener("click", onClick, { capture: true });
el.removeEventListener("click", onClick); // needs { capture: true }
Removing many listeners at once with AbortController. The signal option (5.3 introduced AbortController) removes a whole group of listeners with one call, and works with anonymous functions:
function openModal(modal) {
const controller = new AbortController();
const { signal } = controller;
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") close();
}, { signal });
modal.addEventListener("click", (e) => {
if (e.target === modal) close(); // click on the backdrop
}, { signal });
window.addEventListener("resize", () => position(modal), { signal });
function close() {
modal.hidden = true;
controller.abort(); // removes all three listeners at once
}
}
This pattern is the cleanest way to avoid memory leaks from listeners on long-lived objects (window, document) that refer to short-lived components.
this inside listeners. For a regular function, this is the element the listener is attached to (event.currentTarget). Arrow functions do not have their own this, so they keep the surrounding one (3.3 and 4.3):
button.addEventListener("click", function () {
this.classList.toggle("active"); // this === button
});
button.addEventListener("click", () => {
console.log(this); // NOT the button: outer this (undefined in modules)
});
Relying on event.currentTarget instead of this works with both kinds of function and is clearer.
Methods as listeners. Passing an object's method loses its this (4.3). Bind it, wrap it, or use a class field arrow function:
class Counter {
count = 0;
constructor(button) {
button.addEventListener("click", this.increment); // this.increment keeps the instance
}
increment = () => { // class field arrow function
this.count++;
};
}
The handleEvent interface is another option: pass the object itself, and the browser calls its handleEvent method with this set to the object:
const tracker = {
clicks: 0,
handleEvent(event) {
if (event.type === "click") this.clicks++;
},
};
document.addEventListener("click", tracker);
document.removeEventListener("click", tracker); // same object reference
Passive listeners and scroll performance. When a touchmove or wheel listener might call preventDefault(), the browser must wait for it to finish before scrolling, because the listener could cancel the scroll. A slow listener then makes scrolling stutter. Declaring { passive: true } tells the browser it can scroll immediately. Calling preventDefault() inside a passive listener is ignored, with a console warning. To prevent scrolling on touch (for example in a drawing canvas), prefer the CSS property touch-action: none (see Mouse, Keyboard, Pointer and Touch Events).
Key Takeaways
addEventListener(type, listener, options)supportscapture,once,passiveandsignal.- The same function added twice with the same type and capture flag is registered only once.
removeEventListenerneeds the identical function reference and capture flag. Anonymous functions cannot be removed this way.- Use an
AbortControllersignal to remove groups of listeners with oneabort()call. - In regular function listeners
thisiscurrentTarget. Arrow functions inheritthis, so preferevent.currentTarget. - Mark scroll-related listeners as
passiveso they never delay scrolling.
Practice
- (Beginner) Create a "Start" button that logs a message only the first time it is clicked, using
{ once: true }, and a second button that removes a click listener from a third button.- (Intermediate) Write a
makeDraggable(el)function that addspointermoveandpointeruplisteners todocumentwhen dragging starts and removes them all with a singleAbortControllerwhen it ends. Verify in DevTools (Elements panel, Event Listeners tab) that no listeners remain after dragging.- (Interview) "Why does
el.removeEventListener('click', () => doSomething())not work, and what are the alternatives?" Hint: think about function identity, and about two mechanisms that do not require keeping a reference.
The Event Object and Its Properties
When an event occurs, the listener needs details: which element was clicked, where the pointer was, which key was pressed, whether Shift was held down. The browser packages these into an event object and passes it as the only argument to every listener. All event objects share a common base interface, Event, and specialised event types add the relevant information.
The base Event properties.
| Property | Meaning |
|---|---|
type | The event name, such as "click" |
target | The element where the event originated (the deepest element, such as a <span> inside a button) |
currentTarget | The element whose listener is running now (where you attached the listener) |
eventPhase | 1 capture, 2 at target, 3 bubble (0 when not being dispatched) |
bubbles | Whether the event bubbles up the tree |
cancelable | Whether preventDefault() can cancel its default action |
defaultPrevented | Whether some listener already called preventDefault() |
isTrusted | true if generated by the browser from a real user action, false if created by script |
timeStamp | Milliseconds since the page started loading (comparable with performance.now()) |
composedPath() | Array of every object the event will pass through, from target to window |
target versus currentTarget. This distinction is essential for event delegation (later topic):
<ul id="menu">
<li><a href="/shop"><span class="icon">*</span> Shop</a></li>
</ul>
document.getElementById("menu").addEventListener("click", (event) => {
console.log(event.target); // <span class="icon"> if the icon was clicked
console.log(event.currentTarget); // <ul id="menu">: where the listener is attached
console.log(event.target.closest("a")); // <a href="/shop">: the link, whatever part was clicked
});
currentTarget is only meaningful while the listener is running. Afterwards it is null, which surprises people who log the event and inspect it later or read it after an await:
button.addEventListener("click", async (event) => {
const el = event.currentTarget; // copy it first
await fetch("/api/like", { method: "POST" });
console.log(event.currentTarget); // null: dispatch is over
el.classList.add("liked"); // works
});
Specialised event interfaces. Each family of events has its own interface that extends Event (some through UIEvent):
Event
├── UIEvent
│ ├── MouseEvent clientX/Y, pageX/Y, button, buttons, altKey, ctrlKey, shiftKey, metaKey
│ │ ├── PointerEvent pointerId, pointerType, pressure, width, height, isPrimary
│ │ ├── WheelEvent deltaX, deltaY, deltaMode
│ │ └── DragEvent dataTransfer
│ ├── KeyboardEvent key, code, repeat, isComposing, modifier keys
│ ├── FocusEvent relatedTarget
│ ├── InputEvent data, inputType ("insertText", "deleteContentBackward", ...)
│ └── TouchEvent touches, targetTouches, changedTouches
├── SubmitEvent submitter (the button that submitted the form)
├── CustomEvent detail (your own data)
├── ProgressEvent loaded, total, lengthComputable
├── StorageEvent key, oldValue, newValue (6.4)
└── PopStateEvent state (6.4)
document.addEventListener("keydown", (e) => {
// e is a KeyboardEvent
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault(); // stop the browser's "Save page" dialog
saveDocument();
}
});
canvas.addEventListener("pointerdown", (e) => {
// e is a PointerEvent
console.log(e.pointerType, e.clientX, e.clientY, e.pressure); // "pen" 312 140 0.42
});
form.addEventListener("submit", (e) => {
// e is a SubmitEvent
console.log(e.submitter?.value); // which submit button was used ("draft" or "publish")
});
Exploring an event. The fastest way to learn an event's properties is to log it and expand it in the console, or to run monitorEvents(element, "click") in Chrome DevTools, which logs every event of that type on the element.
isTrusted and synthetic events. Events created by script (see Custom Events and Event Dispatching) have isTrusted === false. Browsers reserve some privileged actions, such as opening pop-ups, entering fullscreen or writing to the clipboard, for trusted events that come from a real user gesture (called user activation).
Key Takeaways
- Every listener receives an event object that describes the occurrence. Specialised interfaces (
MouseEvent,KeyboardEvent,PointerEvent,SubmitEvent...) add details. targetis where the event started, andcurrentTargetis the element whose listener is running.currentTargetisnullonce dispatch ends, so copy it before anyawait.bubbles,cancelable,defaultPreventedandeventPhasedescribe how the event is travelling and whether it can be cancelled.isTrusteddistinguishes real user events from script-created ones, and some powerful APIs require a real user gesture.
Practice
- (Beginner) Add one click listener to
documentthat logstype,target.tagName,currentTarget,eventPhase,clientXandclientY. Click several different elements and compare the output.- (Intermediate) Build a keyboard shortcut handler that supports combinations such as "Ctrl+Shift+K" written as strings in a configuration object, and ignores events that occur while the user is typing in an input, a textarea or a
contenteditableelement.- (Interview) "What is the difference between
event.targetandevent.currentTarget?" Hint: attach a listener to a list and click on a nested element inside one item.
Capturing, Targeting and Bubbling Phases
When you click a <span> inside a <button> inside a <form>, you have arguably clicked all of them. The DOM's answer is that the event travels through the tree, so listeners on the span, the button, the form, the body, the document and the window can all react. The order of that journey is defined precisely, in three phases. Understanding it explains how delegation works, why some listeners run before others, and what "stopping propagation" actually stops.
The three phases. The browser first computes the propagation path: the target and all its ancestors up to window. Then:
- Capture phase (
eventPhase === 1): the event travels down fromwindowto the target's parent, calling listeners registered withcapture: true. - Target phase (
eventPhase === 2): the event is at the target itself. Listeners on the target run, capturing ones first, then non-capturing ones. - Bubble phase (
eventPhase === 3): the event travels back up from the target's parent towindow, calling listeners registered withoutcapture(the default).
window
1 | ^ 3
v |
document
1 | ^ 3
v |
<html>
1 | ^ 3 Capture (1): top-down,
v | listeners with { capture: true }
<body>
1 | ^ 3 Bubble (3): bottom-up,
v | default listeners
<form>
1 | ^ 3
v |
<button>
1 | ^ 3
v |
+---------------------+
| <span> TARGET (2) | Target (2): capture listeners,
+---------------------+ then bubble listeners on the target
Seeing it in action.
<form id="f"><button id="b" type="button"><span id="s">Go</span></button></form>
const ids = ["f", "b", "s"];
for (const id of ids) {
const el = document.getElementById(id);
el.addEventListener("click", (e) => console.log("capture", id, e.eventPhase), { capture: true });
el.addEventListener("click", (e) => console.log("bubble ", id, e.eventPhase));
}
// Clicking the text "Go" logs:
// capture f 1
// capture b 1
// capture s 2 <- at the target
// bubble s 2 <- at the target
// bubble b 3
// bubble f 3
Why two directions? Bubbling is what you want almost all the time: the most specific element reacts first, and more general containers can react afterwards (or handle many children at once with delegation). Capturing is useful when an ancestor must see the event before any descendant can handle or stop it. Typical uses are global logic such as closing open menus on any click, analytics, or debugging tools.
// Close any open dropdown when the user clicks outside it.
// Capture phase: runs even if some inner component calls stopPropagation() during bubbling.
document.addEventListener("click", (e) => {
for (const dropdown of document.querySelectorAll(".dropdown.open")) {
if (!dropdown.contains(e.target)) dropdown.classList.remove("open");
}
}, { capture: true });
Not every event bubbles. Some events only make sense for the element itself, so they have bubbles: false. They still go through the capture phase and the target phase, so a capturing listener on an ancestor can still observe them.
| Does not bubble | Bubbling alternative |
|---|---|
focus, blur | focusin, focusout |
mouseenter, mouseleave | mouseover, mouseout |
pointerenter, pointerleave | pointerover, pointerout |
load, error on elements such as <img> | Listen in capture phase on an ancestor |
scroll on an element (the document's scroll does reach window) | Capture on document, or listen on the element |
invalid on form controls | Capture on the <form> |
// Catch every broken image on the page with one listener
document.addEventListener("error", (e) => {
if (e.target instanceof HTMLImageElement) {
e.target.src = "/img/placeholder.svg";
}
}, { capture: true }); // required: "error" does not bubble
Shadow DOM and composed. Events that cross a Web Component's shadow boundary are retargeted: listeners outside the component see the host element as target, hiding the internal structure. Custom events only cross the boundary if created with composed: true.
Key Takeaways
- Events travel in three phases: capture (window down to the target's parent), target, and bubble (back up to window).
- Listeners default to the bubble phase. Pass
{ capture: true }to run during capture. - At the target, capturing listeners run before non-capturing ones.
- Bubbling enables ancestors to handle events from many descendants. Capturing lets ancestors act before descendants.
- Some events do not bubble (
focus,blur,mouseenter, elementloadanderror). Use their bubbling variants or capture-phase listeners.
Practice
- (Beginner) Reproduce the form/button/span example and predict the log order before clicking. Then remove the capture listeners and predict again.
- (Intermediate) Build a nested comment thread where clicking a comment highlights it, but clicking a reply nested inside must highlight only the reply. Solve it once with
stopPropagationand once by checkingevent.target.closest(".comment") === event.currentTarget, then compare.- (Interview) "Describe the path of a click event on a deeply nested element. When would you use a capture-phase listener?" Hint: name the three phases and give an example where an ancestor must see the event before any child can stop it.
preventDefault and stopPropagation
Events do two independent things after your listeners run. The browser may perform a default action (a link navigates, a form submits, a checkbox toggles), and the event propagates to other elements. Two methods control these separately. They are often confused, and calling the wrong one, or both by habit, is a common source of bugs.
Default actions. Many events trigger built-in behaviour:
| Event | Default action |
|---|---|
click on <a href> | Navigate to the URL |
submit on <form> | Send the form and load the response page |
click on a checkbox or radio | Toggle its checked state |
keydown (Space, arrows, Page Down) | Scroll the page |
keydown in a text field | Insert the character |
wheel, touchmove | Scroll or zoom |
contextmenu | Open the browser context menu |
dragstart on an image or link | Start a native drag |
preventDefault() cancels the default action but does not stop the event travelling. Other listeners still run.
// Single-page application: handle navigation in JavaScript (6.4)
document.querySelector("nav").addEventListener("click", (event) => {
const link = event.target.closest("a[href^='/']");
if (!link) return;
event.preventDefault(); // do not load a new page
router.navigate(link.pathname);
});
// Submit a form with fetch instead of a page load (5.4)
form.addEventListener("submit", async (event) => {
event.preventDefault();
const response = await fetch(form.action, { method: "POST", body: new FormData(form) });
showResult(await response.json());
});
// Restrict a field to digits
digitsInput.addEventListener("beforeinput", (event) => {
if (event.data && /\D/.test(event.data)) event.preventDefault(); // block non-digit insertions
});
preventDefault() only works if event.cancelable is true, and it is ignored in passive listeners. Other listeners can detect that it was called through event.defaultPrevented, which is a cooperative way for components to say "I handled this".
return false. In an on* property or attribute handler, returning false is equivalent to preventDefault(). In addEventListener listeners, the return value is ignored. (In jQuery, Chapter 8, return false means both preventDefault() and stopPropagation(), which adds to the confusion.)
stopPropagation() stops the journey through the tree. Listeners on later elements in the path do not run. Listeners on the current element still run, and the default action still happens.
stopImmediatePropagation() also prevents the remaining listeners on the current element from running.
const card = document.querySelector(".card");
const like = card.querySelector(".like");
card.addEventListener("click", () => openDetails(card)); // clicking a card opens it
like.addEventListener("click", (event) => {
event.stopPropagation(); // the like button must not also open the card
toggleLike(card);
});
Summary of effects.
| Call | Default action | Other listeners on the same element | Listeners on later elements in the path |
|---|---|---|---|
| (nothing) | Happens | Run | Run |
preventDefault() | Cancelled | Run | Run |
stopPropagation() | Happens | Run | Skipped |
stopImmediatePropagation() | Happens | Skipped | Skipped |
Why to be careful with stopPropagation. Stopping propagation makes the event invisible to everything above, including code you do not know about: analytics, "click outside to close" logic, accessibility tools, other components using delegation. A dropdown that closes on outside clicks breaks as soon as any child stops propagation. Before using it, consider the alternatives:
// Alternative 1: the ancestor checks where the click came from
card.addEventListener("click", (event) => {
if (event.target.closest(".like, a, button")) return; // let interactive children handle it
openDetails(card);
});
// Alternative 2: the child marks the event as handled, the ancestor respects it
like.addEventListener("click", (event) => {
event.preventDefault(); // or set a flag on a WeakSet of handled events
toggleLike(card);
});
card.addEventListener("click", (event) => {
if (event.defaultPrevented) return;
openDetails(card);
});
Key Takeaways
preventDefault()cancels the browser's default action (navigation, submission, scrolling, toggling) but does not stop propagation.stopPropagation()stops the event from reaching further elements in the path, but listeners on the current element still run and the default action still happens.stopImmediatePropagation()also skips the remaining listeners on the current element.return falseonly cancels the default action inon*handlers. It does nothing inaddEventListenerlisteners.- Use
stopPropagationsparingly. Checkingevent.targetorevent.defaultPreventedkeeps events visible to other code.
Practice
- (Beginner) Create a link and a form. Prevent the link from navigating and log its
hrefinstead, and prevent the form from submitting and log its data withnew FormData(form).- (Intermediate) Build a card component with a "Delete" button inside it. Clicking the card selects it, and clicking "Delete" removes it without selecting it. Implement it without
stopPropagation, and add a document-level "click outside deselects" handler that keeps working.- (Interview) "What is the difference between
preventDefault,stopPropagationandreturn false?" Hint: separate "what the browser does" from "who else hears about the event", and remember that the answer forreturn falsedepends on how the handler was registered.
Event Delegation
Imagine a list of 5,000 table rows, each with "Edit" and "Delete" buttons, where rows are added and removed as the user works. Attaching two listeners to every button means 10,000 function registrations, extra memory, and code that must remember to attach listeners whenever a row is created and remove them when it is deleted. Event delegation solves this with one listener on a common ancestor, relying on bubbling: since clicks on any button bubble up to the table, the table can handle them all and work out which button was clicked.
An analogy is a company switchboard: instead of every employee having an outside line, all calls arrive at one number and the operator routes each one according to the extension requested.
The basic pattern.
- Attach one listener to a stable container that exists for the lifetime of the items.
- In the listener, find the relevant element with
event.target.closest(selector). - Ignore events that do not come from a matching element, or whose match is outside the container.
<table id="users">
<tbody>
<tr data-id="17">
<td>Ada Lovelace</td>
<td>
<button type="button" data-action="edit"><svg>...</svg> Edit</button>
<button type="button" data-action="delete">Delete</button>
</td>
</tr>
<!-- ...thousands more rows, added and removed dynamically... -->
</tbody>
</table>
const table = document.getElementById("users");
const actions = {
edit(row) { openEditor(row.dataset.id); },
delete(row) { if (confirm("Delete user?")) row.remove(); },
};
table.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]"); // the <svg> inside also works
if (!button || !table.contains(button)) return; // click was elsewhere
const row = button.closest("tr");
actions[button.dataset.action]?.(row); // dispatch by data-action
});
New rows work immediately, without any extra code, because the listener is on the table, not on the rows.
Why closest and not target. event.target is the innermost element clicked, such as an <svg> icon or a <span> inside a button. Checking event.target.matches("button") fails in those cases. closest walks up from the target to the nearest matching ancestor, so it handles any internal structure.
Why the contains check. closest can walk beyond the container. If the whole table were inside a <button> (an unusual case), or if the selector were generic ("div"), the match could be an ancestor of the container. Checking container.contains(match) makes the delegation robust.
Benefits.
| Aspect | One listener per item | Delegation |
|---|---|---|
| Listeners registered | One per item (thousands) | One |
| Items added later | Must attach listeners each time | Handled automatically |
| Items removed | Must remove listeners to avoid leaks | Nothing to do |
| Initial render cost | Higher | Lower |
| Code location | Spread across render code | Centralised |
Limitations and solutions.
- Non-bubbling events.
focus,blur,mouseenterandmouseleavedo not bubble. Delegate withfocusin,focusout,mouseoverandmouseout, or use a capture-phase listener. stopPropagationin children prevents the event from reaching the delegate. This is one more reason to avoid it.- Very frequent events such as
mousemoveandpointermovecan be delegated, but the listener runs for every movement over the whole container. Keep the matching logic cheap. - Over-delegation. Putting every handler for the whole application on
documentmakes the code hard to follow and runs every listener for every click. Delegate to the nearest stable container of the component.
Delegation with mouseover and mouseout. Hover effects with delegation need care, because mouseover fires again each time the pointer moves between children of the same item. Compare relatedTarget (the element the pointer came from or goes to) to detect real entry and exit:
list.addEventListener("mouseover", (e) => {
const item = e.target.closest("li");
if (!item || item.contains(e.relatedTarget)) return; // moving inside the same item
showPreview(item);
});
Delegation is also how frameworks handle events. React (Chapter 14), for example, attaches its listeners to the root container and dispatches internally to the right component's handler.
Key Takeaways
- Event delegation uses one listener on a container to handle events from many descendants, relying on bubbling.
- Use
event.target.closest(selector)to find the relevant element, andcontainer.contains(match)to confirm it belongs to the container. - Delegation automatically handles elements added later and needs no cleanup when they are removed.
- Use
data-action-style attributes to route events to handlers. - Non-bubbling events need their bubbling counterparts (
focusin,mouseover) or capture-phase listeners.
Practice
- (Beginner) Build a to-do list where each item has "Done" and "Remove" buttons, handled by a single listener on the
<ul>. Add new items dynamically and confirm the buttons work without new listeners.- (Intermediate) Implement a form-validation module that uses one
focusoutlistener on the<form>to validate whichever field lost focus, and oneinputlistener to clear the error message on the field being edited. Fields can be added dynamically.- (Interview) "What is event delegation, and what are its limitations?" Hint: explain which event-system feature makes it possible, then think about events that do not follow that feature and about code that stops it.
Mouse, Keyboard, Pointer and Touch Events
Input devices differ widely: a mouse has buttons and hovers, a keyboard produces keys and characters, a touchscreen has several fingers and no hover, and a pen has pressure and tilt. The web platform added an event family for each device over the years: mouse events in the 1990s, touch events with the iPhone, and pointer events as a unified model. Knowing which to use, and which coordinates and key properties to read, avoids the most common input bugs.
Mouse events.
| Event | Fires when | Bubbles |
|---|---|---|
mousedown, mouseup | A button is pressed or released | Yes |
click | Primary button pressed and released on the same element (also triggered by keyboard activation of buttons and links) | Yes |
dblclick | Two clicks in quick succession | Yes |
auxclick | Non-primary button click (middle button) | Yes |
contextmenu | Right click or context-menu key | Yes |
mousemove | Pointer moves over the element | Yes |
mouseover, mouseout | Pointer enters or leaves the element or any child | Yes |
mouseenter, mouseleave | Pointer enters or leaves the element itself | No |
wheel | Wheel or trackpad scroll gesture | Yes |
For one click, the order is mousedown, mouseup, click (and for a double click, two of those sequences followed by dblclick). event.button identifies which button changed (0 primary, 1 middle, 2 secondary). event.buttons is a bit mask of all buttons currently held (1 primary, 2 secondary, 4 middle), which is useful during mousemove.
Coordinate systems. Mouse and pointer events provide several sets of coordinates:
| Properties | Relative to | Use for |
|---|---|---|
clientX, clientY | Viewport | Comparing with getBoundingClientRect(), position: fixed |
pageX, pageY | Whole document (client + scroll) | Absolute positioning in the document |
offsetX, offsetY | Padding box of the target element | Quick local position (unreliable when the target is a child) |
screenX, screenY | Physical screen | Rarely needed |
// Position relative to a canvas, robust even if the click lands on a child element
canvas.addEventListener("pointerdown", (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
drawDot(x, y);
});
Keyboard events. Use keydown and keyup (the old keypress event is deprecated). Keyboard events go to the focused element (or <body> if nothing is focused).
| Property | Meaning | Example (US layout, Shift+A) | Example (French AZERTY, "A" key) |
|---|---|---|---|
key | The character or meaning produced, depends on layout and modifiers | "A" | "a" |
code | The physical key position, independent of layout | "KeyA" | "KeyQ" |
repeat | true when auto-repeating because the key is held | ||
ctrlKey, shiftKey, altKey, metaKey | Modifier state | ||
isComposing | true during IME composition (Chinese, Japanese, Korean input) |
Use key for shortcuts tied to meaning ("Ctrl+S", "Escape", "Enter", "ArrowLeft"). Use code for position-based controls such as WASD movement in a game, which should work on any layout.
document.addEventListener("keydown", (e) => {
if (e.isComposing) return; // the user is composing text with an IME
if (e.target.closest("input, textarea, [contenteditable]")) return; // do not hijack typing
switch (e.key) {
case "ArrowRight": nextSlide(); break;
case "ArrowLeft": previousSlide(); break;
case "Escape": closeOverlay(); break;
case "?": showHelp(); break;
}
});
To react to text changes in a field, use the input event (next topic), not keyboard events. Text can also arrive by paste, drag and drop, autocomplete, voice dictation or an on-screen keyboard, none of which produce reliable key events.
Pointer events: one model for all devices. Pointer events mirror mouse events (pointerdown, pointermove, pointerup, pointerover, pointerout, pointerenter, pointerleave) and add pointercancel. They work for mouse, pen and touch with the same code, and add device information:
| Property | Meaning |
|---|---|
pointerId | Unique id for this pointer (each finger gets its own) |
pointerType | "mouse", "pen" or "touch" |
isPrimary | The first finger in a multi-touch gesture, or the mouse |
pressure | 0 to 1 (0.5 for mouse buttons pressed, real values for pens) |
width, height | Contact geometry (touch) |
tiltX, tiltY, twist | Pen orientation |
Pointer capture. During a drag, the pointer often moves outside the element faster than the element follows, and events stop arriving. setPointerCapture routes all events of that pointer to the element until it is released:
const handle = document.querySelector(".slider-handle");
const track = handle.parentElement;
handle.addEventListener("pointerdown", (e) => {
handle.setPointerCapture(e.pointerId); // receive moves even outside the handle
});
handle.addEventListener("pointermove", (e) => {
if (!handle.hasPointerCapture(e.pointerId)) return; // only while dragging
const rect = track.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
handle.style.setProperty("--value", ratio); // CSS positions the handle
});
handle.addEventListener("pointerup", (e) => {
handle.releasePointerCapture(e.pointerId); // also released automatically on pointerup
});
.slider-handle {
touch-action: none; /* tell the browser not to scroll or zoom when dragging this on touch */
}
Without touch-action: none, a touch drag starts scrolling the page, and the browser fires pointercancel and stops sending pointer events to your code.
Touch events. touchstart, touchmove, touchend and touchcancel predate pointer events and expose lists of touch points (touches, targetTouches, changedTouches). They remain useful for some multi-touch gestures and for code that must support very old browsers. For new code, pointer events are simpler and cover all devices. Browsers also fire compatibility mouse events (mousedown, click) after touches, so a click listener works for taps without touch-specific code.
Which family to use.
| Need | Use |
|---|---|
| Activating a button or link | click (works for mouse, touch, keyboard and assistive technology) |
| Drag, draw, swipe, custom sliders | Pointer events, setPointerCapture, touch-action |
| Hover effects | CSS :hover, or pointerenter/pointerleave |
| Shortcuts and key controls | keydown with key (or code for physical layouts) |
| Text input in fields | input and beforeinput, not key events |
| Wheel zoom or custom scroll | wheel (non-passive only if you must prevent scrolling) |
Key Takeaways
clickis device-independent (mouse, touch, keyboard), so use it for activation.mouseover/mouseoutbubble and fire for children, whilemouseenter/mouseleavedo not bubble and fire only for the element.clientX/clientYare viewport coordinates and pair withgetBoundingClientRect().pageX/pageYadd the scroll offset.- Keyboard
keyis the produced character or meaning, andcodeis the physical key. Ignore events during IME composition. - Pointer events unify mouse, pen and touch. Use
setPointerCapturefor drags andtouch-actionto stop the browser taking over gestures.
Practice
- (Beginner) Create a box that logs
clientX,pageX,offsetX,buttonandbuttonsonmousedownandmousemove. Scroll the page and click again, and explain the differences between the coordinate values.- (Intermediate) Build a drawing pad on a
<canvas>using pointer events that supports mouse, pen (with line width based onpressure) and multiple simultaneous fingers, each identified bypointerId. Prevent the page from scrolling while drawing.- (Interview) "What is the difference between
event.keyandevent.code, and which should a game use for WASD movement?" Hint: think about users with AZERTY or Dvorak keyboards.
Form, Focus and Document Lifecycle Events
Beyond raw input devices, the browser emits higher-level events about meaning: a field's value changed, a form was submitted, an element received focus, the DOM is ready, the page is being hidden. These are the events most application logic should be built on, because they already account for the many ways users interact with a page.
Form events.
| Event | Target | Fires when | Bubbles |
|---|---|---|---|
input | <input>, <textarea>, <select>, contenteditable | The value changes, on every keystroke, paste, drop or autocomplete | Yes |
beforeinput | Same | Just before the value changes. Cancelable, exposes inputType and data | Yes |
change | Same | The change is committed: on blur for text fields, immediately for checkboxes, radios, selects and file inputs | Yes |
submit | <form> | The form is submitted (Enter in a field or a submit button) | Yes |
reset | <form> | The form is reset | Yes |
invalid | Form controls | A control fails constraint validation during submission or checkValidity() | No |
const search = document.querySelector("#search");
const email = document.querySelector("#email");
search.addEventListener("input", debounce(() => { // live search (5.3 covers debounce)
runSearch(search.value);
}, 300));
email.addEventListener("change", () => { // validate once the user is done
email.setCustomValidity(isCompanyEmail(email.value) ? "" : "Use your company email");
email.reportValidity();
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(form)); // { email: "...", plan: "pro" }
console.log("submitted by", event.submitter?.name); // which button
save(data);
});
Listen for submit on the form, not click on the submit button. Pressing Enter in a text field also submits the form, and a click handler would miss it. To submit from JavaScript and still run validation and the submit event, call form.requestSubmit(). form.submit() skips both.
Focus events.
| Event | Bubbles | Note |
|---|---|---|
focus | No | Element received focus |
blur | No | Element lost focus |
focusin | Yes | Like focus, but bubbles: use for delegation |
focusout | Yes | Like blur, but bubbles |
FocusEvent.relatedTarget is the element losing focus (for focus/focusin) or receiving focus (for blur/focusout). It is how a composite widget, such as a menu or a combobox, knows whether focus moved outside the widget or just between its own parts:
menu.addEventListener("focusout", (e) => {
if (!menu.contains(e.relatedTarget)) { // focus left the menu entirely
closeMenu();
}
});
Use el.focus() to move focus programmatically (for example to the first field of a dialog), and document.activeElement to find the currently focused element. The CSS pseudo-class :focus-visible shows focus rings for keyboard users without showing them on mouse clicks.
Document lifecycle events. A page goes through a series of states, from parsing to unloading. Each transition fires an event:
navigation starts
│
v
readyState = "loading" parsing HTML, running classic scripts
│
v
readyState = "interactive" DOM fully parsed, deferred and module scripts run
│──> DOMContentLoaded (on document) DOM ready; images and stylesheets may still load
v
readyState = "complete" all subresources (images, iframes, styles) loaded
│──> load (on window)
v
... user interacts ...
│──> visibilitychange tab hidden or shown (document.visibilityState)
│──> pagehide leaving the page (event.persisted = true if kept in bfcache)
v
page restored from back/forward cache
└──> pageshow (event.persisted = true)
| Event | Target | Use for |
|---|---|---|
DOMContentLoaded | document | Initialising scripts that need the DOM (unnecessary with defer or modules) |
load | window | Work that needs images or all resources, such as measuring image sizes |
readystatechange | document | Reacting to every readyState transition |
visibilitychange | document | Pausing video, timers and polling when the tab is hidden; saving state |
pagehide, pageshow | window | Leaving the page, and returning from the back/forward cache |
beforeunload | window | Warning about unsaved changes |
Saving state reliably. The unload event is deprecated and unreliable (especially on mobile, where pages are often discarded without warning), and its presence prevents the browser from keeping the page in the back/forward cache (bfcache), which makes back navigation instant. The recommended pattern is to save on visibilitychange when the page becomes hidden. That is the last event guaranteed to fire on mobile:
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
// sendBeacon queues a small POST that survives the page being closed
navigator.sendBeacon("/analytics", JSON.stringify(collectMetrics()));
saveDraftToStorage();
}
});
window.addEventListener("pageshow", (event) => {
if (event.persisted) refreshStaleData(); // restored from bfcache: JS state is intact but old
});
Warning about unsaved changes. Browsers show a generic confirmation dialog (custom messages are ignored) when a beforeunload listener cancels the event, and only if the user has interacted with the page. Add the listener only while there are unsaved changes:
function onBeforeUnload(event) {
event.preventDefault(); // standard way to request the confirmation dialog
event.returnValue = ""; // legacy requirement for some browsers
}
editor.addEventListener("input", () => window.addEventListener("beforeunload", onBeforeUnload));
saveButton.addEventListener("click", () => window.removeEventListener("beforeunload", onBeforeUnload));
Key Takeaways
inputfires on every value change from any source.changefires when a change is committed.beforeinputcan cancel a change before it happens.- Handle
submiton the form, callpreventDefault()for JavaScript submission, and userequestSubmit()to submit programmatically with validation. focusandblurdo not bubble.focusinandfocusoutdo, andrelatedTargettells you where focus came from or went.DOMContentLoadedmeans the DOM is parsed, andloadmeans every resource is loaded.- Save state on
visibilitychange(hidden), avoidunload, handle bfcache restores withpageshow, and addbeforeunloadonly while there are unsaved changes.
Practice
- (Beginner) Attach
inputandchangelisteners to a text field, a checkbox and a<select>, logging each event. Describe exactly when each one fires for each control.- (Intermediate) Build a note editor that saves a draft to
localStoragewhenever the tab is hidden, restores it on load, and warns about unsaved changes on navigation only when the current text differs from the last saved version.- (Interview) "What is the difference between
DOMContentLoadedandload, and why isunloaddiscouraged?" Hint: consider which resources each event waits for, and how the back/forward cache and mobile tab discarding behave.
Custom Events and Event Dispatching
The browser is not the only source of events. Your own components can announce what happens inside them, such as "item added to cart", "filter changed" or "upload finished", using the same mechanism as built-in events. Other parts of the application listen without the component knowing who they are. This decouples the code: the cart widget does not import the header badge or the analytics module, it just announces the change, and anyone interested listens. It is the publish/subscribe (observer) pattern, implemented by the platform.
Creating and dispatching a CustomEvent. CustomEvent adds a detail property for your own data:
// Inside the cart component
function addToCart(product) {
cart.items.push(product);
const event = new CustomEvent("cart:add", {
detail: { product, count: cart.items.length }, // any data
bubbles: true, // default false: set true so ancestors and delegates can listen
cancelable: true, // allow listeners to veto with preventDefault()
composed: false, // true to cross shadow DOM boundaries
});
const allowed = cartElement.dispatchEvent(event); // false if a listener called preventDefault()
if (!allowed) {
cart.items.pop(); // a listener vetoed the action
}
}
// Anywhere else, e.g. the header badge
document.addEventListener("cart:add", (event) => {
badge.textContent = event.detail.count;
});
// A rule that can veto the action
document.addEventListener("cart:add", (event) => {
if (event.detail.count > 10) {
event.preventDefault();
alert("Maximum 10 items");
}
});
Dispatch is synchronous. Unlike events from the browser, which are queued as tasks, dispatchEvent calls all listeners immediately and returns only when they have all finished. This is why the return value can report whether one of them called preventDefault():
console.log("before");
el.addEventListener("ping", () => console.log("listener"));
el.dispatchEvent(new Event("ping"));
console.log("after");
// before, listener, after: no event loop involved
An exception thrown in a listener does not stop the other listeners or reach the code that called dispatchEvent. It is reported as an uncaught error instead.
Naming conventions. Event names are case-sensitive strings. Use lowercase names with a namespace prefix to avoid collisions with built-in and third-party events, for example cart:add, cart:remove or filters-change. Do not reuse built-in names such as change for custom meaning, since other code expects their standard behaviour.
Simulating built-in events. You can construct built-in event types (new MouseEvent("click", {...}), new KeyboardEvent("keydown", {...})), but dispatching them generally does not perform default actions. A synthetic keydown does not type a character or scroll the page, and a synthetic submit event does not submit the form. The notable exception is click: dispatching a MouseEvent("click") runs the element's activation behaviour (a link navigates, a checkbox toggles). Synthetic events are marked isTrusted: false, so privileged actions that require user activation still fail. To trigger the real behaviour, call the corresponding method:
| Goal | Use |
|---|---|
| Activate a button or link like a user click | el.click() (dispatches a click and runs its activation behaviour) |
| Focus an element | el.focus() |
Submit a form with validation and the submit event | form.requestSubmit() |
| Notify listeners that a value was changed by script | el.dispatchEvent(new Event("input", { bubbles: true })) |
The last row is common in test code and integrations. Setting input.value from script does not fire input or change, so frameworks listening to those events will not notice unless you dispatch one.
Events on your own objects: extending EventTarget. Any class can become an event source by extending EventTarget, without needing a DOM element:
class Store extends EventTarget {
#state;
constructor(initial) {
super();
this.#state = initial;
}
get state() {
return this.#state;
}
set(patch) {
const previous = this.#state;
this.#state = { ...previous, ...patch };
this.dispatchEvent(new CustomEvent("change", {
detail: { previous, current: this.#state },
}));
}
}
const store = new Store({ theme: "light", user: null });
store.addEventListener("change", ({ detail }) => {
document.documentElement.dataset.theme = detail.current.theme;
});
store.set({ theme: "dark" }); // listeners run synchronously
This tiny store already supports multiple subscribers, once, and removal with an AbortSignal, because it inherits the whole addEventListener machinery. It is the core idea behind many state-management libraries (14.4).
Key Takeaways
new CustomEvent(type, { detail, bubbles, cancelable, composed })creates an event carrying your own data indetail.dispatchEventruns listeners synchronously and returnsfalseif a cancelable event was cancelled withpreventDefault().- Custom events are not bubbling by default. Set
bubbles: trueso ancestors and delegates can hear them, andcomposed: trueto cross shadow roots. - Synthetic built-in events have
isTrusted: falseand mostly do not trigger default actions (clickis the exception). Useclick(),focus()andrequestSubmit()to trigger real behaviour. - Any class can extend
EventTargetto become a lightweight, standard event emitter.
Practice
- (Beginner) Create a "Like" button component that dispatches a bubbling
like:toggleevent with{ id, liked }indetail, and a listener ondocumentthat updates a total counter.- (Intermediate) Implement a
Timerclass extendingEventTargetthat emitstick(every second, with the remaining time),pauseanddoneevents. Build a UI that subscribes to it, and use anAbortControllerto unsubscribe the UI when it is closed.- (Interview) "How does dispatching a custom event differ from a browser-generated event in terms of timing and trust?" Hint: consider when listeners run relative to the
dispatchEventcall, whatisTrustedreports, and whether default actions run.
<a id="64-other-browser-capabilities"></a>
6.4 Other Browser Capabilities
This subchapter surveys the browser APIs that turn a document into an application: storing data on the client, managing URLs without reloading, accessing device features, running code in the background and observing visibility and size changes efficiently.
Web Storage: localStorage and sessionStorage
HTTP is stateless (Chapter 10), and a page's JavaScript variables disappear when the page is reloaded or closed. Many small pieces of state should survive that: the user's theme, a half-written comment, the last-selected tab, a dismissed banner. Before 2009 the only client-side option was cookies, which are tiny (about 4 KB), awkward to use from JavaScript, and sent to the server with every request, wasting bandwidth on data the server never needs. The Web Storage API provides two simple key-value stores that stay in the browser.
The API. localStorage and sessionStorage share the same interface (Storage):
localStorage.setItem("theme", "dark");
localStorage.getItem("theme"); // "dark"
localStorage.getItem("missing"); // null (not undefined)
localStorage.removeItem("theme");
localStorage.clear(); // remove everything for this origin
localStorage.length; // number of keys
localStorage.key(0); // name of the first key (order not guaranteed)
Strings only. Keys and values are always strings. Other values are converted with String(), which silently corrupts objects:
localStorage.setItem("count", 5);
typeof localStorage.getItem("count"); // "string": "5"
localStorage.setItem("user", { name: "Ada" });
localStorage.getItem("user"); // "[object Object]": data lost
// Serialise structured data with JSON (4.1)
localStorage.setItem("user", JSON.stringify({ name: "Ada", visits: 3 }));
const user = JSON.parse(localStorage.getItem("user") ?? "null"); // { name: "Ada", visits: 3 }
Dates come back as strings, and Map, Set, undefined and functions are not preserved by JSON.
localStorage versus sessionStorage.
| Aspect | localStorage | sessionStorage |
|---|---|---|
| Lifetime | Until deleted by script, the user or browser storage policies | Until the tab is closed (survives reloads) |
| Scope | Shared by all tabs and windows of the same origin | One per tab and origin. A duplicated tab gets a copy |
| Typical use | Preferences, cached data, drafts | Multi-step form progress, per-tab state |
Origin scoping. Storage is isolated by origin (scheme + host + port). https://shop.example.com and https://blog.example.com have separate stores, and so do http:// and https:// versions of the same site. This is what keeps other sites from reading your data.
The storage event: syncing tabs. When one tab changes localStorage, other tabs of the same origin receive a storage event on window. The tab that made the change does not receive it.
// Log out in every open tab when the user logs out in one
window.addEventListener("storage", (event) => {
// event.key, event.oldValue, event.newValue, event.url, event.storageArea
if (event.key === "session" && event.newValue === null) {
location.assign("/login");
}
});
For richer cross-tab messaging, BroadcastChannel sends arbitrary messages between same-origin tabs.
Limits and failure modes. Production code must handle storage failures, because storage can be unavailable or full:
- Quota: about 5 MB per origin in most browsers (measured in UTF-16 characters, so roughly 2.5 million characters). Exceeding it throws a
QuotaExceededError. - Disabled or restricted: some privacy settings, embedded iframes with blocked third-party storage, and older private-browsing modes throw on access.
- Eviction: browsers may clear storage under disk pressure, and Safari's tracking prevention can delete script-writable storage for sites the user has not visited for a period of days.
const storage = {
get(key, fallback = null) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch {
return fallback; // storage blocked or corrupted JSON
}
},
set(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (err) {
console.warn("Could not save", key, err.name); // QuotaExceededError, SecurityError
return false;
}
},
};
Synchronous and therefore blocking. Every getItem and setItem call blocks the main thread while the browser reads or writes, and the first access may load the whole store from disk. Small values are fine, but storing megabytes of JSON and parsing it on every change causes jank. Use IndexedDB (next topic) for large or structured data.
Security: never store secrets. Any JavaScript running on the origin can read Web Storage, including injected scripts in an XSS attack (6.1, Chapter 17). Storing authentication tokens in localStorage exposes them to theft. HttpOnly cookies, which JavaScript cannot read at all, are safer for session identifiers (Chapter 10).
Client-side storage options compared.
| Feature | Cookies | localStorage | sessionStorage | IndexedDB |
|---|---|---|---|---|
| Capacity | About 4 KB per cookie | About 5 MB | About 5 MB | Large (a share of free disk space) |
| Sent to the server | With every request | No | No | No |
| API | String parsing (document.cookie) | Synchronous key-value | Synchronous key-value | Asynchronous, transactional |
| Data types | Strings | Strings | Strings | Structured clone (objects, Blobs, Dates, Maps) |
| Readable by JS | Unless HttpOnly | Yes | Yes | Yes |
| Available in Workers | No | No | No | Yes |
Key Takeaways
localStoragepersists per origin across sessions.sessionStoragelasts for one tab's session.- Both store only strings. Serialise with
JSON.stringifyand parse withJSON.parse, handlingnullfor missing keys. - The
storageevent notifies other tabs of the same origin about changes. - The API is synchronous and limited to about 5 MB. Wrap access in
try/catchand use IndexedDB for large data. - Web Storage is readable by any script on the origin. Never store secrets or session tokens in it.
Practice
- (Beginner) Save the content of a
<textarea>tolocalStorageon everyinputevent and restore it when the page loads. Then switch tosessionStorageand compare the behaviour when closing and reopening the tab.- (Intermediate) Build a theme switcher that stores the choice in
localStorageand updates every open tab instantly via thestorageevent. Make it work when storage is disabled, falling back to the operating system'sprefers-color-scheme.- (Interview) "Where would you store a JWT access token in a browser application, and why not
localStorage?" Hint: consider which code can read each storage mechanism, and what an XSS vulnerability would allow an attacker to do.
IndexedDB Basics
Some applications need much more than a few preferences: an email client that works offline, a note-taking app with thousands of notes, a photo editor that keeps images between sessions, a cache of API responses. These need a lot of space, queries by fields other than the key, binary data, and an API that does not block the main thread. IndexedDB is the browser's built-in transactional database for this. It is a NoSQL object store: you store JavaScript objects (not rows and columns), retrieve them by key, and query them through indexes.
Concepts.
Origin (https://notes.example.com)
└── Database "notes-app" (version 2)
├── Object store "notes" keyPath: "id", autoIncrement
│ ├── records: { id: 1, title: "Ideas", tags: ["work"], updated: Date, ... }
│ ├── index "by-updated" on field "updated"
│ └── index "by-tag" on field "tags" (multiEntry: one entry per tag)
└── Object store "attachments" key: provided explicitly
└── records: Blob, File, ArrayBuffer...
| Concept | Meaning | Relational analogy |
|---|---|---|
| Database | Named container per origin, with an integer version | Database |
| Object store | Collection of records with unique keys | Table |
Key / keyPath | How each record is identified (a property path, or an explicit key, optionally auto-incremented) | Primary key |
| Index | Lookup structure on a field of the records | Secondary index |
| Transaction | Group of operations that succeed or fail together, readonly or readwrite | Transaction |
| Cursor | Iterator over a range of records | Cursor |
Values are copied with the structured clone algorithm, which supports objects, arrays, Date, Map, Set, Blob, File, ArrayBuffer and typed arrays, but not functions or DOM nodes.
Opening a database and defining its schema. The schema can only change inside the upgradeneeded event, which fires when the database is created or when you open it with a higher version number:
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open("notes-app", 2); // name, version
request.onupgradeneeded = (event) => {
const db = request.result;
// event.oldVersion tells you which migrations to run (0 = brand-new database)
if (event.oldVersion < 1) {
const notes = db.createObjectStore("notes", { keyPath: "id", autoIncrement: true });
notes.createIndex("by-updated", "updated");
}
if (event.oldVersion < 2) {
const notes = request.transaction.objectStore("notes");
notes.createIndex("by-tag", "tags", { multiEntry: true }); // added in version 2
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onblocked = () => console.warn("Close other tabs to upgrade the database");
});
}
Reading and writing. Every operation happens inside a transaction and returns an IDBRequest with success and error events. The original API predates promises (Chapter 5), so a small helper makes it pleasant to use:
// Convert an IDBRequest into a promise
const promisify = (request) =>
new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
const db = await openDatabase();
// Write: a readwrite transaction on one store
async function saveNote(note) {
const tx = db.transaction("notes", "readwrite");
const id = await promisify(tx.objectStore("notes").put({ ...note, updated: new Date() }));
// put = insert or replace; add = insert only (fails if the key exists)
return id;
}
// Read by primary key
async function getNote(id) {
const tx = db.transaction("notes", "readonly");
return promisify(tx.objectStore("notes").get(id)); // undefined if not found
}
// Query an index: all notes tagged "work"
async function notesWithTag(tag) {
const index = db.transaction("notes").objectStore("notes").index("by-tag");
return promisify(index.getAll(tag));
}
// Range query with a cursor: the 10 most recently updated notes
async function recentNotes(limit = 10) {
const index = db.transaction("notes").objectStore("notes").index("by-updated");
const results = [];
return new Promise((resolve, reject) => {
const request = index.openCursor(null, "prev"); // newest first
request.onsuccess = () => {
const cursor = request.result;
if (!cursor || results.length === limit) return resolve(results);
results.push(cursor.value);
cursor.continue(); // fires onsuccess again
};
request.onerror = () => reject(request.error);
});
}
IDBKeyRange expresses ranges: IDBKeyRange.bound(a, b), lowerBound(a), upperBound(b) and only(x). These can be passed to getAll or openCursor.
The transaction auto-commit trap. A transaction commits automatically as soon as it has no pending requests and control returns to the event loop. If you await something unrelated (such as a fetch) in the middle of a transaction, it has already committed by the time you continue, and further requests throw TransactionInactiveError:
const tx = db.transaction("notes", "readwrite");
const store = tx.objectStore("notes");
const note = await promisify(store.get(1)); // OK: an IndexedDB request
const extra = await fetch("/api/enrich").then((r) => r.json()); // tx commits here!
store.put({ ...note, ...extra }); // TransactionInactiveError
// Fix: do the network work first, then open a transaction for the database work only
Libraries. Because the raw API is verbose, most projects use a thin wrapper. idb (by Jake Archibald) adds promises with almost no abstraction, while Dexie offers a higher-level query API.
Storage quotas and persistence. IndexedDB can store a large share of free disk space, but the browser may evict it under storage pressure ("best-effort" storage). An application can check usage and ask for persistent storage:
const { usage, quota } = await navigator.storage.estimate();
console.log(`${(usage / 1e6).toFixed(1)} MB of ${(quota / 1e6).toFixed(0)} MB used`);
const persisted = await navigator.storage.persist(); // true if the browser grants it
IndexedDB is also available in Web Workers and service workers, which makes it the standard storage for offline-capable Progressive Web Apps (Chapter 15).
Key Takeaways
- IndexedDB is an asynchronous, transactional object database per origin, suitable for large and structured data, including binary data.
- Databases contain object stores (collections keyed by a
keyPathor explicit key) and indexes on record fields. - Schema changes happen only in
upgradeneeded, triggered by opening with a higher version. UseoldVersionto run migrations. - Every operation runs in a transaction and returns a request. Wrap requests in promises or use a library such as
idb. - Transactions auto-commit when idle. Never
awaitunrelated asynchronous work inside one. - Use
navigator.storage.estimate()andpersist()to monitor quota and request durable storage.
Practice
- (Beginner) Create a database with a
contactsstore (keyPath: "email"). Add three contacts, read one by key, and inspect the result in the DevTools Application panel.- (Intermediate) Build an offline-capable notes list that stores notes in IndexedDB with an index on
updated, shows them newest first, supports tag search through amultiEntryindex, and migrates from version 1 to version 2 by adding the tag index without losing data.- (Interview) "When would you choose IndexedDB over
localStorage?" Hint: compare data size, data types, blocking behaviour, querying needs and availability in workers.
History API and Client-Side Routing
In a traditional website, every link click loads a whole new HTML page from the server. A single-page application (SPA) loads one page and replaces its content with JavaScript, which is faster and allows smooth transitions. But if the URL never changes, the app breaks basic web expectations: the Back button leaves the app entirely, refreshing loses your place, and you cannot bookmark or share a link to a specific view. The History API lets JavaScript change the URL and add entries to the session history without loading a new page, so an SPA can behave like a set of real pages. Code that maps URLs to views is called a client-side router.
The session history. Each tab keeps a stack of visited entries, with a pointer to the current one. history.back(), history.forward() and history.go(n) move the pointer, exactly like the browser buttons.
pushState and replaceState.
history.pushState(state, "", url); // ADD a new entry after the current one and make it current
history.replaceState(state, "", url); // REPLACE the current entry
history.state; // the state object of the current entry
state: any structured-cloneable data associated with the entry (for example, a scroll position or the id of an open item). It is returned to you later.- The second parameter is unused (historically a title). Pass
"". url: the new URL, which must be same-origin. It can be relative ("/products/42","?page=2","#reviews").
Neither method loads anything or fires an event. The address bar changes and that is all, so your code must render the new view itself.
Session history after: load /, pushState /products, pushState /products/42, back()
[ / ] [ /products ] [ /products/42 ]
^ current (forward entry still available)
pushState("/cart") from here discards the forward entries:
[ / ] [ /products ] [ /cart ]
^ current
popstate: responding to Back and Forward. When the user navigates between entries created by pushState (with the Back or Forward buttons or history.back()), the page is not reloaded. Instead, window receives a popstate event with the entry's state. pushState and replaceState do not fire popstate.
A minimal router.
const routes = [
{ pattern: new URLPattern({ pathname: "/" }), view: renderHome },
{ pattern: new URLPattern({ pathname: "/products/:id" }), view: renderProduct },
{ pattern: new URLPattern({ pathname: "/cart" }), view: renderCart },
];
// URLPattern is a recent built-in; a regular expression per route works everywhere.
function render(url) {
for (const { pattern, view } of routes) {
const match = pattern.exec(url);
if (match) {
view(match.pathname.groups); // e.g. { id: "42" }
return;
}
}
renderNotFound();
}
function navigate(url) {
history.pushState({}, "", url); // 1. update the URL and history
render(location.href); // 2. render the new view ourselves
}
// Intercept clicks on internal links with event delegation (6.3)
document.addEventListener("click", (event) => {
const link = event.target.closest("a[href]");
if (
!link ||
link.origin !== location.origin || // external link: let the browser handle it
link.target === "_blank" ||
link.hasAttribute("download") ||
event.button !== 0 || // middle click opens a new tab
event.metaKey || event.ctrlKey || event.shiftKey || event.altKey
) return;
event.preventDefault();
navigate(link.href);
});
window.addEventListener("popstate", () => render(location.href)); // Back / Forward
render(location.href); // initial page load
Hash-based routing. Older SPAs used URLs such as /#/products/42. Changing location.hash never reloads the page, and the hashchange event fires on every change. It needs no server configuration, but the URLs are less clean and the fragment is not sent to the server, which matters for server rendering and analytics.
The server must cooperate. With path-based routing, a user can reload or open https://shop.example.com/products/42 directly. The browser then requests that path from the server, which must return the SPA's index.html (a fallback route) instead of a 404. Express (Chapter 12), static hosts and CDNs all provide a setting for this.
Responsibilities a router takes on. Because no real navigation happens, the browser no longer does several things for you:
| Concern | What the router must do |
|---|---|
| Page title | Update document.title for each view |
| Scroll position | Scroll to top on new navigation, and restore the position on Back (history.scrollRestoration = "manual" lets you take full control) |
| Focus and accessibility | Move focus to the new main heading or content, and announce the change for screen-reader users |
| Loading and errors | Show progress, and handle data that fails to load |
| Pending work | Cancel fetches for the previous view (AbortController, 5.3) |
The Navigation API. A newer API, window.navigation, centralises all of this. A single navigate event fires for link clicks, form submissions, Back/Forward and programmatic navigations, and event.intercept({ handler }) converts it into a same-document navigation:
navigation.addEventListener("navigate", (event) => {
if (!event.canIntercept || event.hashChange || event.downloadRequest) return;
const url = new URL(event.destination.url);
if (url.origin !== location.origin) return;
event.intercept({
async handler() {
await render(url.href); // the browser handles the URL, focus and scroll restoration
},
});
});
It removes the need for click interception and popstate handling. Chromium shipped it first and other engines have been adding it, so check support and keep a History API fallback where needed.
Key Takeaways
history.pushStateandreplaceStatechange the URL and session history without loading a page or firing events. Your code must render the new view.popstatefires when the user moves between history entries with Back and Forward. It does not fire forpushState.- A client-side router intercepts internal link clicks, calls
pushState, renders the matching view and re-renders onpopstate. - The server must return the application's HTML for every client-side route so that reloads and deep links work.
- A router must handle titles, scroll, focus and cancellation that the browser normally manages. The Navigation API simplifies this where supported.
Practice
- (Beginner) Create a page with three buttons that call
pushStatewith different URLs and states, and apopstatelistener that displayshistory.state. Use the Back and Forward buttons and observe the URL and the displayed state.- (Intermediate) Build a small SPA with home, product list, product detail (
/products/:id) and 404 views, using the router pattern above. Updatedocument.title, move focus to each view's<h1>, and restore scroll positions when going back. Serve it with a fallback route so that reloading a deep link works.- (Interview) "How does a client-side router work, and what breaks if the server is not configured for it?" Hint: trace a link click, a Back button press and a page reload on a deep URL separately.
Geolocation, Notifications and Clipboard
Some browser APIs reach beyond the page: the device's location, the operating system's notification centre, the system clipboard. Because these can expose private data or be used to annoy users, they share a common security model. They are only available in secure contexts (HTTPS or localhost), many require the user's permission, and some only work in response to a user gesture such as a click. Learning that model once makes every device API easier to use.
The permission model.
page calls the API ──> secure context? ──no──> API missing or rejects
│ yes
v
permission state for this origin?
┌────────────┬──────────────┬─────────────┐
"granted" "prompt" "denied"
│ │ │
proceed browser asks fails immediately
the user (the user must change site settings)
The Permissions API lets you check the current state without triggering a prompt, so you can explain why you need a permission before asking:
const status = await navigator.permissions.query({ name: "geolocation" });
console.log(status.state); // "granted", "prompt" or "denied"
status.addEventListener("change", () => console.log("now", status.state));
Geolocation. navigator.geolocation gives the device's position from GPS, Wi-Fi, cell towers or IP address. The API uses callbacks:
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude, accuracy } = position.coords; // accuracy in metres
showOnMap(latitude, longitude, accuracy);
},
(error) => {
// error.code: 1 PERMISSION_DENIED, 2 POSITION_UNAVAILABLE, 3 TIMEOUT
showMessage(error.code === error.PERMISSION_DENIED
? "Location access was denied. Enter your city instead."
: "Could not determine your location.");
},
{
enableHighAccuracy: false, // true uses GPS: slower and uses more battery
timeout: 10_000, // give up after 10 seconds
maximumAge: 60_000, // accept a cached position up to 1 minute old
}
);
// Continuous tracking (navigation, fitness apps)
const watchId = navigator.geolocation.watchPosition(updateMarker, handleError);
navigator.geolocation.clearWatch(watchId); // stop tracking
Promisifying it (5.2) makes it fit into async code:
const getPosition = (options) =>
new Promise((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject, options));
const { coords } = await getPosition({ timeout: 10_000 });
Notifications. The Notifications API displays system notifications outside the page. Permission must be requested with Notification.requestPermission(), and browsers ignore or silently block requests that are not triggered by a user gesture:
enableAlertsButton.addEventListener("click", async () => {
if (!("Notification" in window)) return;
const permission = await Notification.requestPermission(); // "granted", "denied", "default"
if (permission !== "granted") return;
const notification = new Notification("Order shipped", {
body: "Your order #1042 is on its way.",
icon: "/img/logo-192.png",
tag: "order-1042", // a newer notification with the same tag replaces this one
});
notification.addEventListener("click", () => {
window.focus();
location.assign("/orders/1042");
});
});
Notifications created this way only work while the page is open. To notify users when the site is closed, a service worker receives messages from the server through the Push API and shows the notification (registration.showNotification). This is covered with Progressive Web Apps in Chapter 15.
Clipboard. The asynchronous Clipboard API (navigator.clipboard) replaces the old document.execCommand("copy"):
copyButton.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(codeBlock.textContent); // needs a secure context and focus
copyButton.textContent = "Copied";
} catch (err) {
copyButton.textContent = "Copy failed"; // permission or focus problem
}
});
// Reading is more sensitive: browsers ask for permission or show a "Paste" confirmation
const text = await navigator.clipboard.readText();
// Rich content: images and HTML with ClipboardItem
const blob = await (await fetch("/chart.png")).blob();
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
You can also customise what users copy and paste with the copy, cut and paste events, which expose event.clipboardData:
// Paste images from the clipboard into an editor
editor.addEventListener("paste", (event) => {
const file = [...event.clipboardData.files].find((f) => f.type.startsWith("image/"));
if (file) {
event.preventDefault();
insertImage(URL.createObjectURL(file));
}
});
Requirements at a glance.
| API | Secure context | Permission prompt | User gesture required |
|---|---|---|---|
| Geolocation | Yes | Yes | No (but asking on load is bad practice) |
| Notifications | Yes | Yes | Yes, for requestPermission in most browsers |
clipboard.writeText | Yes | No (Chromium may use a permission) | Usually, and the document must be focused |
clipboard.readText | Yes | Yes, or a per-use paste confirmation | Yes in most browsers |
Good practice. Ask for a permission in context, at the moment its value is obvious (a "Find stores near me" button, not a prompt on page load). Users who deny a prompt are rarely asked again, because the browser remembers the decision. Always provide a fallback path (manual city entry, an in-page message, a selectable text field).
Key Takeaways
- Device APIs require secure contexts. Many need explicit permission, and some need a user gesture.
- Use
navigator.permissions.queryto check state without prompting, and request permissions in context, with a fallback if denied. - Geolocation uses callbacks with success, error (codes 1 to 3) and options (
enableHighAccuracy,timeout,maximumAge). Promisify it forasynccode. Notification.requestPermission()must be called from a user gesture. Notifications for closed pages require a service worker and Push.navigator.clipboard.writeTextandreadTextare promise-based. Reading is more restricted than writing.copyandpasteevents customise clipboard behaviour.
Practice
- (Beginner) Add a "Copy" button to every
<pre>block on a page using event delegation andnavigator.clipboard.writeText, showing "Copied" for two seconds afterwards.- (Intermediate) Build a "Find the nearest store" feature: check the geolocation permission state, explain the request in a message before prompting, compute the distance to a list of stores with the haversine formula, and fall back to a city search box when permission is denied or the request times out.
- (Interview) "Why do browsers require HTTPS and a user gesture for APIs like notifications and clipboard access?" Hint: consider what a malicious page or a network attacker could do with each capability if the restrictions did not exist.
Web Workers and Background Threads
Chapter 5 established that JavaScript runs your code on a single main thread, shared with rendering and input handling. A computation that takes 500 ms (parsing a large file, filtering 100,000 records, applying an image filter) freezes the page for 500 ms, whatever async syntax wraps it. Promises and async functions help with waiting, not with computing. To compute without freezing the page, the work must run on another thread. Web Workers provide exactly that: real OS threads that run JavaScript in parallel with the main thread.
The isolation model. A worker runs in its own global scope (self, of type DedicatedWorkerGlobalScope), with its own event loop and its own memory. It has no access to the DOM, window or document, which would require locking and make the DOM unsafe. Workers and the page communicate only by passing messages, like two people in separate offices who talk by sending letters, never by sharing a desk.
Main thread Worker thread
┌──────────────────────────┐ postMessage() ┌──────────────────────────┐
│ DOM, events, rendering │ ────────────────> │ onmessage: compute │
│ worker.onmessage(...) │ <──────────────── │ postMessage(result) │
└──────────────────────────┘ postMessage() └──────────────────────────┘
window, document self, no DOM
fetch, IndexedDB, timers, WebSocket available
A first worker.
// primes.worker.js: runs in the worker thread
self.addEventListener("message", (event) => {
const { limit } = event.data;
const primes = [];
for (let n = 2; n <= limit; n++) { // deliberately slow CPU work
let isPrime = true;
for (let d = 2; d * d <= n; d++) {
if (n % d === 0) { isPrime = false; break; }
}
if (isPrime) primes.push(n);
}
self.postMessage({ count: primes.length, last: primes.at(-1) });
});
// main.js: runs in the page
const worker = new Worker(new URL("./primes.worker.js", import.meta.url), { type: "module" });
worker.addEventListener("message", (event) => {
output.textContent = `${event.data.count} primes, the largest is ${event.data.last}`;
});
worker.addEventListener("error", (event) => {
console.error("Worker failed:", event.message);
});
button.addEventListener("click", () => {
output.textContent = "Computing..."; // the page stays responsive meanwhile
worker.postMessage({ limit: 5_000_000 });
});
// When no longer needed:
// worker.terminate(); from the page, or self.close() from inside the worker
{ type: "module" } lets the worker use import statements (3.4). Classic workers use importScripts("lib.js") instead.
What can be sent: structured clone and transfer. Messages are copied with the structured clone algorithm (the same one IndexedDB uses): plain objects, arrays, Date, Map, Set, Blob, ArrayBuffer, typed arrays and more. Functions, DOM nodes and class instances' prototypes are not preserved. Copying a 200 MB buffer is slow, so large binary data can be transferred instead: ownership moves to the other thread in constant time, and the sender's copy becomes unusable.
const pixels = new Uint8ClampedArray(4 * 4000 * 3000); // 48 MB image buffer
worker.postMessage({ pixels }, [pixels.buffer]); // second argument: transfer list
console.log(pixels.byteLength); // 0: the buffer now belongs to the worker
A request/response wrapper. Raw messages have no built-in notion of "the answer to this request". A small wrapper adds ids and promises so that callers can simply await:
// main.js
function createWorkerClient(url) {
const worker = new Worker(url, { type: "module" });
const pending = new Map();
let nextId = 0;
worker.onmessage = ({ data }) => {
const { id, result, error } = data;
const { resolve, reject } = pending.get(id);
pending.delete(id);
error ? reject(new Error(error)) : resolve(result);
};
return {
call(method, ...args) {
return new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
worker.postMessage({ id, method, args });
});
},
};
}
// worker.js
const api = {
sum: (numbers) => numbers.reduce((a, b) => a + b, 0),
sortBy: (rows, key) => rows.toSorted((a, b) => (a[key] > b[key] ? 1 : -1)),
};
self.onmessage = ({ data: { id, method, args } }) => {
try {
self.postMessage({ id, result: api[method](...args) });
} catch (err) {
self.postMessage({ id, error: err.message });
}
};
// usage
const client = createWorkerClient("./worker.js");
const total = await client.call("sum", bigArray);
Libraries such as Comlink generalise this pattern, letting you call worker functions as if they were local async functions.
Kinds of workers.
| Kind | Created with | Shared by | Purpose |
|---|---|---|---|
| Dedicated worker | new Worker(url) | The page that created it | Offload CPU work |
| Shared worker | new SharedWorker(url) | All same-origin tabs that connect to it | Share one connection or cache across tabs |
| Service worker | navigator.serviceWorker.register(url) | All pages in its scope, even when closed | Network proxy, offline caching, push (Chapter 15) |
| Worklets | CSS.paintWorklet.addModule(), audioContext.audioWorklet | The rendering or audio pipeline | Tiny hooks into rendering and audio processing |
Costs and limits.
- Starting a worker takes time and memory (a new JavaScript environment). Create workers once and reuse them, or keep a pool sized to
navigator.hardwareConcurrency. - Message copying costs grow with data size. Send only what is needed, and transfer large buffers.
- True shared memory (
SharedArrayBufferwithAtomics) exists, but is only available on cross-origin isolated pages (served with theCross-Origin-Opener-PolicyandCross-Origin-Embedder-Policyheaders) as a defence against Spectre-style attacks. - Workers cannot touch the DOM. For graphics,
OffscreenCanvaslets a worker draw into a canvas (Chapter 7).
Key Takeaways
- Web Workers run JavaScript on separate threads, so CPU-heavy work does not freeze the page.
asynccode alone does not achieve this. - Workers have no DOM access and communicate with the page only through
postMessageandmessageevents. - Messages are copied with structured clone. Transfer
ArrayBuffers to move large data without copying. - Wrap message passing in a request/response protocol with ids and promises (or use Comlink).
- Dedicated, shared and service workers serve different purposes. Reuse workers because they are expensive to start.
Practice
- (Beginner) Run the prime-counting loop on the main thread with a CSS spinner on the page and observe the freeze. Move it into a worker and confirm the spinner keeps turning.
- (Intermediate) Build a CSV viewer that reads a large file with
<input type="file">, sends the file to a worker that parses it and computes column statistics, and reports progress messages every 10,000 rows. Make the "Cancel" button terminate the worker.- (Interview) "If JavaScript is single-threaded, how do Web Workers achieve parallelism, and why can't they access the DOM?" Hint: consider separate event loops and memory, message passing versus shared state, and what concurrent DOM mutation would require.
Intersection and Resize Observers
Two questions come up constantly in UI code: "Is this element visible on screen?" (to lazy-load images, load the next page of an infinite list, start a video or record an ad impression) and "Has this element changed size?" (to redraw a chart, switch a component's layout or truncate text). The traditional answers were scroll and resize listeners that call getBoundingClientRect() and read offsetWidth. These run constantly, force layout on the main thread (6.1) and miss many cases: a resize event fires when the window changes, not when an element changes size because a sidebar collapsed. The observer APIs make the browser do this tracking efficiently and report only actual changes, with the same shape as MutationObserver (6.1).
IntersectionObserver. It reports when a target element starts or stops intersecting a root (the viewport by default, or a scrollable ancestor), crossing given thresholds:
const observer = new IntersectionObserver(
(entries, obs) => {
for (const entry of entries) {
// entry.isIntersecting, entry.intersectionRatio (0..1), entry.target,
// entry.boundingClientRect, entry.intersectionRect, entry.rootBounds, entry.time
if (entry.isIntersecting) {
console.log(entry.target.id, "is visible at", entry.intersectionRatio);
}
}
},
{
root: null, // null = the viewport; or a scrollable container element
rootMargin: "0px 0px 200px 0px", // grow the root box: trigger 200px BEFORE entering the viewport
threshold: [0, 0.5, 1], // call back when visibility crosses 0%, 50% and 100%
}
);
document.querySelectorAll("section").forEach((s) => observer.observe(s));
The callback also runs once for each target as soon as observation starts, reporting its initial state.
Lazy-loading images. Native loading="lazy" on <img> and <iframe> covers the simple case without any JavaScript. The observer is useful for custom behaviour, such as background images or loading components:
const lazy = new IntersectionObserver((entries, obs) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target;
img.src = img.dataset.src; // start the real download
obs.unobserve(img); // done with this one
}
}, { rootMargin: "300px" }); // start a little before it scrolls into view
document.querySelectorAll("img[data-src]").forEach((img) => lazy.observe(img));
Infinite scroll with a sentinel. Put an empty element after the list and observe it. When it becomes visible, load the next page:
const sentinel = document.querySelector("#load-more-sentinel");
let page = 1;
let loading = false;
const loader = new IntersectionObserver(async ([entry]) => {
if (!entry.isIntersecting || loading) return;
loading = true;
const items = await fetch(`/api/items?page=${++page}`).then((r) => r.json());
renderItems(items); // appending pushes the sentinel down again
if (items.length === 0) loader.disconnect(); // no more data
loading = false;
});
loader.observe(sentinel);
Other uses include highlighting the current section in a table of contents, pausing videos and animations that scroll out of view, and reporting ad or product impressions (for example "50% visible", with a threshold of 0.5).
ResizeObserver. It reports when an element's size changes, for any reason: window resize, content change, a sibling collapsing, a font loading, or a CSS class change.
const chartContainer = document.querySelector(".chart");
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// contentBoxSize and borderBoxSize are arrays (one item per fragment, e.g. in multi-column layouts)
const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0];
redrawChart(entry.target, width, height);
}
});
resizeObserver.observe(chartContainer); // content box by default
// resizeObserver.observe(el, { box: "border-box" }); // or observe the border box
entry.contentRect is an older DOMRect-based property that is still widely used. inlineSize and blockSize are the writing-mode-aware width and height.
Avoiding resize loops. If the callback changes the observed element's size, it triggers another observation. The browser processes these in passes during the same frame, and if it cannot settle it reports the console error "ResizeObserver loop completed with undelivered notifications" and defers the rest to the next frame. Avoid resizing the observed element itself in its callback. Change children, or defer the change with requestAnimationFrame.
Container queries first. Since container queries (@container, Chapter 2) let CSS adapt a component to its container's size, a ResizeObserver is no longer needed for purely visual changes. Use it when JavaScript must act on the size, for example to redraw a canvas, pick an image resolution, or virtualise a list.
The observer family.
| Observer | Reports | Typical uses |
|---|---|---|
MutationObserver (6.1) | DOM tree, attribute and text changes | Reacting to third-party DOM changes |
IntersectionObserver | Visibility relative to the viewport or a container | Lazy loading, infinite scroll, impressions, scroll spy |
ResizeObserver | Element size changes | Responsive canvases and charts, component-level layout logic |
PerformanceObserver | Performance entries (long tasks, paints, layout shifts) | Real-user performance monitoring (Chapter 17) |
All share the same lifecycle: create with a callback, observe(target, options), unobserve(target), disconnect(). Callbacks are batched and run asynchronously, so they never block the change that triggered them.
Key Takeaways
IntersectionObserverreports when elements enter or leave the viewport or a container, without scroll listeners or forced layout.- Configure it with
root,rootMargin(to trigger early) andthreshold(the visibility ratios that trigger callbacks). Callunobservewhen a target no longer needs tracking. ResizeObserverreports element size changes from any cause, whichwindowresizeevents cannot.- Do not resize an observed element inside its own
ResizeObservercallback, and prefer CSS container queries for purely visual adaptation. - Mutation, Intersection, Resize and Performance observers share one pattern: callback,
observe,unobserve,disconnect.
Practice
- (Beginner) Create a long page with ten sections and a fixed table of contents. Use an
IntersectionObserverto highlight the link of the section currently in view.- (Intermediate) Build a component that draws a sine wave on a
<canvas>sized to its container, using aResizeObserveranddevicePixelRatiofor sharp rendering. Place it in a layout with a collapsible sidebar and confirm that it redraws when the sidebar toggles, even though the window size does not change.- (Interview) "How would you implement infinite scrolling, and why is
IntersectionObserverpreferable to a scroll listener?" Hint: compare how often each approach runs code, whether it forces layout, and how a sentinel element simplifies the logic.
<a id="7-graphics-and-rendering-apis"></a>