Selecting and Traversing the DOM

▶ Try It Yourself

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
Note: 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.
Tip: Always prefer 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.
Warning: Never iterate a live HTMLCollection while adding or removing elements matching the same selector — this creates an infinite loop or skips elements. Always convert to a static array first: 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

▶ Try It Yourself

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

🧠 Test Yourself

You click a deeply nested <span> inside a <li class="card">. Inside the click handler, which method finds the parent .card element regardless of how deep the click target is?





▶ Try It Yourself