docs/concepts/dom.mdx
How does JavaScript change what you see on a webpage? How do you click a button and see new content appear, or type in a form and watch suggestions pop up? How does a "dark mode" toggle instantly transform an entire page?
// The DOM lets you do things like this:
document.querySelector('h1').textContent = 'Hello, DOM!'
document.body.style.backgroundColor = 'lightblue'
document.getElementById('btn').addEventListener('click', handleClick)
The Document Object Model (DOM) is the bridge between your HTML and JavaScript. It lets you read, modify, and respond to changes in web page content. With the DOM, you can use methods like querySelector() to find elements, getElementById() to grab specific nodes, and addEventListener() to respond to user interactions.
The Document Object Model (DOM) is a programming interface that represents HTML documents as a tree of objects. As specified by the WHATWG DOM Living Standard, when a browser loads a webpage, it parses the HTML and creates the DOM, a live, structured representation that JavaScript can read and modify. Every element, attribute, and piece of text becomes a node in this tree. In short: the DOM is how JavaScript "sees" and changes a webpage.
Think of the DOM like a family tree. At the top sits document (the family historian who knows everyone). Below it is <html> (the matriarch), which has two children: <head> and <body>. Each of these has their own children, grandchildren, and so on.
THE DOM FAMILY TREE
┌──────────┐
│ document │ ← The family historian
│ (root) │ (knows everyone!)
└────┬─────┘
│
┌────┴─────┐
│ <html> │ ← Great-grandma
└────┬─────┘ (the matriarch)
┌─────────────┴─────────────┐
│ │
┌────┴────┐ ┌────┴────┐
│ <head> │ │ <body> │ ← The two branches
└────┬────┘ └────┬────┘ of the family
│ │
┌──────┴──────┐ ┌──────────┼──────────┐
│ │ │ │ │
┌────┴────┐ ┌────┴────┐ ┌───┴───┐ ┌────┴────┐ ┌───┴───┐
│ <title> │ │ <meta> │ │ <nav> │ │ <main> │ │<footer>│
└────┬────┘ └─────────┘ └───┬───┘ └────┬────┘ └───────┘
│ │ │
"My Page" ┌────┴────┐ ┌──┴──┐
(text) │ <ul> │ │<div>│ ← Cousins
└────┬────┘ └──┬──┘
│ │
┌────┼────┐ ...
│ │ │
<li> <li> <li> ← Siblings
Just like navigating a family reunion, the DOM lets you:
| Action | Family Analogy | DOM Method |
|---|---|---|
| Find your parent | "Who's your mom?" | element.parentNode |
| Find your kids | "Where are your children?" | element.children |
| Find your sibling | "Who's your brother?" | element.nextElementSibling |
| Search the whole family | "Where's cousin Bob?" | document.querySelector('#bob') |
Here's the key thing: your HTML file and the DOM are different things:
<Tabs> <Tab title="HTML Source"> ```html <!-- What you wrote (invalid HTML - missing head/body) --> <!DOCTYPE html> <html> Hello, World! </html> ``` </Tab> <Tab title="Resulting DOM"> ```html <!-- What the browser creates (fixed!) --> <!DOCTYPE html> <html> <head></head> <body> Hello, World! </body> </html> ``` </Tab> </Tabs>The browser fixes your mistakes! It adds missing <head> and <body> tags, closes unclosed tags, and corrects nesting errors. The DOM is the corrected version. According to the HTML specification's parsing algorithm, browsers must follow specific error-recovery rules to handle malformed markup consistently across implementations.
DevTools shows you something close to the DOM, but it also shows CSS pseudo-elements (::before, ::after) which are NOT part of the DOM:
/* This creates visual content, but NOT DOM nodes */
.quote::before {
content: '"';
}
Pseudo-elements exist in the render tree (for display), but not in the DOM (for JavaScript). You can't select them with querySelector!
The Render Tree is what actually gets painted to the screen. It excludes:
<!-- These are in the DOM but NOT in the Render Tree -->
<head>...</head> <!-- Never rendered -->
<script>...</script> <!-- Never rendered -->
<div style="display: none">Hidden</div> <!-- Excluded from render -->
DOM Render Tree
┌─────────────────────┐ ┌─────────────────────┐
│ <html> │ │ <html> │
│ <head> │ │ <body> │
│ <title> │ │ <h1> │
│ <body> │ │ "Hello" │
│ <h1>Hello</h1> │ │ <p> │
│ <p>World</p> │ │ "World" │
│ <div hidden> │ │ │
│ Secret! │ │ (no hidden div!) │
│ </div> │ │ │
└─────────────────────┘ └─────────────────────┘
document Object: Your Entry PointThe document object is your gateway to the DOM. It's automatically available in any browser JavaScript. Key properties include document.documentElement (the root <html> element), document.head, document.body, and document.title:
// document is the root of everything
console.log(document) // The entire document
console.log(document.documentElement) // <html> element
console.log(document.head) // <head> element
console.log(document.body) // <body> element
console.log(document.title) // Page title (getter/setter!)
// You can modify the document
document.title = 'New Title' // Changes browser tab title
Everything in the DOM is a Node. But not all nodes are created equal!
Node (base class)
│
┌─────────────────────┼─────────────────────┐
│ │ │
Document Element CharacterData
│ │ │
HTMLDocument ┌────┴────┐ ┌─────┴─────┐
│ │ │ │
HTMLElement SVGElement Text Comment
│
┌────────────────┼────────────────┐
│ │ │
HTMLDivElement HTMLSpanElement HTMLInputElement
...
| Node Type | nodeType | nodeName | Example |
|---|---|---|---|
| Element | 1 | Tag name (uppercase) | <div>, <p>, <span> |
| Text | 3 | #text | Text inside elements |
| Comment | 8 | #comment | <!-- comment --> |
| Document | 9 | #document | The document object |
| DocumentFragment | 11 | #document-fragment | Virtual container |
const div = document.createElement('div')
console.log(div.nodeType) // 1 (Element)
console.log(div.nodeName) // "DIV"
const text = document.createTextNode('Hello')
console.log(text.nodeType) // 3 (Text)
console.log(text.nodeName) // "#text"
console.log(document.nodeType) // 9 (Document)
console.log(document.nodeName) // "#document"
The createElement() and createTextNode() methods create new nodes that you can add to the DOM.
Instead of remembering numbers, use the constants:
Node.ELEMENT_NODE // 1
Node.TEXT_NODE // 3
Node.COMMENT_NODE // 8
Node.DOCUMENT_NODE // 9
Node.DOCUMENT_FRAGMENT_NODE // 11
// Check if something is an element
if (node.nodeType === Node.ELEMENT_NODE) {
console.log('This is an element!')
}
Given this HTML:
<div id="container">
<h1>Title</h1>
<!-- A comment -->
<p>Paragraph</p>
</div>
The actual DOM tree looks like this (including text nodes from whitespace!):
div#container
├── #text (newline + spaces)
├── h1
│ └── #text "Title"
├── #text (newline + spaces)
├── #comment " A comment "
├── #text (newline + spaces)
├── p
│ └── #text "Paragraph"
└── #text (newline)
Before you can manipulate an element, you need to find it. JavaScript provides several methods through the document object:
The getElementById() method is the fastest way to select a single element by its unique ID:
// HTML: <div id="hero">Welcome!</div>
const hero = document.getElementById('hero')
console.log(hero) // <div id="hero">Welcome!</div>
console.log(hero.id) // "hero"
console.log(hero.textContent) // "Welcome!"
// Returns null if not found (not an error!)
const ghost = document.getElementById('nonexistent')
console.log(ghost) // null
getElementsByClassName() and getElementsByTagName() select multiple elements by class or tag name:
// HTML:
// <p class="intro">First</p>
// <p class="intro">Second</p>
// <p>Third</p>
const intros = document.getElementsByClassName('intro')
console.log(intros.length) // 2
console.log(intros[0]) // <p class="intro">First</p>
console.log(intros[0].textContent) // "First"
const allParagraphs = document.getElementsByTagName('p')
console.log(allParagraphs.length) // 3
querySelector() and querySelectorAll() use CSS selectors to find elements. Much more powerful!
// querySelector returns the FIRST match (or null)
const firstButton = document.querySelector('button') // First <button> element
const submitBtn = document.querySelector('#submit') // Element with id="submit"
const firstCard = document.querySelector('.card') // First element with class="card"
const navLink = document.querySelector('nav a.active') // <a class="active"> inside <nav>
const dataItem = document.querySelector('[data-id="123"]') // Element with data-id="123"
// querySelectorAll returns ALL matches (NodeList)
const allButtons = document.querySelectorAll('button') // All <button> elements
const allCards = document.querySelectorAll('.card') // All elements with class="card"
const evenRows = document.querySelectorAll('tr:nth-child(even)') // Every even table row
// By ID
document.querySelector('#main')
// By class
document.querySelector('.active')
document.querySelectorAll('.btn.primary')
// By tag
document.querySelector('header')
document.querySelectorAll('li')
// By attribute
document.querySelector('[type="submit"]')
document.querySelector('[data-modal="login"]')
// Descendant selectors
document.querySelector('nav ul li a')
document.querySelector('.sidebar .widget:first-child')
// Pseudo-selectors (limited support)
document.querySelectorAll('input:not([type="hidden"])')
document.querySelector('p:first-of-type')
This difference trips up many developers. getElementsByClassName() returns a live HTMLCollection, while querySelectorAll() returns a static NodeList:
const liveList = document.getElementsByClassName('item') // LIVE HTMLCollection
const staticList = document.querySelectorAll('.item') // STATIC NodeList
// Start with 3 items
console.log(liveList.length) // 3
console.log(staticList.length) // 3
// Add a new item to the DOM
const newItem = document.createElement('div')
newItem.className = 'item'
document.body.appendChild(newItem)
// Check lengths again
console.log(liveList.length) // 4 (automatically updated!)
console.log(staticList.length) // 3 (still the old snapshot)
| Method | Returns | Live? |
|---|---|---|
getElementById() | Element or null | N/A |
getElementsByClassName() | HTMLCollection | Yes (live) |
getElementsByTagName() | HTMLCollection | Yes (live) |
querySelector() | Element or null | N/A |
querySelectorAll() | NodeList | No (static) |
You can call selection methods on any element, not just document:
const nav = document.querySelector('nav')
// Find links ONLY inside nav
const navLinks = nav.querySelectorAll('a')
// Find the active link inside nav
const activeLink = nav.querySelector('.active')
This is faster than searching the entire document and helps avoid selecting unintended elements.
1. **`getElementById()`** - Direct hashtable lookup, O(1)
2. **`getElementsByClassName()`** - Optimized internal lookup
3. **`getElementsByTagName()`** - Optimized internal lookup
4. **`querySelector()`** - Must parse CSS selector
5. **`querySelectorAll()`** - Must parse and find all matches
However, for most applications, **the difference is negligible**. Use `querySelector/querySelectorAll` for readability unless you're selecting thousands of elements in a loop.
```javascript
// Premature optimization - don't do this
const el1 = document.getElementById('myId')
// This is fine and more readable
const el2 = document.querySelector('#myId')
```
Once you have an element, you can navigate to related elements without querying the entire document.
const ul = document.querySelector('ul')
// Get ALL child nodes (including text nodes!)
const allChildNodes = ul.childNodes // NodeList
// Get only ELEMENT children (usually what you want)
const elementChildren = ul.children // HTMLCollection
// Get specific children
const firstChild = ul.firstChild // First node (might be text!)
const firstElement = ul.firstElementChild // First ELEMENT child
const lastChild = ul.lastChild // Last node
const lastElement = ul.lastElementChild // Last ELEMENT child
<ul>
<li>One</li>
<li>Two</li>
</ul>
What is ul.firstChild? It's NOT the first <li>! It's a text node containing the newline and spaces after <ul>. Use firstElementChild to get the actual <li> element.
</Warning>
const li = document.querySelector('li')
// Direct parent
const parent = li.parentNode // Usually same as parentElement
const parentEl = li.parentElement // Guaranteed to be an Element (or null)
// Find ancestor matching selector (very useful!)
const form = li.closest('form') // Finds nearest ancestor <form>
const card = li.closest('.card') // Finds nearest ancestor with class "card"
// closest() includes the element itself
const self = li.closest('li') // Returns li itself if it matches!
The closest() method is useful for event delegation (see Event Loop for how events are processed):
// Handle clicks on any button inside a card
document.addEventListener('click', (e) => {
const card = e.target.closest('.card')
if (card) {
console.log('Clicked inside card:', card)
}
})
const secondLi = document.querySelectorAll('li')[1]
// Previous/next nodes (might be text!)
const prevNode = secondLi.previousSibling
const nextNode = secondLi.nextSibling
// Previous/next ELEMENTS (usually what you want)
const prevElement = secondLi.previousElementSibling
const nextElement = secondLi.nextElementSibling
// Returns null at the boundaries
const firstLi = document.querySelector('li')
console.log(firstLi.previousElementSibling) // null (no previous sibling)
| Get... | Node Property (includes text) | Element Property (elements only) |
|---|---|---|
| Parent | parentNode | parentElement |
| Children | childNodes | children |
| First child | firstChild | firstElementChild |
| Last child | lastChild | lastElementChild |
| Previous sibling | previousSibling | previousElementSibling |
| Next sibling | nextSibling | nextElementSibling |
// Get all ancestors of an element
function getAncestors(element) {
const ancestors = []
let current = element.parentElement
while (current && current !== document.body) {
ancestors.push(current)
current = current.parentElement
}
return ancestors
}
const deepElement = document.querySelector('.deeply-nested')
console.log(getAncestors(deepElement))
// [<div.parent>, <section>, <main>, ...]
The real power of the DOM is the ability to create, modify, and remove elements dynamically.
Use createElement() to create new elements and createTextNode() to create text nodes:
// Create a new element
const div = document.createElement('div')
const span = document.createElement('span')
const img = document.createElement('img')
// Create a text node
const text = document.createTextNode('Hello, world!')
// Create a comment node
const comment = document.createComment('This is a comment')
// Elements are created "detached" - not yet in the DOM!
console.log(div.parentNode) // null
There are many ways to add elements. Here's a comprehensive overview using methods like appendChild(), insertBefore(), append(), and prepend():
```javascript
const ul = document.querySelector('ul')
const li = document.createElement('li')
li.textContent = 'New item'
ul.appendChild(li)
// <ul>
// <li>Existing</li>
// <li>New item</li> ← Added at the end
// </ul>
```
```javascript
const ul = document.querySelector('ul')
const existingLi = ul.querySelector('li')
const newLi = document.createElement('li')
newLi.textContent = 'First!'
ul.insertBefore(newLi, existingLi)
// <ul>
// <li>First!</li> ← Inserted before
// <li>Existing</li>
// </ul>
```
```javascript
const div = document.querySelector('div')
// append() - adds to the END
div.append('Text', document.createElement('span'), 'More text')
// prepend() - adds to the START
div.prepend(document.createElement('strong'))
```
```javascript
const h1 = document.querySelector('h1')
// Insert BEFORE h1 (as previous sibling)
h1.before(document.createElement('nav'))
// Insert AFTER h1 (as next sibling)
h1.after(document.createElement('p'))
```
For inserting HTML strings, insertAdjacentHTML() is powerful and fast:
const div = document.querySelector('div')
// Four positions to insert:
div.insertAdjacentHTML('beforebegin', '<p>Before div</p>')
div.insertAdjacentHTML('afterbegin', '<p>First child of div</p>')
div.insertAdjacentHTML('beforeend', '<p>Last child of div</p>')
div.insertAdjacentHTML('afterend', '<p>After div</p>')
Visual representation:
<!-- beforebegin -->
<div>
<!-- afterbegin -->
existing content
<!-- beforeend -->
</div>
<!-- afterend -->
```javascript
const element = document.querySelector('.to-remove')
element.remove() // Gone!
```
```javascript
const parent = document.querySelector('ul')
const child = parent.querySelector('li')
parent.removeChild(child)
// Or remove from any element
element.parentNode.removeChild(element)
```
Use cloneNode() to duplicate elements:
const original = document.querySelector('.card')
// Shallow clone (element only, no children)
const shallow = original.cloneNode(false)
// Deep clone (element AND all descendants)
const deep = original.cloneNode(true)
// Clones are detached - must add to DOM
document.body.appendChild(deep)
const clone = original.cloneNode(true)
clone.id = '' // Remove ID
// or
clone.id = 'new-unique-id'
When adding many elements, using a DocumentFragment is more efficient:
// Bad: Multiple DOM updates (potentially multiple reflows)
const ul = document.querySelector('ul')
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li')
li.textContent = `Item ${i}`
ul.appendChild(li) // Modifies live DOM each iteration
}
// Good: Single DOM update
const ul = document.querySelector('ul')
const fragment = document.createDocumentFragment()
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li')
li.textContent = `Item ${i}`
fragment.appendChild(li) // No DOM update (fragment is detached)
}
ul.appendChild(fragment) // Single DOM update!
A DocumentFragment is a lightweight container that:
Three properties let you read and write element content: innerHTML, textContent, and innerText.
const div = document.querySelector('div')
// Read HTML content
console.log(div.innerHTML) // "<p>Hello</p><span>World</span>"
// Write HTML content (parses the string!)
div.innerHTML = '<h1>New Title</h1><p>New paragraph</p>'
// Clear all content
div.innerHTML = ''
Never use innerHTML with user-provided content:
// DANGEROUS! User could inject:
div.innerHTML = userInput // NO!
// Safe alternatives:
div.textContent = userInput // Escapes HTML
// or sanitize the input first
const div = document.querySelector('div')
// Read text (ignores HTML tags)
// <div><p>Hello</p><span>World</span></div>
console.log(div.textContent) // "HelloWorld"
// Write text (HTML is escaped, not parsed)
div.textContent = '<script>alert("XSS")</script>'
// Displays literally: <script>alert("XSS")</script>
// Safe from XSS!
const div = document.querySelector('div')
// innerText respects CSS visibility
// <div>Hello <span style="display:none">Hidden</span> World</div>
console.log(div.textContent) // "Hello Hidden World"
console.log(div.innerText) // "Hello World" (Hidden is excluded!)
| Property | Use Case |
|---|---|
innerHTML | Inserting trusted HTML (never user input!) |
textContent | Setting/getting plain text (safe, fast) |
innerText | Getting text as user sees it (slower, respects CSS) |
// Performance: textContent is faster than innerText
// because innerText must calculate styles
// Setting text content (both work, textContent is faster)
element.textContent = 'Hello' // Preferred
element.innerText = 'Hello' // Works but slower
HTML elements have attributes. JavaScript lets you read, write, and remove them using getAttribute(), setAttribute(), hasAttribute(), and removeAttribute().
const link = document.querySelector('a')
// Get attribute value
const href = link.getAttribute('href')
const target = link.getAttribute('target')
// Set attribute value
link.setAttribute('href', 'https://example.com')
link.setAttribute('target', '_blank')
// Check if attribute exists
if (link.hasAttribute('target')) {
console.log('Link opens in new tab')
}
// Remove attribute
link.removeAttribute('target')
This confuses many developers! Attributes are in the HTML. Properties are on the DOM object.
<input type="text" value="initial">
const input = document.querySelector('input')
// ATTRIBUTE: The original HTML value
console.log(input.getAttribute('value')) // "initial"
// PROPERTY: The current state
console.log(input.value) // "initial"
// User types "new text"...
console.log(input.getAttribute('value')) // Still "initial"!
console.log(input.value) // "new text"
// Reset to attribute value
input.value = input.getAttribute('value')
Key differences:
| Aspect | Attribute | Property |
|---|---|---|
| Source | HTML markup | DOM object |
| Access | get/setAttribute() | Direct property access |
| Updates | Manual only | Automatically with user interaction |
| Type | Always string | Can be any type |
// Attribute is always a string
checkbox.getAttribute('checked') // "" or null
// Property is a boolean
checkbox.checked // true or false
// Attribute (string)
input.getAttribute('maxlength') // "10"
// Property (number)
input.maxLength // 10
Custom data attributes start with data- and are accessible via the dataset property:
<div id="user"
data-user-id="123"
data-role="admin"
data-is-active="true">
John Doe
</div>
const user = document.querySelector('#user')
// Read data attributes (camelCase!)
console.log(user.dataset.userId) // "123"
console.log(user.dataset.role) // "admin"
console.log(user.dataset.isActive) // "true" (string, not boolean!)
// Write data attributes
user.dataset.lastLogin = '2024-01-15'
// Creates: data-last-login="2024-01-15"
// Delete data attributes
delete user.dataset.role
// Check if exists
if ('userId' in user.dataset) {
console.log('Has user ID')
}
Many attributes have direct property shortcuts:
// These pairs are equivalent:
element.id // element.getAttribute('id')
element.className // element.getAttribute('class')
element.href // element.getAttribute('href')
element.src // element.getAttribute('src')
element.title // element.getAttribute('title')
// For class manipulation, use classList (covered next)
JavaScript can modify element styles in several ways using the style property and classList API.
const box = document.querySelector('.box')
// Set individual styles (camelCase!)
box.style.backgroundColor = 'blue'
box.style.fontSize = '20px'
box.style.marginTop = '10px'
// Read styles (only reads INLINE styles!)
console.log(box.style.backgroundColor) // "blue"
console.log(box.style.color) // "" (not inline, from stylesheet)
// Set multiple styles at once
box.style.cssText = 'background: red; font-size: 16px; padding: 10px;'
// Remove an inline style
box.style.backgroundColor = '' // Removes the style
Use getComputedStyle() to read the final computed styles:
const box = document.querySelector('.box')
// Get all computed styles
const styles = getComputedStyle(box)
console.log(styles.backgroundColor) // "rgb(0, 0, 255)"
console.log(styles.fontSize) // "16px"
console.log(styles.display) // "block"
// Get pseudo-element styles
const beforeStyles = getComputedStyle(box, '::before')
console.log(beforeStyles.content) // '"Hello"'
The classList API is the modern way to add/remove/toggle classes:
const button = document.querySelector('button')
// Add classes
button.classList.add('active')
button.classList.add('btn', 'btn-primary') // Multiple at once
// Remove classes
button.classList.remove('active')
button.classList.remove('btn', 'btn-primary') // Multiple at once
// Toggle (add if missing, remove if present)
button.classList.toggle('active')
// Toggle with condition
button.classList.toggle('active', isActive) // Add if isActive is true
// Check if class exists
if (button.classList.contains('active')) {
console.log('Button is active')
}
// Replace a class
button.classList.replace('btn-primary', 'btn-secondary')
// Iterate over classes
button.classList.forEach(cls => console.log(cls))
// Get number of classes
console.log(button.classList.length) // 2
// className is a string (old way)
element.className = 'btn btn-primary' // Replaces ALL classes
element.className += ' active' // Appending is clunky
// classList is a DOMTokenList (modern way)
element.classList.add('active') // Adds without affecting others
element.classList.remove('btn-primary') // Removes specifically
Understanding how browsers render pages helps you write performant code. This is where JavaScript Engines and the browser's rendering engine work together.
When you load a webpage, the browser goes through these steps:
<Steps> <Step title="1. Parse HTML → Build DOM"> Browser reads HTML bytes and constructs the Document Object Model tree. </Step> <Step title="2. Parse CSS → Build CSSOM"> CSS is parsed into the CSS Object Model with styling rules. </Step> <Step title="3. Combine → Render Tree"> DOM + CSSOM merge into the Render Tree (only visible elements). </Step> <Step title="4. Layout (Reflow)"> Calculate exact position and size of every element. </Step> <Step title="5. Paint"> Fill in pixels: colors, borders, shadows, text. </Step> <Step title="6. Composite"> Combine layers into the final image using the GPU. </Step> </Steps>┌─────────────────────────────────────────────────────────────────────────────┐
│ THE CRITICAL RENDERING PATH │
│ │
│ 1. PARSE HTML 2. PARSE CSS 3. BUILD RENDER TREE │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ HTML bytes │ │ CSS bytes │ │ DOM + CSSOM │ │
│ │ ↓ │ │ ↓ │ │ ↘ ↙ │ │
│ │ Characters │ │ Characters │ │ RENDER TREE │ │
│ │ ↓ │ │ ↓ │ │ (visible elements │ │
│ │ Tokens │ │ Tokens │ │ + their styles) │ │
│ │ ↓ │ │ ↓ │ └──────────────────────┘ │
│ │ Nodes │ │ Rules │ │ │
│ │ ↓ │ │ ↓ │ ▼ │
│ │ DOM │ │ CSSOM │ 4. LAYOUT (Reflow) │
│ └──────────────┘ └──────────────┘ ┌──────────────────────┐ │
│ │ Calculate exact │ │
│ │ position & size of │ │
│ │ every element │ │
│ └──────────┬───────────┘ │
│ │ │
│ ▼ │
│ 5. PAINT │
│ ┌──────────────────────┐ │
│ │ Fill in pixels: │ │
│ │ colors, borders, │ │
│ │ shadows, text │ │
│ └──────────┬───────────┘ │
│ │ │
│ ▼ │
│ 6. COMPOSITE │
│ ┌──────────────────────┐ │
│ │ Combine layers into │ │
│ │ final image (GPU) │ │
│ └──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ PIXELS! │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
The Render Tree only contains visible elements:
<!-- NOT in Render Tree -->
<head>...</head> <!-- head is never rendered -->
<script>...</script> <!-- script tags aren't visible -->
<link rel="stylesheet"> <!-- link tags aren't visible -->
<meta> <!-- meta tags aren't visible -->
<div style="display: none">Hi</div> <!-- display:none excluded -->
<!-- IN the Render Tree (even if not seen) -->
<div style="visibility: hidden">Hi</div> <!-- Takes up space -->
<div style="opacity: 0">Hi</div> <!-- Takes up space -->
Layout calculates the geometry of every element: position, size, margins, etc.
Reflow is triggered when:
After layout, the browser paints the pixels: text, colors, images, borders, shadows.
Repaint (without reflow) happens when:
Modern browsers separate content into layers and use the GPU to composite them. This is why some animations are smooth:
/* These properties can animate without reflow/repaint */
transform: translateX(100px); /* GPU accelerated! */
opacity: 0.5; /* GPU accelerated! */
/* These properties cause reflow */
left: 100px; /* Avoid for animations! */
width: 200px; /* Avoid for animations! */
DOM operations can be slow. Here's how to keep your pages fast.
// Bad: Queries the DOM every iteration
for (let i = 0; i < 1000; i++) {
document.querySelector('.result').textContent += i
}
// Good: Query once, reuse
const result = document.querySelector('.result')
for (let i = 0; i < 1000; i++) {
result.textContent += i
}
// Even better: Build string, set once
const result = document.querySelector('.result')
let text = ''
for (let i = 0; i < 1000; i++) {
text += i
}
result.textContent = text
// Avoid: Multiple style changes (may trigger multiple reflows)
element.style.width = '100px'
element.style.height = '200px'
element.style.margin = '10px'
// Better: Single style assignment with cssText
element.style.cssText = 'width: 100px; height: 200px; margin: 10px;'
// Best: Use a CSS class (cleanest and most maintainable)
element.classList.add('my-styles')
// Good: DocumentFragment for multiple elements
const fragment = document.createDocumentFragment()
items.forEach(item => {
const li = document.createElement('li')
li.textContent = item
fragment.appendChild(li)
})
ul.appendChild(fragment) // Single DOM update
Layout thrashing occurs when you alternate between reading and writing DOM properties:
// TERRIBLE: Forces layout on EVERY iteration
boxes.forEach(box => {
const width = box.offsetWidth // Read (forces layout)
box.style.width = (width + 10) + 'px' // Write (invalidates layout)
})
// GOOD: Batch reads, then batch writes
const widths = boxes.map(box => box.offsetWidth) // Read all
boxes.forEach((box, i) => {
box.style.width = (widths[i] + 10) + 'px' // Write all
})
Properties that trigger layout when read:
| Property | What It Returns |
|---|---|
offsetWidth / offsetHeight | Element's layout width/height including borders |
offsetTop / offsetLeft | Position relative to offset parent |
clientWidth / clientHeight | Inner dimensions (padding but no border) |
scrollWidth / scrollHeight | Full scrollable dimensions |
scrollTop / scrollLeft | Current scroll position |
getBoundingClientRect() | Position and size relative to viewport |
getComputedStyle() | All computed CSS values |
// Any of these reads forces a layout calculation
const width = element.offsetWidth // Layout triggered!
const rect = element.getBoundingClientRect() // Layout triggered!
const styles = getComputedStyle(element) // Layout triggered!
Use requestAnimationFrame() to batch visual changes with the browser's render cycle:
// Bad: DOM changes at unpredictable times
window.addEventListener('scroll', () => {
element.style.transform = `translateY(${window.scrollY}px)`
})
// Good: Batch visual changes with next frame
let ticking = false
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
element.style.transform = `translateY(${window.scrollY}px)`
ticking = false
})
ticking = true
}
})
The most dangerous DOM mistake is using innerHTML with untrusted content. This opens your application to Cross-Site Scripting (XSS) attacks.
┌─────────────────────────────────────────────────────────────────────────┐
│ innerHTML: THE SECURITY TRAP │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ❌ DANGEROUS ✓ SAFE │
│ ───────────── ────── │
│ │
│ User Input: User Input: │
│ "" "" │
│ │ │ │
│ ▼ ▼ │
│ element.innerHTML = userInput element.textContent = input │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ BROWSER PARSES │ │ DISPLAYED AS │ │
│ │ AS REAL HTML! │ │ PLAIN TEXT │ │
│ │ │ │ │ │
│ │ 🚨 Script runs! │ │ "
div.innerHTML = `Welcome, ${username}!`
// The malicious script EXECUTES!
// ✓ SAFE - textContent escapes HTML
const username = getUserInput()
div.textContent = `Welcome, ${username}!`
// Displays: Welcome, !
// The HTML is shown as text, not executed
// ✓ SAFE - Create elements programmatically
const username = getUserInput()
const welcomeText = document.createTextNode(`Welcome, ${username}!`)
div.appendChild(welcomeText)
// ✓ CORRECT - Check first or use optional chaining
const element = document.querySelector('.maybe-missing')
if (element) {
element.classList.add('active')
}
// Or use optional chaining (modern)
document.querySelector('.maybe-missing')?.classList.add('active')
```
// ✓ CLEAR - Only element children
console.log(ul.children.length) // 3 (just the <li> elements)
```
// ✓ FAST - Batch reads, then batch writes
const widths = boxes.map(box => box.offsetWidth) // All reads
boxes.forEach((box, i) => {
box.style.width = widths[i] + 10 + 'px' // All writes
})
```
When an event occurs on a DOM element, it doesn't just trigger on that element. It travels through the DOM tree in a process called event propagation. Understanding this helps with event handling.
Every DOM event goes through three phases:
1. CAPTURING PHASE ↓ (from window → target's parent)
2. TARGET PHASE ● (at the target element)
3. BUBBLING PHASE ↑ (from target's parent → window)
// Most events bubble UP by default
document.querySelector('.child').addEventListener('click', (e) => {
console.log('Child clicked')
})
document.querySelector('.parent').addEventListener('click', (e) => {
console.log('Parent also receives the click!') // This fires too!
})
By default, event listeners fire during the bubbling phase (bottom-up). You can listen during the capturing phase (top-down) with the third parameter:
// Bubbling (default) — fires on the way UP
element.addEventListener('click', handler)
element.addEventListener('click', handler, false)
// Capturing — fires on the way DOWN
element.addEventListener('click', handler, true)
element.addEventListener('click', handler, { capture: true })
// Practical example: see the order
document.querySelector('.parent').addEventListener('click', () => {
console.log('1. Parent - capturing')
}, true)
document.querySelector('.child').addEventListener('click', () => {
console.log('2. Child - target')
})
document.querySelector('.parent').addEventListener('click', () => {
console.log('3. Parent - bubbling')
})
// Click on child outputs: 1, 2, 3
You can stop an event from traveling further:
element.addEventListener('click', (e) => {
e.stopPropagation() // Stop bubbling/capturing
// Parent handlers won't fire
})
element.addEventListener('click', (e) => {
e.stopImmediatePropagation() // Stop ALL handlers, even on same element
})
Don't confuse propagation with default behavior:
// Prevent the browser's default action (e.g., following a link)
link.addEventListener('click', (e) => {
e.preventDefault() // Don't navigate
// Event still bubbles unless you also call stopPropagation()
})
// Common use cases:
// - Prevent form submission: form.addEventListener('submit', e => e.preventDefault())
// - Prevent link navigation: link.addEventListener('click', e => e.preventDefault())
// - Prevent context menu: element.addEventListener('contextmenu', e => e.preventDefault())
event.target vs event.currentTargetThis distinction matters for event delegation:
document.querySelector('.parent').addEventListener('click', (e) => {
console.log(e.target) // The element that was actually clicked
console.log(e.currentTarget) // The element with the listener (.parent)
console.log(this) // Same as currentTarget (in regular functions)
})
// If you click on a <span> inside .parent:
// e.target = <span> (what you clicked)
// e.currentTarget = .parent (what has the listener)
Most events bubble, but some don't:
| Event | Bubbles? | Notes |
|---|---|---|
click, mousedown, keydown | Yes | Most user events bubble |
focus, blur | No | Use focusin/focusout for bubbling versions |
mouseenter, mouseleave | No | Use mouseover/mouseout for bubbling versions |
load, unload, scroll | No | Window/document events |
// focus doesn't bubble, but focusin does
form.addEventListener('focusin', (e) => {
console.log('Something in the form was focused:', e.target)
})
Instead of adding listeners to many elements, add one to a parent. This pattern relies on event bubbling. When you click a child element, the event bubbles up to the parent where your listener catches it:
// Bad: Many listeners
document.querySelectorAll('.btn').forEach(btn => {
btn.addEventListener('click', handleClick)
})
// Good: One listener with delegation
document.querySelector('.button-container').addEventListener('click', (e) => {
const btn = e.target.closest('.btn')
if (btn) {
handleClick(e)
}
})
Benefits:
// Using querySelector (returns null if not found)
const element = document.querySelector('.maybe-exists')
if (element) {
element.textContent = 'Found!'
}
// Optional chaining (modern)
document.querySelector('.maybe-exists')?.classList.add('active')
// With getElementById
const el = document.getElementById('myId')
if (el !== null) {
// Element exists
}
Listen for the DOMContentLoaded event to know when the DOM is ready:
// Modern: DOMContentLoaded (DOM ready, images may still be loading)
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM is ready!')
// Safe to query elements
})
// Full page load (including images, stylesheets)
window.addEventListener('load', () => {
console.log('Everything loaded!')
})
// If script is at end of body, DOM is already ready
// <script src="app.js"></script> <!-- Just before </body> -->
// Modern: defer attribute (script loads in parallel, runs after DOM ready)
// <script src="app.js" defer></script>
1. **Fixes errors** — Missing `<head>`, `<body>`, unclosed tags are auto-corrected
2. **Normalizes structure** — Text outside elements gets wrapped properly
3. **Reflects JavaScript changes** — DOM updates don't change your HTML file
```html
<!-- Your HTML file -->
<html>Hello World
<!-- What the DOM looks like -->
<html>
<head></head>
<body>Hello World</body>
</html>
```
View Source shows your file. DevTools Elements shows the DOM.
- The difference is **microseconds** — imperceptible to users
- `querySelector` is more **flexible** and **readable**
- You'd need to call it **thousands of times in a loop** to notice
```javascript
// Both are fine for normal use
document.getElementById('myId')
document.querySelector('#myId')
// Only optimize if you're selecting in a tight loop
// with performance issues (rare!)
```
**Rule:** Write readable code first. Optimize only when you have a measured problem.
```javascript
element.style.display = 'none'
// Element is STILL in the DOM!
console.log(document.getElementById('hidden')) // Element exists
console.log(element.parentNode) // Still has parent
// To actually remove from DOM:
element.remove()
// or
element.parentNode.removeChild(element)
```
- `display: none` → Hidden but in DOM, not in Render Tree
- `visibility: hidden` → Hidden but takes up space, in Render Tree
- `remove()` → Actually removed from DOM
```javascript
const items = document.getElementsByClassName('item')
// DANGER: Removing items changes the collection while looping!
for (let i = 0; i < items.length; i++) {
items[i].remove() // Collection shrinks, indices shift!
}
// Some items are skipped!
// SAFE: Use static NodeList or convert to array
const items = document.querySelectorAll('.item') // Static
items.forEach(item => item.remove()) // Works correctly
```
**Tip:** Prefer `querySelectorAll` (static) unless you specifically need live updates.
document.querySelector and document.getElementById?// getElementById — only IDs
document.getElementById('myId')
// querySelector — any CSS selector
document.querySelector('#myId') // Same as above
document.querySelector('.card:first-child') // Not possible with getElementById
document.querySelector('[data-id="123"]') // Attribute selector
Best answer: "getElementById is marginally faster but querySelector is more flexible. In practice, the performance difference is negligible for most applications. I prefer querySelector for consistency and flexibility." </Accordion>
// ❌ Without delegation — 100 listeners for 100 items
document.querySelectorAll('.item').forEach(item => {
item.addEventListener('click', handleClick)
})
// ✓ With delegation — 1 listener handles all items
document.querySelector('.container').addEventListener('click', (e) => {
const item = e.target.closest('.item')
if (item) handleClick(e)
})
Benefits:
Best answer: Include a code example and mention closest() for finding the target element.
</Accordion>
// ❌ Thrashing — forces layout on EVERY iteration
boxes.forEach(box => {
const width = box.offsetWidth // READ → triggers layout
box.style.width = width + 10 + 'px' // WRITE → invalidates layout
})
// ✓ Batched — one layout calculation
const widths = boxes.map(box => box.offsetWidth) // All reads
boxes.forEach((box, i) => {
box.style.width = widths[i] + 10 + 'px' // All writes
})
Properties that trigger layout: offsetWidth/Height, clientWidth/Height, getBoundingClientRect(), getComputedStyle()
Best answer: Explain the read-write-read-write pattern and show the batched solution. </Accordion>
innerHTML, textContent, and innerText?// <div id="el"><span style="display:none">Hidden</span> Visible</div>
el.innerHTML // "<span style="display:none">Hidden</span> Visible"
el.textContent // "Hidden Visible"
el.innerText // " Visible" (hidden text excluded)
Security warning: Never use innerHTML with user input. It can execute malicious scripts (XSS attacks). Use textContent instead.
Best answer: Mention the XSS security risk with innerHTML. This shows you understand real-world implications. </Accordion>
// ❌ Slow — 1000 DOM updates, 1000 potential reflows
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li')
li.textContent = `Item ${i}`
ul.appendChild(li) // Triggers update each time
}
// ✓ Fast — 1 DOM update
const fragment = document.createDocumentFragment()
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li')
li.textContent = `Item ${i}`
fragment.appendChild(li) // No DOM update (fragment is detached)
}
ul.appendChild(fragment) // Single update
Alternative: Build an HTML string and use innerHTML once (but only with trusted content, never user input).
Best answer: Show the fragment approach and explain WHY it's faster (detached container, single reflow). </Accordion>
<input type="text" value="initial">
const input = document.querySelector('input')
// Attribute — original HTML value
input.getAttribute('value') // "initial" (never changes)
// Property — current live value
input.value // "initial" initially, then whatever user types
// User types "hello"...
input.getAttribute('value') // Still "initial"
input.value // "hello"
| Aspect | Attribute | Property |
|---|---|---|
| Source | HTML markup | DOM object |
| Type | Always string | Can be any type |
| Updates | Manual only | Automatically with interaction |
Best answer: Use the <input value=""> example. It's the clearest demonstration of the difference.
</Accordion>
The DOM is a tree — Elements are nodes with parent, child, and sibling relationships
DOM ≠ HTML source — The browser fixes errors and JavaScript modifies it
Use querySelector — More flexible than getElementById, accepts any CSS selector
Element vs Node properties — Use children, firstElementChild, etc. to skip text nodes
closest() is your friend — Perfect for event delegation and finding ancestor elements
innerHTML is dangerous — Never use with user input; use textContent instead
Attributes vs Properties — Attributes are HTML source, properties are live DOM state
classList over className — Use add/remove/toggle for cleaner class manipulation
Batch DOM operations — Use DocumentFragment or build strings to minimize reflows
Avoid layout thrashing — Don't alternate reading and writing layout properties
</Info>- `childNodes` returns ALL child nodes, including **text nodes** (whitespace!) and **comment nodes**
- `children` returns only **element nodes**
```javascript
// <ul>
// <li>One</li>
// <li>Two</li>
// </ul>
ul.childNodes.length // 5 (text, li, text, li, text)
ul.children.length // 2 (li, li)
```
**Rule:** Use `children` unless you specifically need text/comment nodes.
```javascript
// User input:
div.innerHTML = userInput // Executes malicious code!
// Safe: textContent escapes HTML
div.textContent = userInput // Displays as plain text
```
Always sanitize HTML or use `textContent` for user-provided content.
- `getAttribute('value')` returns the **original HTML attribute** (initial value)
- `.value` property returns the **current value** (what user typed)
```javascript
// <input value="initial">
// User types "hello"
input.getAttribute('value') // "initial"
input.value // "hello"
```
Attributes are the HTML source. Properties are the live DOM state.
```javascript
// <div class="card">
// <button class="btn">Click</button>
// </div>
btn.closest('.card') // Returns the parent div
btn.closest('button') // Returns btn itself (it matches!)
btn.closest('.modal') // null (no matching ancestor)
```
**Super useful for event delegation:**
```javascript
document.addEventListener('click', (e) => {
const card = e.target.closest('.card')
if (card) {
// Handle click inside any card
}
})
```
```javascript
// BAD: Read-write-read-write pattern
boxes.forEach(box => {
const width = box.offsetWidth // READ → forces layout
box.style.width = width + 10 + 'px' // WRITE → invalidates layout
})
// Each iteration forces a new layout calculation!
// GOOD: Batch reads, then batch writes
const widths = boxes.map(b => b.offsetWidth) // All reads
boxes.forEach((box, i) => {
box.style.width = widths[i] + 10 + 'px' // All writes
})
// Only one layout calculation!
```
**In DOM but NOT in Render Tree:**
- `<head>` and its contents
- `<script>`, `<link>`, `<meta>` tags
- Elements with `display: none`
**In Render Tree:**
- Visible elements
- Elements with `visibility: hidden` (still take space)
- Elements with `opacity: 0` (still take space)
Pseudo-elements (`::before`, `::after`) are in the Render Tree but NOT in the DOM.
| Aspect | `getElementsByClassName` | `querySelectorAll` |
|--------|--------------------------|-------------------|
| Returns | HTMLCollection | NodeList |
| **Live** | **Yes** (updates automatically) | **No** (static snapshot) |
| Selector | Class name only | Any CSS selector |
| Speed | Slightly faster | Slightly slower |
```javascript
const live = document.getElementsByClassName('item')
const staticList = document.querySelectorAll('.item')
// Add new element with class="item"
document.body.appendChild(newItem)
live.length // Increased (live collection)
staticList.length // Same (static snapshot)
```
```javascript
const fragment = document.createDocumentFragment()
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li')
li.textContent = `Item ${i}`
fragment.appendChild(li) // No reflow (fragment is detached)
}
ul.appendChild(fragment) // Single reflow!
```
A DocumentFragment is a virtual container. When appended, only its children are inserted. The fragment disappears.
Alternative: Build HTML string and use `innerHTML` once (but sanitize if user input!).