← Controllers

toc

Highlights the current section in a table of contents as you scroll.

To see this controller in action, visit Getting Started on desktop. The "On this page" sidebar highlights the current section as you scroll.

Usage

<nav data-controller="toc" data-toc-offset-value="100">
  <a href="#section-1" data-toc-target="link" data-id="section-1">
    Section 1
  </a>
  <a href="#section-2" data-toc-target="link" data-id="section-2">
    Section 2
  </a>
</nav>

Targets

Target Purpose
link TOC links that get highlighted

Values

Value Type Default Description
offset Number 100 Offset from top for activation

Required CSS

[data-toc-target="link"] {
  @apply text-gray-600 hover:text-gray-900 transition-colors;
}

[data-toc-target="link"].active {
  @apply text-blue-600 font-medium border-blue-500;
}

Source

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["link"];
  static values = {
    offset: { type: Number, default: 100 },
  };

  connect() {
    this.headings = [];
    this.collectHeadings();

    if (this.headings.length > 0) {
      this.boundScrollHandler = this.onScroll.bind(this);
      window.addEventListener("scroll", this.boundScrollHandler, {
        passive: true
      });
      this.highlightCurrentSection();
    }
  }

  disconnect() {
    if (this.boundScrollHandler) {
      window.removeEventListener("scroll", this.boundScrollHandler);
    }
  }

  collectHeadings() {
    this.linkTargets.forEach((link) => {
      const id = link.dataset.id || link.getAttribute("href")?.replace("#", "");
      const heading = document.getElementById(id);
      if (heading) {
        this.headings.push({ id, element: heading, link });
      }
    });
  }

  onScroll() {
    requestAnimationFrame(() => this.highlightCurrentSection());
  }

  highlightCurrentSection() {
    const scrollPos = window.scrollY + this.offsetValue;
    let currentHeading = null;

    for (let i = this.headings.length - 1; i >= 0; i--) {
      if (this.headings[i].element.offsetTop <= scrollPos) {
        currentHeading = this.headings[i];
        break;
      }
    }

    this.linkTargets.forEach((link) => link.classList.remove("active"));
    if (currentHeading) {
      currentHeading.link.classList.add("active");
    }
  }
}

Auto-Scroll TOC

If the TOC container is scrollable, the active item scrolls into view automatically.