JavaScript DOM Traversal Table
| Property / method | Gives you | Field note |
|---|---|---|
parentNode | The containing element | May be a document or fragment, not just an element |
parentElement | The containing ELEMENT | null when the parent is the document - the practical pick |
children | Element children only | Skips text nodes - unlike childNodes |
childNodes | ALL child nodes | Includes whitespace TEXT nodes - the classic surprise |
firstElementChild | First element child | Beats firstChild (often a text node) |
nextElementSibling | Next element sibling | Element-aware; nextSibling stops at whitespace |
closest('.sel') | Nearest ancestor matching | Walks UP including self - event delegation's partner |
matches('.sel') | Self test against selector | The filter half of delegation |
contains(node) | Descendant test | True for the element itself too - guard with a !== b |
remove() | Remove self | Modern; replaces the parent.removeChild(child) dance |
DOM traversal is walking the element tree from JavaScript: up to parents, across to siblings, down to children, with tests along the way. The table below is the working ten - and the single distinction behind most traversal bugs: the DOM contains ELEMENT nodes and TEXT nodes, and the older properties count both while the Element-suffixed ones skip the text.
Bottom line: default to the Element variants. childNodes includes the whitespace text nodes BETWEEN your tags (the newlines and indents in formatted HTML become nodes), which is why firstChild is often a text node and children exists. Same for siblings: nextSibling stops at whitespace; nextElementSibling is the one you meant.
The honest part: two methods changed how traversal code is written. closest('.selector') walks up from an element to the nearest ancestor matching a selector - the partner of event delegation, where one parent listener answers for many children by asking event.target.closest('button'). And remove() replaced the parent.removeChild(child) two-step: an element now removes itself.
How to use
- Pick the Element variant by default: children, firstElementChild, nextElementSibling - they skip the whitespace nodes.
- Use closest() plus matches() for delegation: one listener on the container, event.target.closest('button') finds the actionable ancestor.
- Remember remove() for deletion - and contains() (which counts the element itself) when testing containment.
Frequently asked questions
Why does firstChild give me a text node instead of my element?
Because the DOM counts whitespace: the newline and indent between <ul> and <li> in formatted HTML is a real text node, and it is the first child. firstChild returns it; firstElementChild skips text and comment nodes to give the first actual element. The same pair exists for siblings (nextSibling vs nextElementSibling) and for the child list (childNodes vs children). Default to the Element variants and the whitespace surprise disappears.
What is the difference between parentNode and parentElement?
Almost nothing, by design: both return the containing node, except when the parent is the Document or a DocumentFragment - parentElement returns null there, parentNode returns the node. In practice you want parentElement: the cases where the parent is not an element (the document itself) are exactly the cases where element-specific methods would fail next anyway.
How does closest() work with event delegation?
closest('.selector') walks UP from the element - starting with itself - to the nearest ancestor matching the selector. Combined with one listener on a container, it is event delegation: container.addEventListener('click', e => { const btn = e.target.closest('button.action'); if (!btn) return; handle(btn); }). The pattern survives dynamic content (new children need no new listeners) and matches() is its filtering half - testing one element against a selector without walking.
Does contains() mean what it looks like?
Almost: node.contains(other) returns true when other is INSIDE node - but also when other IS node itself. For a strict 'is a proper descendant' test, add the identity guard: node !== other && node.contains(other). The same self-inclusion applies to CSS :has and to closest() starting at self - DOM containment APIs consistently count the element itself, which is either convenient or a bug depending on the question you meant to ask.