Skip to content
JavaScript js dom 5 min read

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 childNodes returns surprising counts. Stick to children whenever 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 / methodReturnsSkips text nodes?
parentElementParent element or nullYes
parentNodeParent node (element, document, fragment)No
childrenHTMLCollection of child elementsYes
childNodesNodeList of all child nodesNo
firstElementChild / lastElementChildEdge child elementYes
nextElementSibling / previousElementSiblingAdjacent element or nullYes
closest(sel)Nearest matching ancestor or nulln/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 .parentElement repeatedly — 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 children and childNodes are live collections — convert with [...el.children] before mutating the DOM during iteration to avoid skipping items.
  • Always check for null: sibling and closest lookups return null at the edges of the tree, and reading a property of null throws.
Last updated June 1, 2026
Was this helpful?