JAVASCRIPT / THE BROWSER: DOM, EVENTS, AND STORAGE
Traversing parents, children, and siblings
Move around the DOM from any element to its parents, children, and siblings, and know why whitespace in your markup shows up in some walks but not others.
What you will learn
- Walk up with parentElement and jump to a matching ancestor with closest(selector)
- Use children and firstElementChild to skip the whitespace text nodes childNodes returns
- Move sideways with nextElementSibling/previousElementSibling and stop when you get null
- Spread children into an array before calling map, filter, or indexOf on it
Understanding Traversing parents, children, and siblings
Every element you already hold carries references to its neighbours in the tree, so traversal is following pointers rather than searching the document again. From one element you can go up to the node that contains it, down to the nodes it contains, and sideways to the nodes sharing its parent. This matters when an element is handed to you rather than chosen by you, and the thing you need is the label beside it or the row around it, which no fixed selector can describe.
The properties come in two parallel families, and the difference between them is the core of the topic. Node-level properties (childNodes, firstChild, lastChild, nextSibling, previousSibling, parentNode) see every node, including the text nodes the HTML parser builds from the line breaks and indentation between your tags, and comment nodes too. Element-level properties (children, firstElementChild, lastElementChild, nextElementSibling, previousElementSibling, childElementCount, parentElement) filter that same view down to elements only. In any indented markup, firstChild is usually a whitespace #text node, which is why the element-level names should be your default.
Climbing by chaining parentElement is exact but fragile: wrap the markup in one extra div and every hop count is wrong. closest(selector) starts at the element itself and climbs until an ancestor matches, so it expresses intent, the row this cell lives in, instead of a distance. Traversal reports absence as null, at the top of the tree and past the first and last sibling, so a sibling walk ends when it reaches null rather than throwing. Also remember that children is a live HTMLCollection that tracks the tree as it changes, so copy it with a spread when you want a stable list.
document.body.innerHTML = `
<ul id="menu">
<li>Tea</li>
<li class="active">Coffee</li>
<li>Juice</li>
</ul>`;
const menu = document.getElementById('menu');
const active = menu.querySelector('.active');
console.log(menu.childNodes.length, menu.children.length);
console.log(menu.firstChild.nodeName, menu.firstElementChild.nodeName);
console.log(active.previousSibling.nodeName, active.previousElementSibling.textContent);
console.log(active.nextElementSibling.textContent);
console.log(active.parentElement.id, active.closest('ul') === menu);Node traversal and element traversal are two views of the same tree, and the whitespace text nodes in your markup are what separates them.
Worked examples
Climbing with parentElement versus closest
Shows the difference between counting hops upward and asking for the nearest matching ancestor.
document.body.innerHTML = `<table id="grid"><tbody>
<tr data-id="7"><td><span class="cell">Ana</span></td></tr>
</tbody></table>`;
const cell = document.querySelector('.cell');
console.log(cell.parentElement.tagName);
console.log(cell.parentElement.parentElement.tagName);
console.log(cell.closest('tr').dataset.id);
console.log(cell.closest('table').id);
console.log(cell.closest('.cell').tagName);
console.log(cell.closest('form'));Example explained
Line 1cell.parentElement is the td directly containing the span; each parentElement is exactly one level up, never more.
Line 2closest('tr') climbs past the td until a tr matches, so the row is reached without knowing how many levels away it is.
Line 3closest('.cell') returns the span itself, because closest tests the starting element before moving upward.
Line 4closest('form') returns null since no ancestor matches; traversal signals a miss with null instead of throwing.
What childNodes actually contains
Lists every child node of a paragraph with its type, exposing the text and comment nodes children hides.
document.body.innerHTML = '<p id="bio">Hi <b>Ana</b>, welcome<!-- draft --></p>';
const bio = document.getElementById('bio');
for (const node of bio.childNodes) {
console.log(node.nodeType, node.nodeName, JSON.stringify(node.nodeValue));
}
console.log('elements:', bio.children.length, 'nodes:', bio.childNodes.length);Example explained
Line 1nodeType 3 marks a text node and 8 a comment node, while 1 marks an element, so childNodes mixes three kinds of thing.
Line 2The b element prints null for nodeValue because only text and comment nodes carry character data.
Line 3children reports 1 and childNodes reports 4 on the same parent: the two text runs and the comment exist either way, they are just filtered out.
Line 4Iterating childNodes with for...of works because a NodeList is iterable.
Walking sideways until null
Collects the following siblings of an element and finds its position among its parent's element children.
document.body.innerHTML = `
<ol id="steps">
<li>mix</li>
<li id="here">bake</li>
<li>cool</li>
<li>eat</li>
</ol>`;
const here = document.getElementById('here');
const after = [];
for (let n = here.nextElementSibling; n; n = n.nextElementSibling) {
after.push(n.textContent);
}
console.log(after.join(' -> '));
console.log([...here.parentElement.children].indexOf(here));
console.log(here.previousSibling.nodeName, here.previousElementSibling.nodeName);
console.log(here.parentElement.lastElementChild.nextElementSibling);Example explained
Line 1The loop condition is just the node itself: it stops when nextElementSibling finally yields null past the last item.
Line 2[...children] copies the live HTMLCollection into an array so indexOf can report that here is the second element child.
Line 3previousSibling is the whitespace #text node from the indentation, while previousElementSibling is the LI that was meant.
Line 4The last element child has no element after it, so nextElementSibling is null even though a trailing whitespace text node exists.
Important notes
closest() checks the starting element first, so el.closest('div') returns el itself when el is a div rather than its parent div.
parentNode and parentElement agree for ordinary elements and diverge only at the top: for <html>, parentNode is the document while parentElement is null.
Common mistakes
Using firstChild or nextSibling on indented HTML and getting a whitespace #text node, so the next line throws because text nodes have no classList, tagName, or querySelector.
Treating children as an array: el.children.filter(...) throws "el.children.filter is not a function" because an HTMLCollection has no array methods.
Forgetting the ends of the tree: previousElementSibling on a first child is null, so reading .textContent from it throws "Cannot read properties of null".
Try it yourself
Change, predict, then run
Paste a three-row table into a blank page, grab one td with querySelector, then log the text of every cell in that same row by going up with closest('tr') and looping over its children. Then walk sideways from your cell with nextElementSibling until the loop hits null, and log how many cells followed it.
Open the JavaScript workspaceCheck your understanding
A <ul> written across several lines with three indented <li> items reports childNodes.length as 7 but children.length as 3. What explains the gap?
- children counts only direct children while childNodes also counts grandchildren such as text inside each <li>.
- children is a live collection that has not refreshed yet, so it temporarily undercounts.
- The line breaks and indentation between the tags are text nodes, and childNodes counts every node type while children keeps only elements.
- childNodes counts the opening and closing tags of the <ul> itself as extra nodes.
Show answer
The four extra entries are whitespace text nodes the parser created from the newlines and indentation between the <li> tags, and childNodes includes text and comment nodes while children filters to elements. The first option is tempting because depth feels like a plausible difference, but both collections only ever describe the immediate level; crossing depth needs querySelectorAll or a recursive walk.