The Document Object Model (DOM) is the browser’s live, in-memory representation of your HTML as a tree of objects. Every HTML element, text node, comment, and attribute is a node in this tree. JavaScript can read and modify the tree at any time — and the browser instantly reflects every change on screen. Before you can change anything, you need to select the right elements. In this lesson you will master every selection method, understand the difference between live and static collections, and learn to traverse the tree using parent, child, and sibling relationships.
Selection Methods
| Method | Returns | Live? | Best For |
|---|---|---|---|
document.getElementById('id') |
Element or null |
N/A | Fastest — unique ID lookup |
document.querySelector(css) |
First matching Element or null |
No | Any CSS selector — first match |
document.querySelectorAll(css) |
Static NodeList | No | All matches — use most often |
document.getElementsByClassName('cls') |
Live HTMLCollection | Yes | Legacy — avoid in new code |
document.getElementsByTagName('tag') |
Live HTMLCollection | Yes | Legacy — avoid in new code |
element.closest(css) |
Nearest ancestor matching selector | N/A | Walk up tree — event delegation |
element.matches(css) |
Boolean | N/A | Check if element matches selector |
Tree Traversal Properties
| Property | Returns | Includes Text Nodes? |
|---|---|---|
element.parentElement |
Parent element | No |
element.children |
Live HTMLCollection of child elements | No |
element.firstElementChild |
First child element | No |
element.lastElementChild |
Last child element | No |
element.nextElementSibling |
Next sibling element | No |
element.previousElementSibling |
Previous sibling element | No |
element.childNodes |
Live NodeList — all node types | Yes |
element.childElementCount |
Number of child elements | No |
Live vs Static Collections
| Type | Method | Behaviour |
|---|---|---|
| Live HTMLCollection | getElementsByClassName, children |
Automatically updates when DOM changes — can cause infinite loops |
| Static NodeList | querySelectorAll |
Snapshot at call time — safe to iterate while mutating DOM |
| Convert to array | [...nodeList] or Array.from(nodeList) |
Enables all array methods: map, filter, reduce |
querySelector and querySelectorAll accept any valid CSS selector — including complex ones like '.card:not(.disabled) > .title' or 'input[type="checkbox"]:checked'. They also work on any element, not just document: card.querySelector('.title') searches only within card‘s subtree, which is faster and more precise than a document-wide search.querySelectorAll over getElementsByClassName or getElementsByTagName. The static NodeList from querySelectorAll is safe to iterate while modifying the DOM, accepts full CSS selectors, and is consistent. For single elements, querySelector is cleaner and more powerful than getElementById since it accepts any CSS selector.const items = [...document.getElementsByClassName('item')], then iterate items.Basic Example
// Assume this HTML:
// <nav id="main-nav">
// <ul class="nav-list">
// <li class="nav-item active"><a href="/">Home</a></li>
// <li class="nav-item"><a href="/about">About</a></li>
// <li class="nav-item"><a href="/contact">Contact</a></li>
// </ul>
// </nav>
// ── Selecting elements ────────────────────────────────────────────────────
const nav = document.getElementById('main-nav');
const firstLink= document.querySelector('.nav-item a'); // first match
const allItems = document.querySelectorAll('.nav-item'); // static NodeList
const activeEl = document.querySelector('.nav-item.active'); // combined selector
console.log(allItems.length); // 3
// ── Convert NodeList to array for array methods ───────────────────────────
const links = [...document.querySelectorAll('.nav-item a')];
const hrefs = links.map(a => a.getAttribute('href'));
console.log(hrefs); // ['/', '/about', '/contact']
// ── Scoped search — only inside nav ──────────────────────────────────────
const navLinks = nav.querySelectorAll('a'); // only searches within nav
console.log(navLinks.length); // 3
// ── matches — test an element against a selector ─────────────────────────
allItems.forEach(item => {
if (item.matches('.active')) {
console.log('Active:', item.textContent.trim());
}
});
// ── closest — walk up to find nearest ancestor ───────────────────────────
const link = document.querySelector('.nav-item a');
const item = link.closest('.nav-item'); // immediate parent li
const list = link.closest('ul'); // grandparent ul
const outer= link.closest('.does-not-exist'); // null — not found
console.log(item.className); // 'nav-item active'
console.log(list.className); // 'nav-list'
// ── Tree traversal ────────────────────────────────────────────────────────
const ul = document.querySelector('.nav-list');
console.log(ul.childElementCount); // 3
console.log(ul.firstElementChild.textContent.trim()); // 'Home'
console.log(ul.lastElementChild.textContent.trim()); // 'Contact'
const secondItem = ul.firstElementChild.nextElementSibling;
console.log(secondItem.textContent.trim()); // 'About'
console.log(secondItem.parentElement === ul); // true
// ── Walk all siblings ─────────────────────────────────────────────────────
let current = ul.firstElementChild;
while (current) {
console.log(current.textContent.trim());
current = current.nextElementSibling;
}
// Home / About / Contact
How It Works
Step 1 — The DOM Is a Live Tree of Node Objects
When the browser parses HTML, it creates a tree of Node objects in memory. Element nodes (HTMLElement) represent tags; text nodes represent text between tags; comment nodes represent HTML comments. Every node has a parent (except the root), zero or more children, and optional siblings. JavaScript interacts with this tree through the DOM API.
Step 2 — querySelector Accepts Any CSS Selector
document.querySelector(css) runs the CSS selector engine against the DOM tree and returns the first matching element, or null. querySelectorAll(css) returns all matches as a static NodeList. The CSS selector can be as complex as needed — attribute selectors, pseudo-classes, combinators — the same syntax you use in stylesheets.
Step 3 — Scoped Queries Are Faster and Safer
Calling element.querySelector(css) on a specific element only searches within that element’s subtree. This is faster than a document-wide search for large DOMs, and prevents accidental matches elsewhere on the page. Always scope searches to the smallest relevant container.
Step 4 — closest Walks Up; querySelector Walks Down
element.closest(css) starts at the element itself and walks up the ancestor chain, returning the first ancestor (or the element itself) matching the selector. This is the essential tool for event delegation — when a click hits a child element and you need to find the parent container it belongs to.
Step 5 — Convert Collections to Arrays for Full Power
Both NodeList and HTMLCollection only support forEach (NodeList) and index access. Converting with [...nodeList] gives a true array with map, filter, reduce, sort, and every other array method. This is always the first step when you need to process a group of elements.
Real-World Example: Accessible Tab Component
// tabs.js — selecting and traversing to build a tab widget
class Tabs {
#container;
#tabs;
#panels;
constructor(selector) {
this.#container = document.querySelector(selector);
if (!this.#container) throw new Error(`No element found for "${selector}"`);
this.#tabs = [...this.#container.querySelectorAll('[role="tab"]')];
this.#panels = [...this.#container.querySelectorAll('[role="tabpanel"]')];
this.#init();
}
#init() {
// Activate first tab
this.#activate(0);
// Keyboard navigation
this.#container.addEventListener('keydown', (e) => {
const current = this.#tabs.findIndex(t => t === document.activeElement);
if (current === -1) return;
const map = {
ArrowRight: (current + 1) % this.#tabs.length,
ArrowLeft: (current - 1 + this.#tabs.length) % this.#tabs.length,
Home: 0,
End: this.#tabs.length - 1,
};
if (e.key in map) {
e.preventDefault();
this.#activate(map[e.key]);
this.#tabs[map[e.key]].focus();
}
});
// Click activation
this.#tabs.forEach((tab, i) => {
tab.addEventListener('click', () => this.#activate(i));
});
}
#activate(index) {
this.#tabs.forEach((tab, i) => {
tab.setAttribute('aria-selected', i === index ? 'true' : 'false');
tab.setAttribute('tabindex', i === index ? '0' : '-1');
});
this.#panels.forEach((panel, i) => {
panel.hidden = i !== index;
});
}
}
// HTML structure expected:
// <div class="tabs">
// <div role="tablist">
// <button role="tab">Tab 1</button>
// <button role="tab">Tab 2</button>
// </div>
// <div role="tabpanel">Content 1</div>
// <div role="tabpanel">Content 2</div>
// </div>
const tabs = new Tabs('.tabs');
Common Mistakes
Mistake 1 — querySelector returns null — not guarding before use
❌ Wrong — crashes if element is absent:
document.querySelector('.btn').addEventListener('click', fn);
// TypeError if .btn doesn't exist
✅ Correct — guard with optional chaining:
document.querySelector('.btn')?.addEventListener('click', fn);
Mistake 2 — Iterating a live collection while mutating it
❌ Wrong — live collection may skip or loop infinitely:
const items = document.getElementsByClassName('item');
for (const item of items) {
item.classList.remove('item'); // mutates the collection mid-loop!
}
✅ Correct — snapshot first:
const items = [...document.querySelectorAll('.item')];
items.forEach(item => item.classList.remove('item'));
Mistake 3 — Document-wide query when a scoped one would do
❌ Imprecise — searches entire document:
const title = document.querySelector('.title'); // which .title?
✅ Correct — scope to the relevant container:
const title = card.querySelector('.title'); // only inside this card
Quick Reference
| Task | Code |
|---|---|
| Select by ID | document.getElementById('id') |
| Select first match | document.querySelector('.class') |
| Select all matches | [...document.querySelectorAll('css')] |
| Scoped search | el.querySelectorAll('css') |
| Walk up to ancestor | el.closest('.container') |
| Test selector | el.matches('.active') |
| Parent element | el.parentElement |
| All child elements | [...el.children] |
| Next sibling | el.nextElementSibling |