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 treeGo to a parent, a child, or a sibling, and know when you might land on blank text instead of a tag.
- 02Search with selectorsFind elements with
querySelector,querySelectorAll,matches, andclosest. - 03Pick the right kind of listTell a live list from a snapshot, and remove items in a loop without skipping any.
Find your place in the tree
The browser turns your HTML into a tree of objects. This tree is called the DOM. Each object in the tree is a node. Once your code holds one node, it can reach other nodes in two ways:
- Walk. Follow a link from the node you have: "go to my parent", "go to my first child", or "go to the next sibling". A sibling is a node with the same parent.
- Search. Describe what you want with a CSS selector, such as
lior.done, and let the browser find it. You can ask for the first match or for every match.
This lesson builds on The browser environment and The DOM tree. Here you move around the tree on purpose. For example, you go from a clicked button up to its card, or from a menu down to its items.
You sit at a family dinner. You can point to your parent, your children, or the brother next to you. You don't need to search the whole room. You start from yourself and follow one family link.
- In real life: Point to your parent
- In JavaScript:
parentElement - In real life: Point to your children
- In JavaScript:
children,firstElementChild,lastElementChild - In real life: Point to the brother or sister next to you
- In JavaScript:
nextElementSiblingorpreviousElementSibling
DOM navigation means getting from one node to another. You either follow a family link (parentElement, children, siblings) or search with a selector (querySelector, closest, and similar methods).
One difference trips up many people: elements versus nodes.
- An element is a tag, such as
ulorli. - A node is anything in the tree. That includes elements, but also text and comments. Even the spaces and line breaks between your tags become text nodes. We call these blank ones whitespace text nodes.
The property name tells you which kind you get. Names with Element in them, and children, give only elements. childNodes and firstChild give every kind of node.
Parents, children & siblings
INTERACTIVEFamily links are the quickest way to move when the node you want is close by. The browser already keeps these links in the tree. So it just follows one link. It does not have to read a selector.
How to read this table: each row is one direction. The middle column skips text and comments. The last column includes them.
| Where you want to go | Elements only | Every kind of node |
|---|---|---|
| Up to the parent | parentElement gives the parent element, or null if the parent is not an element. | parentNode gives any parent node. At the very top, that is the document. |
| Down to the children | children, firstElementChild, and lastElementChild give only tags. | childNodes, firstChild, and lastChild also give text and comments. |
| Sideways to a sibling | nextElementSibling and previousElementSibling skip blank text. | nextSibling and previousSibling can land on blank text. |
| When to use them | Use these for tags you style or click. That is most code. | Use these only when text or comments matter to you. |
The top of the tree has a surprise. document.documentElement is the html element. Its parent is the document object. The document is a node, but it is not an element. So:
parentNodeofhtmlis thedocument.parentElementofhtmlisnull, because there is no parent element.
const html = document.documentElement;
console.log(html.parentElement === null);
console.log(html.parentNode === document);Line 1 saves the html element in html. Line 2 prints true: it has no parent element. Line 3 prints true: its parent node is the document.
The same thing happens at every dead end. The last child has no next sibling, so nextElementSibling gives null. Check for null before you read a property from the result.
Pick elements only or every kind of node, then step through the same walk.
script
let current = list.firstElementChild;current = current.nextElementSibling;current = current.parentElement;console.log(current.id);Now walk real nodes in your browser. The buttons with Element in their names skip whitespace text nodes. The other buttons can land on one, because the mini page has line breaks and spaces between its tags.
const list = document.querySelector("#menu");let current = list.firstElementChild;current = current.nextElementSibling;current = current.parentElement;current = current.parentNode;current = current.firstChild;current = current.nextSibling;listYou start on the menu. Buttons with Element in the name skip blank text. The others can land on it.
querySelector & querySelectorAll
INTERACTIVEWhen the node you want is not close by, search for it with a CSS selector. A selector is the pattern CSS uses to pick elements:
lipicks everylitag..donepicks every element with the classdone.#menupicks the element with the idmenu.
There are two search methods:
querySelector(selector)gives the first matching element. If nothing matches, it givesnull.querySelectorAll(selector)gives every matching element in a list called aNodeList. This list has aforEachmethod. It is static: it is a snapshot, so it does not change when the page changes later.
querySelector stops at the first match. querySelectorAll collects every match into a list.You ask a librarian for "the first red JavaScript book". You get one book. You ask for "every red JavaScript book". You get a stack of books.
- 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: Ask in words the librarian can't understand
- In JavaScript: A broken selector throws a
SyntaxError
You can call both methods on document or on one element. On an element, the search only gives back elements inside it. These are its descendants: its children, their children, and so on.
But the selector itself still looks at the whole page. So menu.querySelectorAll("div p") can match a p inside menu even when the div is outside menu.
To mean "the element I called this on", write :scope. For example, :scope > li means li elements whose parent is that element.
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 gives the first one.
querySelector gives, has a thicker outline. Only a broken selector throws an error.const missing = document.querySelector("#missing");
const noItems = document.querySelectorAll("#missing");
console.log(missing === null);
console.log(noItems.length);
try {
document.querySelector("li[");
} catch (error) {
console.log(error.name);
}The page has no element with the id missing.
- Line 3 prints
true. A good selector with no match givesnull. - Line 4 prints
0. The list fromquerySelectorAllis empty. - Line 6 uses a broken selector,
li[, with no closing bracket. The browser throws an error instead of giving an empty result.tryandcatchcatch the error, and line 8 prints its name:SyntaxError.
getElementById("id")works ondocument. It does not work on ordinary elements. (It also works onDocumentFragmentandShadowRoot, two special node holders you'll meet later.)getElementsByName("field")works only ondocument.- Every element has
querySelectorandquerySelectorAll.
closest & matches
INTERACTIVEEvery element has two more selector methods:
matches(selector)asks one yes-or-no question: does this exact element match? It givestrueorfalse.closest(selector)checks the element itself first. If that doesn't match, it checks the parent, then the grandparent, and so on up the tree. It gives the first element that matches. If none match, it givesnull.
An invitation arrives with a name on it. First you ask, "Is it me?" If not, you ask your parent, then your grandparent. You stop at the first person it names.
- In real life: Are you the person named on the invitation?
- In JavaScript:
el.matches(selector) - In real life: If not, ask your parent, then your grandparent
- In JavaScript:
el.closest(selector) - In real life: No one in your family line is named
- In JavaScript:
null
closest asks "is this an li?" about the clicked element first, then about each parent going up. It stops at the first yes.This is very useful for event delegation. That means you put one click listener on a big container, instead of one listener on every button. Inside the listener, event.target is the exact element the click landed on. It might be an icon, a span, or the text inside a button. So you start from event.target and climb with closest to the button or card you care about.
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 says yes or no about the element you clicked. closest checks that same element first, then each parent going up, until the selector matches.Sometimes you also need to check that one element is inside another. a.contains(b) gives true when b is a itself or is somewhere inside a.
const card = document.querySelector("[data-card-id]");
const button = document.querySelector("#menu button");
console.log(card.contains(button));
console.log(button.contains(card));
console.log(document.contains(card));- Line 1 finds the card: the element with a
data-card-idattribute. Line 2 finds the first button in the menu. - Line 3 prints
true. The button is inside the card. - Line 4 prints
false. The card is not inside the button. The order of the two matters. - Line 5 prints
true. The card is on the page, so the document contains it.
Use contains when an element you found must belong to the part of the page you meant.
Older search methods: getElementsBy*
Before querySelector existed, browsers had methods such as getElementsByTagName and getElementsByClassName. They still work, and you will see them in older code.
The big difference is not how you describe what to find. It is the kind of list you get back. These methods give an HTMLCollection. That is a list of elements that is live: it updates by itself when the page changes.
How to read this table: each row is one search method. The last column says whether its result changes after you get it.
| Method | What it gives back | Does the result change later? |
|---|---|---|
document.querySelector(selector) | The first matching element, or null. | No. It is one element, not a list. |
document.querySelectorAll(selector) | A NodeList of every match. | No. It is a snapshot, and it has forEach. |
element.getElementsByTagName(name) | A live HTMLCollection. | Yes. It updates by itself. It has no forEach. |
element.getElementsByClassName(name) | A live HTMLCollection. | Yes. It updates by itself. It has no forEach. |
document.getElementsByName(name) | A live NodeList of elements with that name attribute. | Yes. It only works on document. |
You can loop over an HTMLCollection with for...of. But it has no forEach, map, or filter, because it is not an array. Array.from(collection) copies it into a real array. Use that when you want array methods, or a copy that won't change.
Live vs static collections
INTERACTIVEA security camera shows the room right now. If someone walks in, the number of people on screen changes. A photograph shows the room at one moment. It stays the same even after the room changes.
- In real life: A live security-camera feed
- In JavaScript: A live
HTMLCollectionfromgetElementsBy* - In real life: A photograph taken once
- In JavaScript: A static
NodeListfromquerySelectorAll - In real life: People leave while you count them on the feed
- In JavaScript: Removing items from a live list while you loop over it
Live lists are handy, but they cause a bug when you remove items while you loop forward. Here is what happens with three items:
- The loop starts at index 0 and removes the first item.
- The live list updates. The old second item slides down to index 0.
- The loop moves on to index 1. It never looks at index 0 again, so it skips the item that slid down.
Three safe fixes:
- Copy the list first with
Array.from(live), then loop over the copy. - Use
querySelectorAll, which gives a snapshot. - Loop backward, from the last index to 0.
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 follows the page. The snapshot from querySelectorAll keeps what it saw when you called it.const live = menu.getElementsByTagName("li");
const snapshot = menu.querySelectorAll("li");
menu.firstElementChild.remove();
console.log(live.length);
console.log(snapshot.length);li. The live list follows the page and now holds 2. The snapshot was taken before, so it still holds 3.The menu starts with three li items. Line 1 gets a live list of them. Line 2 gets a snapshot of them. Line 3 removes the first item from the page.
- Line 4 prints
2. The live list followed the page. - Line 5 prints
3. The snapshot still holds the three items it saw on line 2.
So a saved live list always shows the page as it is now. A saved snapshot can fall behind the page.
element.childrendocument.getElementsByClassName("done")menu.getElementsByTagName("li")menu.querySelectorAll("li")element.childNodeselement.parentElementbutton.closest("li")document.querySelector(".done")
Put each property or method in the group that matches what it gives back.
Where you’ll use this
Real pages often start from the small element that was clicked. Then the code needs the bigger part of the page around it. For example:
- In a to-do app, you click a delete icon. The code finds the to-do row around it.
- In a menu, you click a
spaninside a button. The code finds the button. - In a product grid, you click "Save". The code finds the product card and reads its id.
The helper below does that last job. It uses three tools you may not know yet:
button.dataset.actionreads thedata-actionattribute from the HTML.card?.datasetuses?.(optional chaining). Ifcardisnull, it stops and givesundefinedinstead of throwing an error.?? nullmeans "if the left side isnullorundefined, usenullinstead".
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, };}Line 1 takes the click event. Line 2 climbs from the clicked element to a button with a data-action attribute. Line 3 stops early with null when the click was not on such a button. Line 5 climbs again, to the card. Lines 6 to 9 give back the action and the card id.
This helper keeps working even if the button later gets an icon or an extra wrapper inside it. closest still climbs to the right button.
Tables have their own family links. A table element has rows and tBodies (its body sections), and each row has cells.
const table = document.querySelector("#orders");
console.log(table.rows.length);
console.log(table.rows[0].cells.length);
console.log(table.tBodies[0].rows[1].cells[0].textContent);- Line 2 prints
3.rowsholds the header row and the two body rows. - Line 3 prints
2. The first row has two cells. - Line 4 prints
Toast. It takes the first body section, then its second row, then that row's first cell. You don't need a long selector for this.
Common misconceptions
- "A child is always an element." Not always.
firstChildcan be a whitespace text node. UsefirstElementChildto get a tag. - "A search with no match throws an error." No. A good selector with no match gives
nullor an empty list. Only a broken selector throws aSyntaxError. - "A NodeList is always static." No.
querySelectorAllgives a staticNodeList, butchildNodesis a liveNodeList. - "An HTMLCollection is an array." No. You can loop over it with
for...of, but it has no array methods such asforEach. - "closest searches inside the element." No. It checks the element itself, then goes up through its parents. It never looks down.
- "A search on an element only reads that element." No. The selector still looks at the whole page. Use
:scopewhen the selector must start at the element you called it on.
Practice exercises
5 EXERCISESType the exact output. It looks like the live and static lengths the playground shows.
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 number, then a space, a slash, and a space, then the second number: 3 / 2.
Which selector finds the li elements whose parent is the element you search from?
const answer = ":scope > li";
console.log(answer);:scope > li means li elements whose parent is the element you called querySelectorAll on.
This loop skips items. Name one safe way to fix it.
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) copies the live list into an array first. Removing an element no longer changes the list you loop over.
Which method checks an element, then its parents, until a selector matches?
const row = event.target.closest("li");closest searches upward with a selector. It is the right tool when a click lands on something inside the element you want.
Write the handler in your editor or console. It should do nothing when the click is not on an action button.
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);
});closest climbs from wherever the click landed to the button, then to its li. So the handler works even when the click lands on text inside the button.
Check your understanding
10 QUESTIONSQuestion 1 of 10What does
parentElementgive when the parent is not an element?Choose an answer to see the explanation.
Question 2 of 10Which property skips whitespace text nodes?
Choose an answer to see the explanation.
Question 3 of 10What does this print?
Read the code, then predictconst found = null; console.log(found === null);Choose an answer to see the explanation.
Question 4 of 10Which sentence about
querySelectorAllis true?Choose an answer to see the explanation.
Question 5 of 10What does
closestcheck first?Choose an answer to see the explanation.
Question 6 of 10A buggy forward loop over a live list left two items behind. This array holds their labels. What does it print?
Read the code, then predictconst skipped = ["Home", "About"]; console.log(skipped.length);Choose an answer to see the explanation.
Question 7 of 10Which method works on
documentbut not on ordinary elements?Choose an answer to see the explanation.
Question 8 of 10With
element.querySelectorAll, how do you find only the children whose parent is that element?Choose an answer to see the explanation.
Question 9 of 10What does
card.contains(button)tell you?Choose an answer to see the explanation.
Question 10 of 10What does
table.rows.lengthcount?Choose an answer to see the explanation.
Key takeaways
- Use the properties with
Elementin their names (andchildren) when you want tags, not whitespace text nodes. querySelectorgives the first match ornull.querySelectorAllgives a staticNodeListof every match.matchesasks about one element.closestchecks that element, then each parent going up.getElementsByTagName,getElementsByClassName, andchildrengive live lists that update by themselves.- Before you remove items from a live list in a loop, copy it with
Array.from, or loop backward.
DOM navigation is picking the right path through the page tree: follow family links when the node is close by, and search with a selector when you need to find matches.
Up next: Node properties & contents.