Walking & searching the DOM
Learn to find elements by walking parent, child, and sibling relationships, searching with CSS selectors, using closest and matches, and choosing between live and static DOM collections.
- 01Walk the treeUse parent, child, and sibling properties without being surprised by text nodes.
- 02Search with selectorsUse querySelector, querySelectorAll, matches, and closest accurately.
- 03Choose collection APIsTell live HTMLCollections from static NodeLists and avoid loop-removal bugs.
Find your place in the tree
The DOM is a tree. Once JavaScript has one node in that tree, it can either walk by relationships or search by a selector. Walking means “go to my parent,” “go to my first child,” or “go to the next sibling.” Searching means “find the first element that matches this CSS selector” or “find all matches.”
This lesson builds on The browser environment and The DOM tree. You will use the same mental model, but now you will move around it on purpose: from a clicked button to its card, from a menu to its items, and from a list of matching elements to safe loops.
Imagine you are sitting at a family dinner. You can point to your parent, your children, or the person next to you without searching the whole room. DOM walking is that local: start from one node and follow a relationship.
- In real life: Go up to your parent
- In JavaScript:
parentElement - In real life: Look at the kids
- In JavaScript:
children,firstElementChild,lastElementChild - In real life: The person sitting next to you
- In JavaScript:
nextElementSiblingorpreviousElementSibling
Where the analogy stops: Families have names and stories; DOM nodes are just objects linked by parent/child/sibling references. Also, node properties can include text nodes that do not feel like family members at all.
DOM navigation means finding nodes by relationship (parentElement, children, siblings) or by a selector (querySelector, closest, and friends).
The biggest gotcha is the difference between elements and nodes. Elements are tags like ul and li. Nodes also include whitespace text and comments. The names tell you which world you are in: children and firstElementChild are element-only; childNodes and firstChild include all node types.
Parents, children & siblings
INTERACTIVERelationship properties are the fastest way to move when you already have a nearby node. They do not parse selector strings. They simply follow links the browser already keeps in the tree.
| Goal | Element-only property | All-node property |
|---|---|---|
| Go up | parentElement → an element or null | parentNode → any parent node, possibly the document |
| Go down | children, firstElementChild, lastElementChild | childNodes, firstChild, lastChild |
| Go sideways | nextElementSibling, previousElementSibling | nextSibling, previousSibling |
| Loop safely | Great for tags you can style or click | Use when text/comment nodes matter |
A professional detail worth knowing: document.documentElement is the html element. Its parentNode is the document, but its parentElement is null, because a document is a node, not an element.
Switch between element-only and all-node walking, then step through the same tree.
script
let current = list.firstElementChild;current = current.nextElementSibling;current = current.parentElement;console.log(current.id);Now try the same idea on real nodes created in your browser. The buttons with Element in their names skip whitespace. The shorter node properties can land on a blank text node because the markup has line breaks and indentation between elements.
const list = document.querySelector("#menu");let current = list.firstElementChild;current = current.nextElementSibling;current = current.parentElement;current = current.parentNode;current = current.firstChild;current = current.nextSibling;listStart on the menu. Element buttons skip whitespace; node buttons reveal it.
querySelector & querySelectorAll
INTERACTIVEWhen you do not already have a nearby node, search by CSS selector. querySelector returns the first matching element or null. querySelectorAll returns all matching elements in a static NodeList. That list has forEach, and it does not update when the page changes later.
If you ask a librarian for “the first red JavaScript book,” you get one book. If you ask for “every red JavaScript book,” you get a stack. Selectors work the same way: one match or all matches.
- In real life: Ask for the first matching book
- In JavaScript:
querySelector(selector) - In real life: Ask for every matching book
- In JavaScript:
querySelectorAll(selector) - In real life: Bad description that is not valid library language
- In JavaScript: Invalid selector →
SyntaxError
Where the analogy stops: A librarian may ask clarifying questions. The browser will not: a valid selector with no matches gives null or an empty list, and an invalid selector throws immediately.
Search methods exist on both document and elements. Element searches are scoped to descendants of that element, but the selector is still interpreted as a normal CSS selector. For direct children of the current element, use :scope > li. Without :scope, selectors such as div p can be surprising because the selector pattern is matched in the larger tree before descendants are filtered.
const menu = document.querySelector("#menu");const firstDone = menu.querySelector(".done");const allItems = menu.querySelectorAll("li");const directItems = menu.querySelectorAll(":scope > li");const dataItems = menu.querySelectorAll("[data-id]");3 matches. querySelector marks the first one.
querySelector gives the first match or null. querySelectorAll gives a static NodeList of every match and throws only for invalid selector syntax.document.getElementById("id")exists ondocument,DocumentFragment, andShadowRoot, not ordinary elements.document.getElementsByName("field")is document-level.- Elements do have
querySelectorandquerySelectorAll.
closest & matches
INTERACTIVETwo selector methods live on elements themselves. matches(selector) returns a boolean: does this exact element match? closest(selector) starts with the element itself, then walks upward through parents until a match is found. If nothing matches, it returns null.
Suppose every table has a host. You start with yourself: “Am I the host?” If not, you ask your parent, then grandparent. That is closest: it checks the current element first, then moves up.
- In real life: Check whether you are the person named on the invitation
- In JavaScript:
el.matches(selector) - In real life: If not, ask your parent, then grandparent
- In JavaScript:
el.closest(selector) - In real life: No one in your line matches
- In JavaScript:
null
Where the analogy stops: closest walks only through ancestors. It never searches siblings, children, cousins, or the whole page.
This is especially useful for event delegation. A click may land on an icon, a span, or text inside a button. Instead of wiring every tiny piece, you start from event.target and climb to the button or card that matters.
button.addEventListener("click", (event) => { const element = event.target; const isItem = element.matches("li"); const row = element.closest("li"); const menu = element.closest("#menu");});Click any element in the mini-page, then change the selector.
matches answers yes/no for the clicked element. closest starts with that same element, then walks through parents until a selector matches.getElementsBy*
Before selector APIs were common, browsers had methods such as getElementsByTagName and getElementsByClassName. They still exist, and you will see them in older code and performance- sensitive code. Their biggest difference is not the search syntax; it is the collection they return.
| Method | Returns | Collection behavior |
|---|---|---|
document.querySelector(selector) | First matching element or null | No collection |
document.querySelectorAll(selector) | Static NodeList of elements | Snapshot; has forEach |
element.getElementsByTagName(name) | Live HTMLCollection | Updates automatically; no forEach |
element.getElementsByClassName(name) | Live HTMLCollection | Updates automatically; no forEach |
document.getElementsByName(name) | Live NodeList in browsers | Document-level name search |
HTMLCollection is iterable in modern browsers, so for...of works, but it does not have forEach. Convert with Array.from(collection) when you want array methods or a safe snapshot.
Live vs static collections
INTERACTIVEA security camera shows the room right now. If someone walks in, the count changes. A photograph stays the same even if the room changes. Live collections are the camera; static lists are the photograph.
- In real life: Live security-camera feed
- In JavaScript: Live
HTMLCollectionfromgetElementsBy* - In real life: A photograph taken once
- In JavaScript: Static
NodeListfromquerySelectorAll - In real life: People leave while you count
- In JavaScript: Removing from a live collection while looping
Where the analogy stops: A live collection is not a video file you can rewind. It is an object whose contents are recalculated from the current DOM whenever you look at it.
Live collections can be convenient, but they are dangerous when you remove elements while looping forward. Removing item 0 makes item 1 slide into index 0, then the loop increments to index 1 and skips it. Use Array.from(live), use querySelectorAll, or loop backward.
const liveItems = menu.getElementsByTagName("li");const staticItems = menu.querySelectorAll("li");menu.append(document.createElement("li"));console.log(liveItems.length);console.log(staticItems.length); for (const item of liveItems) { item.remove();}Live length: 3. Static length: 3.
getElementsByTagName keeps watching the page. querySelectorAll is a snapshot taken at the moment you call it.element.childrendocument.getElementsByClassName("done")menu.getElementsByTagName("li")menu.querySelectorAll("li")element.childNodeselement.parentElementbutton.closest("li")document.querySelector(".done")
Classify each API by the kind of value it returns.
Where you’ll use this
DOM navigation appears in real interfaces whenever code starts from a small event target and needs the larger piece of UI around it. A todo app might click a delete icon and find the todo row. A menu might click a nested span and find the button. A product grid might click “Save” and find the card id.
function findCardAction(event) { const button = event.target.closest("button[data-action]"); if (!button) return null; const card = button.closest("[data-card-id]"); return { action: button.dataset.action, cardId: card?.dataset.cardId ?? null, };}Notice the defensive shape: search upward, bail out with null when nothing matches, then read dataset from the element that actually owns the data. This keeps the handler working even if the button later gains an icon or extra wrapper.
Common misconceptions
- “Child means element.” Not always.
firstChildmay be whitespace text; usefirstElementChildfor tags. - “No selector match throws.” A valid selector with no match returns
nullor an empty list. Invalid selector syntax throwsSyntaxError. - “NodeList means static.”
querySelectorAllreturns a staticNodeList, butchildNodesis a liveNodeList. - “HTMLCollection is an array.” It is array-like and iterable, but it lacks array methods such as
forEach. - “closest searches inside the element.” It checks the element itself and ancestors only.
- “Element search is always perfectly scoped.” Use
:scopewhen the selector must refer to the element you called from.
Practice exercises
5 EXERCISESType the exact output. This mirrors the live/static length display in the playground.
const liveLength = 3;
const staticLength = 2;
console.log(liveLength + " / " + staticLength);const liveLength = 3;
const staticLength = 2;
console.log(liveLength + " / " + staticLength);The program prints the first value, then a slash surrounded by spaces, then the second value: 3 / 2.
Which selector finds direct li children of the current element?
const answer = ":scope > li";
console.log(answer);:scope > li means li elements whose direct parent is the element you called querySelectorAll on.
Name one safe fix for this live-collection loop.
const items = list.getElementsByTagName("li");
for (let i = 0; i < items.length; i += 1) {
items[i].remove();
}for (const item of Array.from(items)) {
item.remove();
}Array.from(items) takes a static snapshot first. Removing an element no longer changes the list being looped over.
Which method walks upward until a selector matches?
const row = event.target.closest("li");closest is the upward selector search. It is perfect when an event starts on a nested child.
Write the handler in your editor or console. It should ignore clicks outside action buttons.
menu.addEventListener("click", (event) => {
// Find a button with data-action, then find its li.
});menu.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (!button) return;
const item = button.closest("li[data-id]");
console.log(button.dataset.action, item?.dataset.id ?? null);
});The handler works even when the click lands inside the button, because closest climbs to the meaningful elements.
Check your understanding
8 QUESTIONSQuestion 1 of 8What does
parentElementreturn when there is no parent element?Choose an answer to see the explanation.
Question 2 of 8Which property skips whitespace text nodes?
Choose an answer to see the explanation.
Question 3 of 8What does this print?
Read the code, then predictconst found = null; console.log(found === null);Choose an answer to see the explanation.
Question 4 of 8Which statement about
querySelectorAllis accurate?Choose an answer to see the explanation.
Question 5 of 8What does
closestcheck first?Choose an answer to see the explanation.
Question 6 of 8What length does this skipped-list snippet print?
Read the code, then predictconst skipped = ["Home", "About"]; console.log(skipped.length);Choose an answer to see the explanation.
Question 7 of 8Which API exists on
document, DocumentFragment, and ShadowRoot, but not ordinary elements?Choose an answer to see the explanation.
Question 8 of 8How do you make
element.querySelectorAllmean direct children of that element?Choose an answer to see the explanation.
Key takeaways
- Use element-only walking properties when you want tags, not whitespace text nodes.
querySelectorreturns the first match ornull;querySelectorAllreturns a staticNodeList.matchestests one element;closestchecks that element and then its ancestors.getElementsByTagName,getElementsByClassName, andchildrenare live element collections.- Snapshot live collections before removing, or loop backward.
DOM navigation is choosing the right path through the page tree: local relationships when you are nearby, selector searches when you need to find matches.
Up next: Node properties & contents.