Traversing the DOM
Once you have a reference to an element, you rarely stay put. You need to reach its container, loop over its children, jump to the next item in a list, or climb upward to find an enclosing card or form. This walking from one node to another is called DOM traversal, and it lets you write code that adapts to structure instead of hard-coding a selector for every single element. The DOM gives you two parallel sets of properties — node-based and element-based — and knowing which to reach for is the key to clean, bug-free traversal.
Going up: parentElement and parentNode
Every node knows its parent. There are two properties for this. parentNode returns whatever contains the node, which could be an element, the document, or a document fragment. parentElement returns the parent only if it is an element, and null otherwise. For day-to-day work on the page, parentElement is the safer, more predictable choice.
const item = document.querySelector('.item');
console.log(item.parentElement); // the containing element
console.log(item.parentNode); // usually the same element
// At the top of the tree they differ:
console.log(document.documentElement.parentNode); // the document
console.log(document.documentElement.parentElement); // null
Going down: children and childNodes
To reach the nodes inside an element, you again have a node version and an element version. childNodes is a live NodeList of every child, including the whitespace and line breaks between your tags (those become text nodes). children is an HTMLCollection of only the child elements. Almost always you want children.
<ul id="list">
<li>First</li>
<li>Second</li>
</ul>
const list = document.getElementById('list');
console.log(list.childNodes.length); // 5 — includes whitespace text nodes
console.log(list.children.length); // 2 — just the <li> elements
for (const li of list.children) {
console.log(li.textContent);
}
Output:
5
2
First
Second
Indentation and newlines in your HTML are real text nodes. That is why
childNodesreturns surprising counts. Stick tochildrenwhenever you only care about elements and the whitespace problem disappears.
First and last child elements
To grab an edge of the children directly, use firstElementChild and lastElementChild. These skip text nodes for you, unlike the older firstChild / lastChild, which return whatever node comes first — often an empty text node.
const list = document.getElementById('list');
console.log(list.firstElementChild.textContent); // "First"
console.log(list.lastElementChild.textContent); // "Second"
console.log(list.firstChild); // a text node (the whitespace)
console.log(list.firstChild === list.firstElementChild); // false
Sideways: next and previous siblings
Siblings share the same parent. nextElementSibling and previousElementSibling move to the adjacent element in either direction, returning null when there is nothing there. As with everything else, prefer the *Element* variants over nextSibling / previousSibling so you don’t trip over whitespace text nodes.
const first = document.querySelector('#list li');
console.log(first.nextElementSibling.textContent); // "Second"
console.log(first.previousElementSibling); // null (it's first)
The interactive demo below uses sibling traversal to move a highlight up and down a list without re-querying the DOM each time:
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: system-ui, sans-serif; }
li { padding: 6px 10px; list-style: none; }
.active { background: #2563eb; color: #fff; border-radius: 6px; }
button { margin-right: 6px; }
</style>
</head>
<body>
<ul id="menu">
<li class="active">Dashboard</li>
<li>Projects</li>
<li>Settings</li>
<li>Profile</li>
</ul>
<button id="up">Move up</button>
<button id="down">Move down</button>
<script>
const move = (dir) => {
const current = document.querySelector('#menu .active');
const next = dir === 'down'
? current.nextElementSibling
: current.previousElementSibling;
if (next) {
current.classList.remove('active');
next.classList.add('active');
}
};
document.getElementById('up').addEventListener('click', () => move('up'));
document.getElementById('down').addEventListener('click', () => move('down'));
</script>
</body>
</html>
Climbing with closest, and testing with matches
Manually chaining .parentElement to find an ancestor is fragile. closest(selector) does it for you: starting from the element itself and walking up, it returns the nearest ancestor (including the element) that matches a CSS selector, or null if none match. Its companion matches(selector) returns true/false for whether a single element matches a selector — perfect for filtering inside event handlers.
closest shines with event delegation. You attach one listener to a container, then use closest to figure out which inner element was actually clicked, even if the user clicked a nested icon or label.
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: system-ui, sans-serif; }
.card { border: 1px solid #ddd; border-radius: 8px; padding: 12px; margin: 8px; width: 180px; }
.card button { margin-top: 8px; }
#status { margin: 8px; font-weight: 600; }
</style>
</head>
<body>
<div id="board">
<div class="card" data-id="a"><strong>Card A</strong><br><button>Open <span>›</span></button></div>
<div class="card" data-id="b"><strong>Card B</strong><br><button>Open <span>›</span></button></div>
</div>
<p id="status">Click a card's button.</p>
<script>
document.getElementById('board').addEventListener('click', (event) => {
// Only react to clicks on (or inside) a button.
if (!event.target.matches('button, button *')) return;
// Climb to the enclosing card and read its data.
const card = event.target.closest('.card');
document.getElementById('status').textContent =
`Opened card "${card.dataset.id}"`;
});
</script>
</body>
</html>
Quick reference
| Property / method | Returns | Skips text nodes? |
|---|---|---|
parentElement | Parent element or null | Yes |
parentNode | Parent node (element, document, fragment) | No |
children | HTMLCollection of child elements | Yes |
childNodes | NodeList of all child nodes | No |
firstElementChild / lastElementChild | Edge child element | Yes |
nextElementSibling / previousElementSibling | Adjacent element or null | Yes |
closest(sel) | Nearest matching ancestor or null | n/a |
matches(sel) | Boolean — does this element match? | n/a |
Best Practices
- Default to the element-aware properties (
children,firstElementChild,nextElementSibling) so stray whitespace text nodes never break your logic. - Reach for
closest()instead of chaining.parentElementrepeatedly — it is shorter, clearer, and survives small markup changes. - Pair
closest()with a single delegated listener on a container rather than attaching one listener per item; it scales better and works for elements added later. - Use
matches()to guard event handlers so you only act on the elements you intend to. - Remember that
childrenandchildNodesare live collections — convert with[...el.children]before mutating the DOM during iteration to avoid skipping items. - Always check for
null: sibling andclosestlookups returnnullat the edges of the tree, and reading a property ofnullthrows.