Complete JavaScript Course Module 8: Intermediate: The DOM (Document Object Model)

DOM Navigation: Parents, Children and Siblings

  • Beginner
  • Lesson 8.2
  • 6 min read
  • 33 of 76 in the course

In short

DOM navigation lets you move from one element to the elements around it. Learn parentElement, children, nextElementSibling, closest, nodes vs elements, and live vs static lists. What is DOM navigation? In the previous lesson you found elements with querySelector.

DOM navigation lets you move from one element to the elements around it. Learn parentElement, children, nextElementSibling, closest, nodes vs elements, and live vs static lists.

What is DOM navigation?

In the previous lesson you found elements with querySelector. Often, though, you already have one element and need the one next to it: the box around a button that was clicked, the answer under a question, or the next item in a list. DOM navigation (also called DOM traversal) means moving from one element to its relatives in the DOM tree.

The DOM tree works like a family tree, and it uses family words:

  • The parent is the element directly around another element.
  • Children are the elements directly inside an element.
  • Siblings are elements with the same parent, side by side.
  • Ancestors are the parent, the parent's parent, and so on up to <html>. Descendants are children, grandchildren and so on.

We will use this product card in the examples:

<article class="card" id="card">
  <h2 class="card-title">Wireless headphones</h2>
  <p class="price">$89</p>
  <ul class="features">
    <li>40-hour battery</li>
    <li class="highlight">Noise cancelling</li>
    <li>Foldable</li>
  </ul>
  <button class="buy">Add to cart</button>
</article>

Here <article> is the parent of <h2>, <p>, <ul> and <button>. Those four are siblings of each other. The three <li> elements are children of the <ul> and descendants of the <article>.

Moving up, down and sideways: the navigation properties

Every element has properties that point to its relatives. They return null when there is no such relative, for example when you ask for the next sibling of the last child.

PropertyDirectionReturns
parentElementUpThe parent element
childrenDownAll child elements (an HTMLCollection)
firstElementChildDownThe first child element
lastElementChildDownThe last child element
childElementCountDownHow many child elements there are
nextElementSiblingSidewaysThe next sibling element
previousElementSiblingSidewaysThe previous sibling element
closest(selector)UpThe nearest ancestor (or the element itself) that matches

Put the card HTML in a page and try these in a script or the console:

const highlight = document.querySelector(".highlight");

console.log(highlight.textContent);                          // Output: Noise cancelling
console.log(highlight.parentElement.className);              // Output: features
console.log(highlight.previousElementSibling.textContent);   // Output: 40-hour battery
console.log(highlight.nextElementSibling.textContent);       // Output: Foldable
console.log(highlight.nextElementSibling.nextElementSibling); // Output: null

const card = document.querySelector("#card");
console.log(card.childElementCount);                // Output: 4
console.log(card.firstElementChild.textContent);    // Output: Wireless headphones
console.log(card.lastElementChild.textContent);     // Output: Add to cart

for (const child of card.children) {
  console.log(child.tagName);
}
// Output: H2, P, UL, BUTTON (one per line)

You can chain these properties, like highlight.parentElement.parentElement to reach the card. But long chains break easily: if someone adds a wrapper <div> to the HTML, the chain points to the wrong element. That is where closest helps.

Finding the right ancestor with closest()

You met closest() briefly in the Event Delegation lesson. element.closest(selector) starts at the element itself and walks up through its ancestors, returning the first one that matches the CSS selector, or null if none do. It does not care how many levels up the match is, so it keeps working when the HTML changes.

const buyButton = document.querySelector(".buy");

const productCard = buyButton.closest(".card");
console.log(productCard.id);                                          // Output: card
console.log(productCard.querySelector(".card-title").textContent);    // Output: Wireless headphones

console.log(buyButton.closest("button") === buyButton);  // Output: true  (it checks itself first)
console.log(buyButton.closest(".sidebar"));              // Output: null

A very common pattern is "go up with closest, then down with querySelector". From any button in a card, you climb to the card, then search inside it for the title or the price.

Here is a full example: an FAQ accordion where each question button shows or hides the answer right after it, using nextElementSibling:

<!DOCTYPE html>
<html lang="en">
  <body>
    <div class="faq">
      <button class="question">How long is delivery?</button>
      <p class="answer" hidden>Usually 2 to 4 working days.</p>
      <button class="question">Can I return an item?</button>
      <p class="answer" hidden>Yes, within 30 days.</p>
    </div>

    <script>
      const faq = document.querySelector(".faq");

      faq.addEventListener("click", (event) => {
        const question = event.target.closest(".question");
        if (!question) return;                      // the click was not on a question

        const answer = question.nextElementSibling; // the <p> right after the button
        answer.hidden = !answer.hidden;             // show or hide it
        console.log(`${question.textContent} -> ${answer.hidden ? "closed" : "open"}`);
      });
    </script>
  </body>
</html>
How long is delivery? -> open

Click a question and its answer appears; click again to hide it. There is no ID on any answer: each button simply finds its neighbour.

Nodes vs elements: why firstChild gives you text

The DOM contains more than elements. Everything in it is a node: elements are nodes, but so are pieces of text and comments. Even the spaces and line breaks between your tags become text nodes.

That is why there are two sets of navigation properties. The ones with "Element" in the name skip text and comments. The older ones include every node:

Elements only (use these)All nodes, including text
childrenchildNodes
firstElementChild / lastElementChildfirstChild / lastChild
nextElementSibling / previousElementSiblingnextSibling / previousSibling
parentElementparentNode
const features = document.querySelector(".features");

console.log(features.children.length);       // Output: 3   (the three <li> elements)
console.log(features.childNodes.length);     // Output: 7   (3 <li> plus 4 whitespace text nodes)
console.log(features.firstChild.nodeName);   // Output: #text
console.log(features.firstElementChild.nodeName); // Output: LI

The 4 extra nodes are the line breaks and spaces before, between and after the <li> tags.

Common mistake: Using nextSibling or firstChild and getting a text node full of spaces instead of the element you wanted. Unless you really need text nodes, always use the "Element" versions.

HTMLCollection vs NodeList: live vs static lists

Different methods give you different kinds of lists:

You get it fromTypeContainsLive or static?
element.childrenHTMLCollectionElementsLive
getElementsByClassName(), getElementsByTagName()HTMLCollectionElementsLive
element.childNodesNodeListAll nodesLive
querySelectorAll()NodeListElementsStatic

A live list updates itself automatically when the page changes. A static list is a snapshot: it stays exactly as it was when you created it.

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

const liveItems = list.children;                  // live HTMLCollection
const snapshot = list.querySelectorAll("li");     // static NodeList

const newItem = document.createElement("li");
newItem.textContent = "Bluetooth 5.3";
list.append(newItem);

console.log(liveItems.length);   // Output: 4   (updated by itself)
console.log(snapshot.length);    // Output: 3   (still the old snapshot)

Another difference: a NodeList has a forEach method, but an HTMLCollection does not. Both work with for...of, and you can turn either into a real array with Array.from(list) or [...list] to use map and filter.

Tip: Live lists can surprise you inside loops. If you remove elements while looping over a live list, its length shrinks as you go and you skip items. Copy it first with Array.from(), or use querySelectorAll.

Practice exercise

Build a small to-do list with three <li> items, each containing a task name in a <span> and two buttons, "Up" and "Down". Use one click listener on the <ul>. When "Up" is clicked, move that <li> above its previous sibling; when "Down" is clicked, move it below its next sibling. Do nothing at the top or bottom of the list.

Hint: find the item with event.target.closest("li"). Use item.previousElementSibling and prev.before(item) to move up, and item.nextElementSibling with next.after(item) to move down. Check for null first.

Key takeaways

  • parentElement goes up, children, firstElementChild and lastElementChild go down, and nextElementSibling and previousElementSibling go sideways.
  • closest(selector) finds the nearest matching ancestor and survives changes to the HTML better than long chains.
  • Text and whitespace are nodes too; use the properties with "Element" in their names to skip them.
  • children is a live HTMLCollection; querySelectorAll gives a static NodeList snapshot.