Skip to content

Selecting and Traversing Elements

beginner14 min read

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.

Mental Model

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);
});
Common Trap

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.

Quiz
What does document.querySelector('.card') return if there are 5 elements with the class 'card'?

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!
Live collections can bite you

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.

MethodReturnsLive?
querySelectorAllNodeListStatic (snapshot)
getElementsByClassNameHTMLCollectionLive
getElementsByTagNameHTMLCollectionLive
getElementsByNameNodeListLive
childNodesNodeListLive
childrenHTMLCollectionLive
// 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
});
Quiz
You call getElementsByClassName('todo'), then add a new element with class 'todo' to the page. What happens to the 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
Common Trap

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.

Quiz
What does element.closest('.wrapper') do if the element itself has the class 'wrapper'?

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
Key Rules
  1. 1Use querySelector/querySelectorAll for most lookups — they accept any CSS selector
  2. 2querySelectorAll returns a static NodeList; getElementsBy* returns live collections
  3. 3Use element-only traversal (parentElement, children, firstElementChild) to avoid whitespace text nodes
  4. 4closest walks up the tree and checks the element itself first
  5. 5Scope selectors to a parent element to limit search area
What developers doWhat 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