Selecting and Traversing Elements
Finding Needles in the DOM Haystack
You know the DOM is a tree. Now you need to find things in it. Maybe you want to grab a button to attach a click handler. Maybe you need to highlight every paragraph with a certain class. Maybe you need to walk up the tree from a deeply nested element to find its parent container.
JavaScript gives you two categories of tools: selectors (jump directly to what you need) and traversal (walk the tree from one node to its neighbors). Let's master both.
Think of the DOM as a family tree. Selectors are like a search engine — type in a name and jump straight to that person. Traversal is like asking for directions from someone you already found — "who's your parent? your first child? your next sibling?" Both get you where you need to go, but you reach for them in different situations.
Selectors: The Direct Route
querySelector — Your Go-To Method
querySelector takes any CSS selector and returns the first matching element, or null if nothing matches.
// By tag
document.querySelector('h1');
// By class
document.querySelector('.hero-title');
// By ID
document.querySelector('#main-nav');
// By attribute
document.querySelector('[data-active="true"]');
// Complex selectors work too
document.querySelector('nav > ul > li:first-child a');
querySelectorAll — When You Want Everything
querySelectorAll returns all matching elements as a NodeList.
const allButtons = document.querySelectorAll('button');
const activeItems = document.querySelectorAll('.item.active');
// Loop over results
allButtons.forEach(button => {
console.log(button.textContent);
});
querySelectorAll returns a static NodeList. That means if you add a new matching element to the DOM after the query, your NodeList does not update. It's a snapshot, not a live view. This is usually what you want, but it's critical to understand the difference (we'll cover live vs static below).
Scoping Selectors
Both querySelector and querySelectorAll can be called on any element, not just document. When called on an element, they search only within that element's subtree.
const sidebar = document.querySelector('.sidebar');
// Only searches inside the sidebar
const sidebarLinks = sidebar.querySelectorAll('a');
const firstButton = sidebar.querySelector('button');
This is powerful for component-style code where you want to isolate your DOM operations to a specific region of the page.
getElementById — The Fastest Lookup
If you have an element with an id, getElementById is the fastest way to find it. It's optimized internally because IDs must be unique.
const nav = document.getElementById('main-nav');
Note: no # prefix — just the raw ID string. This is the one selector method that doesn't use CSS selector syntax.
getElementsByClassName and getElementsByTagName
These older methods still exist and have one unique property: they return live collections.
const items = document.getElementsByClassName('item');
console.log(items.length); // 3
// Add a new .item to the DOM
const newItem = document.createElement('div');
newItem.className = 'item';
document.body.appendChild(newItem);
// The live collection automatically updates
console.log(items.length); // 4 — it grew!
Iterating over a live HTMLCollection while modifying the DOM can cause infinite loops or skipped elements. If you remove items from the DOM while looping, the collection shrinks under your feet. Convert to an array first with Array.from() or the spread operator.
Live vs Static Collections
This distinction is important enough to warrant its own section.
| Method | Returns | Live? |
|---|---|---|
querySelectorAll | NodeList | Static (snapshot) |
getElementsByClassName | HTMLCollection | Live |
getElementsByTagName | HTMLCollection | Live |
getElementsByName | NodeList | Live |
childNodes | NodeList | Live |
children | HTMLCollection | Live |
// Static — safe to modify DOM while iterating
const staticList = document.querySelectorAll('.item');
// Live — modifying DOM during iteration is dangerous
const liveList = document.getElementsByClassName('item');
// Safe pattern: convert live to array before modifying
Array.from(liveList).forEach(item => {
item.remove(); // safe — we're iterating the array, not the live collection
});
DOM Traversal: Walking the Tree
Once you have a reference to an element, you can navigate to its neighbors using traversal properties. There are two parallel sets — one that includes all node types (text, comments, etc.) and one that only includes elements.
Element-Only Traversal (what you usually want)
const item = document.querySelector('.current-item');
// Parent
item.parentElement; // direct parent element
// Children
item.children; // HTMLCollection of child elements
item.firstElementChild; // first child element
item.lastElementChild; // last child element
item.children.length; // number of child elements (or childElementCount)
// Siblings
item.previousElementSibling; // previous sibling element
item.nextElementSibling; // next sibling element
All-Node Traversal (includes text and comments)
item.parentNode; // parent node (could be document for <html>)
item.childNodes; // NodeList of ALL child nodes
item.firstChild; // first child node (often a whitespace text node!)
item.lastChild; // last child node
item.previousSibling; // previous sibling node
item.nextSibling; // next sibling node
firstChild and firstElementChild are often different. If your HTML has any whitespace between the parent tag and the first child tag, firstChild returns a text node containing that whitespace. firstElementChild skips text nodes and gives you the first actual element. Use the element-only versions unless you specifically need text nodes.
closest — Walk Up the Tree
closest is one of the most useful traversal methods. It walks up from the current element through its ancestors, returning the first one that matches a CSS selector. Returns null if no ancestor matches.
// Given a deeply nested button click
button.addEventListener('click', (e) => {
// Find the nearest ancestor with class "card"
const card = e.target.closest('.card');
// Find the nearest form element
const form = e.target.closest('form');
// It checks the element itself first, then walks up
const self = e.target.closest('button'); // returns the button itself if it matches
});
closest is essential for event delegation (we'll cover that pattern in the next topic). When you listen for clicks on a parent container, closest helps you find which logical item was clicked.
Practical Patterns
Find All Siblings
There's no built-in "get all siblings" method, but it's easy to build:
function getSiblings(element) {
return Array.from(element.parentElement.children)
.filter(child => child !== element);
}
const sibs = getSiblings(document.querySelector('.active'));
Walk the Entire Subtree
Sometimes you need to visit every node in a subtree. TreeWalker is the built-in tool for this:
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_ELEMENT
);
while (walker.nextNode()) {
console.log(walker.currentNode.tagName);
}
Check if an Element Matches a Selector
matches returns true if the element matches the given CSS selector:
const el = document.querySelector('button');
el.matches('.primary'); // true if it has class "primary"
el.matches('[disabled]'); // true if it has the disabled attribute
el.matches('form button'); // true if it's a button inside a form
- 1Use querySelector/querySelectorAll for most lookups — they accept any CSS selector
- 2querySelectorAll returns a static NodeList; getElementsBy* returns live collections
- 3Use element-only traversal (parentElement, children, firstElementChild) to avoid whitespace text nodes
- 4closest walks up the tree and checks the element itself first
- 5Scope selectors to a parent element to limit search area
| What developers do | What they should do |
|---|---|
| Using getElementById with a # prefix: getElementById('#nav') getElementById takes a raw ID string, not a CSS selector. The # is only for querySelector. Using it with getElementById returns null because no element has id='#nav' | Plain ID string: getElementById('nav') |
| Using firstChild expecting an element firstChild returns the first node of any type, which is often a whitespace text node. firstElementChild skips text and comment nodes | Using firstElementChild for the first element |
| Looping over a live HTMLCollection while removing elements Live collections shrink as you remove matching elements, causing you to skip items or loop infinitely. Array.from creates a static copy safe for iteration | Convert to an array first with Array.from(), then loop |