../

DOM manipulation

Selecting, walking, building and measuring the document with the standard DOM APIs, typed with TypeScript's lib.dom. Events live on their own sheet: Events.

Selecting

MethodReturnsLive?Notes
querySelector(sel)Element | nulln/afirst match in document order
querySelectorAll(sel)NodeListOf<Element>staticsnapshot; has forEach, iterable
getElementById(id)HTMLElement | nulln/aDocument, DocumentFragment, ShadowRoot only
getElementsByClassName(c)HTMLCollectionOf<Element>livespace-separated list means "has all"
getElementsByTagName(t)HTMLCollectionOf<…>livetyped by tag name like querySelector
getElementsByName(n)NodeListOf<HTMLElement>liveDocument only; matches name="…"
el.closest(sel)Element | nulln/awalks up from el itself to the root
el.matches(sel)booleann/awould el be selected by sel?
el.contains(other)booleann/ainclusive: el.contains(el) is true
document.forms, form.elementsHTMLCollection…livenamed access: form.elements.namedItem("email")

Selectors are any valid CSS selector; an invalid one throws a SyntaxError DOMException. Calling on an element scopes the search to its descendants, but the selector is still matched against the whole document, so use :scope to anchor it.

// HTMLElement
const nav = document.querySelector("nav");
// children
const links = nav?.querySelectorAll(":scope > a");
// HTMLLIElement
const first = document.querySelector("li");
// HTMLElement
const byId = document.getElementById("app");
 
// ids starting with a digit or holding odd chars
// need escaping
const id = "1st-item";
const el = document.querySelector(`#${CSS.escape(id)}`);

NodeList vs HTMLCollection

AspectNodeListHTMLCollection
Containsany Node (text, comments, elements)elements only
Live?childNodes live; querySelectorAll staticalways live
forEachyesno
for…of, spreadyesyes
Named accessnonamedItem(id) or coll.name
entries/keys/valuesyesno
To arrayArray.from(list) or [...list]same

Traversing

Element-onlyAny node (incl. text, comments)Notes
parentElementparentNodehtml.parentElement is null, parentNode is document
childrenchildNodesHTMLCollection vs live NodeList
firstElementChildfirstChildwhitespace between tags is a text node
lastElementChildlastChild
nextElementSiblingnextSibling
previousElementSiblingpreviousSibling
childElementCountchildNodes.length
closest(sel)getRootNode()root is document or a ShadowRoot
function* ancestors(el: Element): Generator<Element> {
  for (let p = el.parentElement; p; p = p.parentElement) {
    yield p;
  }
}
 
const cell = document.querySelector("td");
// HTMLTableRowElement
const row = cell?.closest("tr");
const next = row?.nextElementSibling;     // Element | null

For large walks (every text node, say) use document.createTreeWalker(root, NodeFilter.SHOW_TEXT) instead of recursion.

Creating & inserting

MethodAcceptsNotes
document.createElement(tag)tag nametyped: createElement("a") is HTMLAnchorElement
document.createTextNode(s)stringstrings passed to append do this for you
parent.append(...nodes)nodes and stringsat the end; returns undefined
parent.prepend(...nodes)nodes and stringsat the start
el.before(...), el.after(...)nodes and stringsas siblings
el.replaceWith(...)nodes and stringsswaps el out
parent.replaceChildren(...)nodes and stringsempty args clears all children
el.remove()nonedetaches from its parent
parent.appendChild(node)one nodeolder; returns the node
parent.insertBefore(node, ref)one node, ref or nullolder; null ref appends
node.cloneNode(true)deep flagcopies attributes, not listeners
el.insertAdjacentElement(pos, el)position, elementreturns the element or null
el.insertAdjacentHTML(pos, html)position, HTML stringparses; XSS risk with untrusted input
el.insertAdjacentText(pos, s)position, stringsafe text

Inserting a node that is already in the document moves it (it is never duplicated). parent.moveBefore(node, ref) moves while keeping state such as focus, iframe loads and running animations; Chrome 133+ and Firefox 144+, not Safari yet.

const li = document.createElement("li");   // HTMLLIElement
li.className = "item";
li.append("Buy ", Object.assign(
  document.createElement("strong"), { textContent: "milk" },
));
document.querySelector("ul")?.append(li);

insertAdjacent positions

<!-- beforebegin -->
<p>
  <!-- afterbegin -->
  existing content
  <!-- beforeend -->
</p>
<!-- afterend -->

beforebegin and afterend need the element to have a parent.

Templates and fragments

A DocumentFragment is a parentless container: appending it moves its children in one insertion and leaves the fragment empty. <template> content is an inert fragment (scripts don't run, images don't load) that you clone per use.

<template id="row-tpl">
  <li class="row"><span class="name"></span></li>
</template>
type User = { id: string; name: string };
 
function renderUsers(
  list: Element,
  tpl: HTMLTemplateElement,
  users: readonly User[],
): void {
  const frag = document.createDocumentFragment();
  for (const u of users) {
    // importNode is generic: returns DocumentFragment
    const row = document.importNode(tpl.content, true);
    row.querySelector(".name")!.textContent = u.name;
    row.firstElementChild?.setAttribute("data-id", u.id);
    frag.append(row);
  }
  list.replaceChildren(frag); // clear + insert once
}
 
const tpl = document.querySelector<HTMLTemplateElement>(
  "#row-tpl",
);
const list = document.querySelector("ul");
if (tpl && list) {
  renderUsers(list, tpl, [{ id: "1", name: "Ada" }]);
}

tpl.content.cloneNode(true) works too but is typed Node, so it needs a cast.

Content

PropertyReadsWritesLayout?Safe with user input?
textContentall text, incl. <script>/<style> and hiddenreplaces children with one text nodenoyes
innerTextrendered text as CSS shows it (skips hidden, applies text-transform)text, newlines become <br>yes, forces layoutyes
innerHTMLserialized HTML of childrenparses and replaces childrennono
outerHTMLserialized HTML incl. the elementreplaces the element itselfnono
nodeValuetext of a text/comment node; null on elementssamenoyes
const out = document.querySelector("output");
declare const userInput: string;
 
if (out) {
  out.textContent = userInput;         // ✅ always text
  // out.innerHTML = userInput;        // ❌ XSS
}

setHTML and the Sanitizer API

el.setHTML(html, { sanitizer }) parses and sanitizes in one step, always stripping script-capable content (<script>, <iframe>, <object>, on* handler attributes) even if your config allows it. Chrome 146+ and Firefox 148+, not Safari, so not Baseline: feature-detect and keep a fallback (for example DOMPurify). TypeScript 5.9's lib.dom has no types for it yet. setHTMLUnsafe(html) (Baseline 2025) parses declarative shadow DOM but does not sanitize by default.

type SetHTML = (html: string) => void;
 
function safeSet(el: Element, html: string): void {
  const setHTML = (el as Element & { setHTML?: SetHTML })
    .setHTML;
  if (typeof setHTML === "function") {
    setHTML.call(el, html);
  } else {
    el.textContent = html; // or DOMPurify.sanitize
  }
}

Trusted Types

Trusted Types (Baseline 2026) make the browser reject plain strings at injection sinks. Turn it on with the CSP header require-trusted-types-for 'script', then only values produced by a policy from trustedTypes.createPolicy(name, { createHTML }) are accepted by innerHTML and friends. Types come from @types/trusted-types, not lib.dom.

Attributes & dataset

MethodReturnsNotes
getAttribute(n)string | nullraw attribute text
setAttribute(n, v)voidv is a string; numbers are coerced
removeAttribute(n)voidno error if missing
hasAttribute(n)boolean
toggleAttribute(n, force?)booleanfor boolean attributes; returns new state
getAttributeNames()string[]

Properties vs attributes

Attributes are the HTML source text; properties are the live JS state. Many reflect each other, but not all.

AttributePropertyDifference
value on <input>value / defaultValueattribute is the initial value; property is current
checkedchecked / defaultCheckedsame split as value
href, srchref, srcproperty is the resolved absolute URL
class, forclassName, htmlForrenamed because they are JS keywords
disabled, hiddendisabled, hiddenpresence of the attribute means true
aria-expandedariaExpandedproperty is string | null, not boolean
tabindextabIndexproperty is a number, -1 when unset on non-focusables
const input = document.querySelector("input");
if (input) {
  input.value = "typed";                 // current value
  input.getAttribute("value");           // still the initial
  input.toggleAttribute("disabled", true);
  input.disabled;                        // true
}

dataset

data-* attributes map to el.dataset (DOMStringMap): drop data-, and each dash plus lowercase letter becomes the uppercase letter. Values are always strings.

AttributeProperty
data-iddataset.id
data-user-iddataset.userId
data-sort-by-datedataset.sortByDate
const card = document.querySelector<HTMLElement>(".card");
if (card) {
  const id: string | undefined = card.dataset.userId;
  card.dataset.state = "open";   // sets data-state="open"
  card.dataset.count = String(3); // strings only
  delete card.dataset.state;     // removes the attribute
}

Style off data attributes in CSS with [data-state="open"].

Classes & styles

classList memberReturnsNotes
add(...names)voidignores ones already present
remove(...names)voidignores missing
toggle(name, force?)booleanforce makes it add-only or remove-only
contains(name)boolean
replace(old, new)booleanfalse if old wasn't present
value, lengthstring, numbersame as className
const panel = document.querySelector<HTMLElement>(".panel");
if (panel) {
  const open = panel.classList.toggle("open");
  panel.classList.toggle("dim", !open);
 
  panel.style.transform = "translateX(0)"; // camelCase
  panel.style.setProperty("--gap", "8px"); // custom prop
  panel.style.setProperty("color", "red", "important");
  panel.style.removeProperty("color");
 
  const cs = getComputedStyle(panel);
  const gap = cs.getPropertyValue("--gap").trim();
  const width = parseFloat(cs.width); // "320px" to 320
}
APIWhat it holds
el.styleinline style="" only; empty strings for anything else
el.style.cssTextthe whole inline style as one string
style.setProperty(n, v, p?)the only way to set --custom props or !important
getComputedStyle(el, pseudo?)read-only resolved values after the cascade; forces style recalc

Prefer toggling classes or data attributes over writing many inline styles; keep inline style for values computed at runtime (positions, custom props).

Measuring & scrolling

el.getBoundingClientRect() returns a DOMRect (x, y, width, height, top, right, bottom, left) relative to the viewport, fractional, including CSS transforms. Add scrollX/scrollY for page coordinates.

PropertyIncludesNotes
offsetWidth, offsetHeightcontent + padding + border + scrollbarintegers; ignores transforms
clientWidth, clientHeightcontent + padding, no border or scrollbar0 for inline elements
scrollWidth, scrollHeightfull content incl. overflowed part + padding>= clientWidth
offsetTop, offsetLeftposition relative to offsetParentnearest positioned ancestor
scrollTop, scrollLefthow far the element is scrolledwritable
clientTop, clientLeftborder width (roughly)
innerWidth, innerHeightviewport incl. scrollbar (on window)documentElement.clientWidth excludes it
const box = document.querySelector("#box");
if (box) {
  const r = box.getBoundingClientRect();
  const pageTop = r.top + window.scrollY;
  const inView = r.bottom > 0 && r.top < innerHeight;
 
  box.scrollIntoView({
    behavior: "smooth",   // "auto" | "instant" | "smooth"
    // "start" (default) | "nearest" | …
    block: "center",
    inline: "nearest",
  });
 
  window.scrollTo({ top: 0, behavior: "smooth" });
  window.scrollBy({ top: 200 });
}
 
const scroller = document.querySelector(".log");
if (scroller) {
  // stick to bottom
  scroller.scrollTop = scroller.scrollHeight;
  const atEnd =
    scroller.scrollHeight - scroller.scrollTop
      - scroller.clientHeight < 1;
}

behavior: "auto" follows CSS scroll-behavior. The scrollend event (Baseline 2025) tells you when a smooth scroll has finished. Use scroll-margin-top in CSS to offset sticky headers.

Observers

All three take a callback, then observe(target, options?), and stop with unobserve(target) or disconnect(). Callbacks run asynchronously and batched.

ObserverWatchesCallback entriesReplaces
IntersectionObserverelement visibility in a root/viewportIntersectionObserverEntry[]scroll + getBoundingClientRect
ResizeObserverelement box size changesResizeObserverEntry[]resize on window
MutationObserverDOM tree, attribute, text changesMutationRecord[]deprecated mutation events

IntersectionObserver

const onVisible: IntersectionObserverCallback = (
  entries, observer,
) => {
  for (const e of entries) {
    if (!e.isIntersecting) continue;
    const img = e.target as HTMLImageElement;
    img.src = img.dataset.src ?? "";
    observer.unobserve(img); // load once
  }
};
 
const io = new IntersectionObserver(onVisible, {
  root: null,              // viewport
  rootMargin: "200px 0px", // start early
  threshold: [0, 0.5, 1],  // ratios that fire callbacks
});
document.querySelectorAll("img[data-src]")
  .forEach((img) => io.observe(img));

ResizeObserver

const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const [box] = entry.contentBoxSize; // inline = width
    const el = entry.target as HTMLElement;
    el.classList.toggle("narrow", box.inlineSize < 480);
  }
});
const card = document.querySelector(".card");
if (card) ro.observe(card, { box: "border-box" });

Changing the observed size inside the callback can loop; the browser stops after one pass and reports "ResizeObserver loop completed with undelivered notifications".

MutationObserver

const mo = new MutationObserver((records) => {
  for (const r of records) {
    if (r.type === "childList") {
      r.addedNodes.forEach((n) => {
        if (n instanceof HTMLElement) n.classList.add("new");
      });
    } else if (r.type === "attributes") {
      console.info(r.attributeName, r.oldValue);
    }
  }
});
 
mo.observe(document.body, {
  childList: true,
  subtree: true,
  attributes: true,
  attributeFilter: ["class", "data-state"],
  attributeOldValue: true,
});
const pending = mo.takeRecords(); // flush before disconnect
mo.disconnect();

Typing the DOM

CallType
querySelector("input")HTMLInputElement | null
querySelector("svg")SVGSVGElement | null
querySelector("#email")Element | null
querySelector("input.email")Element | null (not a bare tag)
querySelector<HTMLInputElement>("#email")HTMLInputElement | null, unchecked
getElementById("email")HTMLElement | null, no generic
querySelectorAll("li")NodeListOf<HTMLLIElement>
createElement("canvas")HTMLCanvasElement
closest("form")HTMLFormElement | null

The tag-name overloads look up HTMLElementTagNameMap (plus the SVG and MathML maps). Any other selector string falls back to Element; the generic parameter is an assertion in disguise.

// 1. `as` / generic: trusts you, no runtime check
const a = document.querySelector(
  "#email",
) as HTMLInputElement;
const b = document.querySelector<HTMLInputElement>("#email");
 
// 2. instanceof: checks at runtime, narrows
const c = document.querySelector("#email");
if (c instanceof HTMLInputElement) {
  c.value = "x"; // HTMLInputElement
}
 
// 3. helper that checks and throws
function $<T extends Element>(
  sel: string,
  type: new () => T,
  root: ParentNode = document,
): T {
  const el = root.querySelector(sel);
  if (!(el instanceof type)) {
    throw new Error(`${sel} is not a ${type.name}`);
  }
  return el;
}
const email = $("#email", HTMLInputElement);
Null handlingUse when
if (!el) returnelement may legitimately be absent
el?.focus()fire-and-forget calls
throw / helper abovethe markup is required; fail loudly
el! non-null assertionyou are certain; the error later is a vague TypeError

instanceof fails across realms (an element from an <iframe> has a different HTMLInputElement); check el.tagName === "INPUT" there.

Custom elements

class UserCard extends HTMLElement {
  static observedAttributes = ["name"];
  attributeChangedCallback(
    _n: string, _old: string | null, next: string | null,
  ): void {
    this.textContent = next ?? "";
  }
}
customElements.define("user-card", UserCard);
 
declare global {
  interface HTMLElementTagNameMap {
    "user-card": UserCard;
  }
}
 
const card = document.querySelector("user-card"); // UserCard

tsconfig

{
  "compilerOptions": {
    "lib": ["ES2024", "DOM", "DOM.Iterable"],
    "strict": true
  }
}

DOM.Iterable adds for…of, entries() and friends on NodeList, FormData and the like. From TypeScript 6.0 it is folded into DOM and listing it is harmless. Leave DOM out of server-only projects so window and document are type errors there.

Performance

DoWhy
read all layout values, then write all stylesa read after a write forces a synchronous layout
do visual writes inside requestAnimationFrameone write per frame, right before paint
build off-DOM (fragment, replaceChildren, append(...nodes))one insertion instead of N
animate transform and opacitycompositor only; top/width trigger layout
content-visibility: auto on long off-screen sectionsskips their layout and paint until near the viewport
observers instead of scroll/resize handlersno work on the main thread per scroll tick
delegate events to a containerone listener instead of thousands

Layout-forcing reads include offset*, client*, scroll*, getBoundingClientRect(), getComputedStyle(), innerText and focus().

const items = [...document.querySelectorAll<HTMLElement>(
  ".item",
)];
 
// ❌ thrashing: read, write, read, write…
for (const el of items) {
  el.style.height = `${el.offsetWidth / 2}px`;
}
 
// ✅ batch reads, then writes in the next frame
const widths = items.map((el) => el.offsetWidth);
requestAnimationFrame(() => {
  items.forEach((el, i) => {
    el.style.height = `${widths[i] / 2}px`;
  });
});
.comment {
  content-visibility: auto;
  /* placeholder height */
  contain-intrinsic-size: auto 200px;
}

content-visibility is Baseline 2024 (Safari 18). Content it skips is still in the DOM and findable with find-in-page.

References