DOM Traversal

DOM traversal is the process of navigating through the DOM tree structure to move from one element to related elements. Instead of selecting elements by their IDs or classes every time, you can start with one element and traverse to its parents, children, or siblings.

Understanding the DOM Tree Structure

The DOM is structured like a family tree. Every element has relationships with other elements:

<div id="grandparent">
  <div id="parent-1">
    <div id="child-1">Child 1</div>
    <div id="child-2">Child 2</div>
  </div>
  <div id="parent-2">Parent 2</div>
</div>

Visual Representation

grandparent
├── parent-1
│   ├── child-1
│   └── child-2
└── parent-2

In this structure:

  • grandparent is the parent of parent-1 and parent-2
  • parent-1 and parent-2 are siblings (they share the same parent)
  • child-1 and child-2 are children of parent-1
  • child-1 and child-2 are siblings of each other

Traversing to Child Elements

The children property returns a live HTMLCollection of all direct child elements:

<div id="grandparent">
  <div id="parent-1">Parent 1</div>
  <div id="parent-2">Parent 2</div>
</div>
const grandparent = document.querySelector("#grandparent")
const children = grandparent.children

console.log(children) // HTMLCollection with parent-1 and parent-2
console.log(children.length) // 2

// Access individual children by index
const firstChild = children[0] // parent-1
const secondChild = children[1] // parent-2

Getting Specific Children

You can use querySelector and querySelectorAll on any element, not just document:

<div id="container">
  <div class="item">Item 1</div>
  <div class="item">Item 2</div>
  <span class="item">Item 3</span>
</div>
const container = document.querySelector("#container")

// Find all elements with class "item" inside the container
const items = container.querySelectorAll(".item")
console.log(items.length) // 3

// Find the first div with class "item" inside the container
const firstDiv = container.querySelector("div.item")
console.log(firstDiv.textContent) // "Item 1"

Traversing to Sibling Elements

nextElementSibling and previousElementSibling allow you to move between sibling elements at the same level in the DOM tree.

<div id="one">1</div>
<div id="two">2</div>
<div id="three">3</div>
const two = document.querySelector("#two")
const three = two.nextElementSibling
const one = two.previousElementSibling

What About nextSibling and previousSibling?

nextSibling and previousSibling may seem like they do the same thing, but they return any node, including text nodes (like whitespace):

<div id="one">1</div>
<div id="two">2</div>
<div id="three">3</div>
const two = document.querySelector("#two")
const three = two.nextSibling
const one = two.previousSibling

console.log(three) // Grabs the text between divs (whitespace)
console.log(one) // Grabs the text between divs (whitespace)

Traversing to Parent Elements

parentElement allows you to move up the DOM tree to the parent of an element:

<div id="grandparent">
  <div id="parent-1">
    <div id="child-1">Child 1</div>
  </div>
  <div id="parent-2">Parent 2</div>
</div>
const child1 = document.querySelector("#child-1")
const parent1 = child1.parentElement

console.log(parent1.id) // "parent-1"

// Chain to go up multiple levels
const grandparent = child1.parentElement.parentElement

console.log(grandparent.id) // "grandparent"

Don't Use parentNode

Just like nextSibling, parentNode can return nodes that are not elements. Always prefer parentElement for traversing up the DOM tree.

Finding Ancestors: closest()

The closest() method is incredibly useful - it searches up the DOM tree for the first ancestor that matches a CSS selector:

<div class="container">
  <div class="section">
    <div class="card">
      <button id="delete-btn">Delete</button>
    </div>
  </div>
</div>
const deleteBtn = document.querySelector("#delete-btn")

// Find the closest ancestor with class "card"
const card = deleteBtn.closest(".card")

// Find the closest ancestor with class "section"
const section = deleteBtn.closest(".section")

// Find the closest ancestor with class "container"
const container = deleteBtn.closest(".container")

// If no matching ancestor is found, returns null
const nonExistent = deleteBtn.closest(".does-not-exist")
console.log(nonExistent) // null

Prefer closest and querySelector Over Other Methods

If you use children, nextElementSibling, previousElementSibling, and parentElement too much, your code can become brittle and break if the HTML structure changes.

Instead, prefer closest and querySelector/querySelectorAll to find elements based on their relationships and CSS selectors. This makes your code more flexible and easier to maintain.

<div class="post">
  <h2 class="post-title">My First Post</h2>
  <p class="post-content">This is the content of my first post.</p>
  <button class="edit-btn">Edit</button>
</div>
const editBtn = document.querySelector(".edit-btn")
editBtn.addEventListener("click", () => {
  // This works, but is brittle if HTML structure changes
  const post = editBtn.parentElement
  const title = post.children[0]
  console.log(title) // <h2 class="post-title">My First Post</h2>
})

Now imagine if we changed the HTML structure:

<div class="post">
  <h2 class="post-title">My First Post</h2>
  <p class="post-content">This is the content of my first post.</p>
  <div class="actions">
    <button class="edit-btn">Edit</button>
  </div>
</div>

Our original code would break because editBtn.parentElement is no longer the .post element.

const editBtn = document.querySelector(".edit-btn")
editBtn.addEventListener("click", () => {
  // This works, but is brittle if HTML structure changes
  const post = editBtn.closest(".post")
  const title = post.querySelector(".post-title")
  console.log(title) // <h2 class="post-title">My First Post</h2>
})

By using closest and querySelector, our code can adapt to changes in the HTML structure without breaking.