JavaScriptDOM Traversal

DOM Traversal

Once you've selected an element, you often need to find something near it — its parent, its siblings, a specific descendant, or the nearest ancestor that matches some condition. The DOM gives you a small toolbox for walking the tree without needing to re-query from document every time. This page covers the moves you'll actually use.

The example tree

tree.html

HTML
<section id="todo">
  <h2>Today</h2>
  <ul class="list">
    <li class="task" data-id="1">Buy milk</li>
    <li class="task done" data-id="2">Read DOM page</li>
    <li class="task" data-id="3">Write code</li>
  </ul>
  <button class="add">Add task</button>
</section>
Going up: parentNode and parentElement

Every node has a parent. Use parentElement when you specifically want an element back (it returns null if the parent is the document itself).

JS
const li = document.querySelector(".task");
li.parentElement;        // the <ul class="list">
li.parentElement.parentElement; // the <section id="todo">

document.documentElement.parentNode;    // the document
document.documentElement.parentElement; // null
Going down: children, childNodes, first/last

There are two slightly different ways to ask for children:

  • element.children — only element children, as a live HTMLCollection.

  • element.childNodes — every child node, including text nodes for whitespace, comments, etc.

  • element.firstElementChild / element.lastElementChild — first/last element child, or null if none.

  • element.firstChild / element.lastChild — first/last node, often a whitespace text node.

JS
const list = document.querySelector(".list");

list.children.length;        // 3
list.firstElementChild;      // <li>Buy milk</li>
list.lastElementChild;       // <li>Write code</li>
list.children[1].textContent;// "Read DOM page"

list.childNodes.length;      // often more than 3 — whitespace text counts
Whitespace is text
A line break between two tags creates a text node in the DOM. That's why `firstChild` often surprises beginners. Prefer `firstElementChild` unless you specifically need the text nodes.
Going sideways: siblings

JS
const middle = document.querySelector('[data-id="2"]');

middle.previousElementSibling; // <li data-id="1">Buy milk</li>
middle.nextElementSibling;     // <li data-id="3">Write code</li>

const first = middle.parentElement.firstElementChild;
first.previousElementSibling;  // null — already first

As with children, the *ElementSibling variants skip text and comment nodes. The plain previousSibling / nextSibling walk over every node.

Closest: walking up until something matches

element.closest(selector) walks up from the element itself, looking for the nearest ancestor that matches a CSS selector — including the element itself. It returns null if nothing matches.

click delegation, briefly

JS
document.querySelector("#todo").addEventListener("click", (e) => {
  const task = e.target.closest(".task");
  if (!task) return;            // clicked the heading, the button, etc.
  console.log("clicked task id =", task.dataset.id);
});
clicked task id = 2     (when the user clicks the second <li>)

This is the workhorse of event delegation — covered in detail on its own page.

Matches: is this element a …?

element.matches(selector) returns true if the element itself satisfies the selector. It does not look at ancestors.

JS
const li = document.querySelector(".task.done");
li.matches(".task");        // true
li.matches(".done");        // true
li.matches("li:nth-child(2)"); // true
li.matches("button");       // false

matches pairs well with closest and with event delegation when you want to be specific about which sub-elements you care about.

Putting it together: a tiny traversal

from a clicked button to its task

JS
document.querySelector(".add").addEventListener("click", (e) => {
  const section = e.target.closest("section");
  const list    = section.querySelector(".list");
  const lastTask = list.lastElementChild;

  console.log("about to add after:", lastTask?.textContent ?? "(empty list)");
});
Performance note
  • Walking the tree with parentElement, children, and siblings is faster than re-running querySelector from document for every step.

  • closest is implemented natively and is essentially free — use it liberally.

  • Live collections (children) are recomputed lazily, but iterating them while you mutate the tree still leads to bugs. Snapshot with Array.from(parent.children) when in doubt.

Quick reference
  • UpparentElement, closest(sel)

  • Downchildren, firstElementChild, lastElementChild, querySelector(sel)

  • SidewayspreviousElementSibling, nextElementSibling

  • Test selfmatches(sel)

With these six tools you can navigate any DOM tree without re-querying document. The next pages put them to work changing what those nodes contain and look like.