ui-027

Scrollspy Table of Contents

Free

A scrollspy table of contents for long pages: a marker slides to the section in view and a progress line tracks reading.

ScrollTriggerScrollToPlugin intermediate
5 more details
scrollspyscroll-progresssmooth-scrollcheckpoint-navigationkeyboard-navigation

This element reads better at your own screen size than in the frame below:

Updated

About this component

A sticky table of contents for documentation, guides and long articles. One ScrollTrigger per section moves a marker down the rail to whichever heading is at reading position, the active label brightens, and a thin progress line fills as the reader works through the piece. Clicking an entry scrolls the page to that section with ScrollToPlugin and moves the keyboard caret with it.

Read the full effect overview

Ships in a light and a dark theme, switched by one data-variant attribute and defined entirely in CSS custom properties, so the same script serves both. The rail can sit on either side of the document with one attribute. One HTML file, one stylesheet, one script, GSAP plus ScrollTrigger and ScrollToPlugin.

Scrollspy Table of Contents - UI component preview

What's included

8 items
  • One ScrollTrigger per section, so the active entry changes at reading position rather than at the viewport edge
  • Marker slides on measured offsets, so labels can wrap to any height
  • Reading-progress line scrubbed across the whole article
  • Clicking an entry scrolls with ScrollToPlugin and moves focus to that section
  • Re-measures after web fonts load and on resize, then refreshes ScrollTrigger
  • Works with no JavaScript: the entries are plain anchors and still jump
  • Reduced motion keeps every state change but drops the travel time to zero
  • Light and dark themes on one token list, and a rail that sits on either side

Perfect for

4 use cases
  • Documentation and API reference sidebars
  • Long-form editorial and research articles
  • Legal pages, changelogs and release notes
  • Onboarding guides with numbered steps

How it works

3 sections

One trigger per section

Each section gets a ScrollTrigger whose start and end sit on a band a third of the way down the viewport. onToggle fires when that band is inside the section, which is what makes the rail change at reading position instead of when a heading first appears at the bottom of the screen.

A measured marker

The marker carries no CSS transform and keeps top: 0, so the y GSAP tweens is the entire offset. Each move measures the target link against the rail with getBoundingClientRect and tweens both y and height, which means a label that wraps to two lines gets a taller marker rather than a misaligned one. A CSS top or margin on the marker would be added to the measurement and double the offset.

Progress and clicks

A single scrubbed ScrollTrigger over the article sets the progress line's scaleY from its own progress value, so the fill is exact rather than interpolated from the active section. Clicking an entry cancels the default jump and hands the scroll to ScrollToPlugin with an offset for the sticky header, then focuses the destination section so a keyboard user carries on from there.

Plugins ScrollTrigger, ScrollToPlugin
Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="robots" content="noindex, nofollow">
  <title>Scrollspy Table of Contents — GSAP Vault</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="assets/style.css">
  <style>
    /* Demo-only toolbar; not part of the component. */
    .showcase-toolbar {
      position: fixed; inset: 0 0 auto 0; z-index: 1000; height: 44px;
      display: flex; align-items: center; gap: 10px; padding: 0 12px;
      flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
      background: var(--ground-2); border-bottom: 1px solid var(--line);
      color: var(--ink-3); font-family: var(--font); font-size: 0.75rem;
      white-space: nowrap;
    }
    .showcase-toolbar::-webkit-scrollbar { display: none; }
    .showcase-toolbar__prompt { color: var(--ink-2); margin-right: auto; }
    .showcase-toolbar__group { display: flex; gap: 2px; padding: 2px; border-radius: 6px; border: 1px solid var(--line); background: var(--raised); }
    .showcase-toolbar button {
      appearance: none; border: 0; cursor: pointer; font: inherit;
      min-height: 26px; padding: 0 9px; border-radius: 4px;
      background: transparent; color: var(--ink-2);
    }
    .showcase-toolbar button:hover { background: var(--hover); color: var(--ink); }
    .showcase-toolbar button[aria-pressed="true"] { background: var(--ink); color: var(--ground); }
    .showcase-toolbar button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
    @media (max-width: 620px) { .showcase-toolbar__prompt { display: none; } }
  </style>
</head>
<body data-variant="light">
  <header class="showcase-toolbar">
    <span class="showcase-toolbar__prompt">Scroll the page, or click an entry, and watch the marker follow the reading position.</span>
    <div class="showcase-toolbar__group" role="group" aria-label="Rail side">
      <button class="side-btn" type="button" data-side="left" aria-pressed="true">Left</button>
      <button class="side-btn" type="button" data-side="right" aria-pressed="false">Right</button>
    </div>
    <div class="showcase-toolbar__group" role="group" aria-label="Theme">
      <button class="variant-btn" type="button" data-variant="light" aria-pressed="true">Light</button>
      <button class="variant-btn" type="button" data-variant="dark" aria-pressed="false">Dark</button>
    </div>
  </header>

  <main class="toc-layout" id="tocLayout" data-rail-side="left">
    <nav class="toc-rail" id="tocRail" aria-label="On this page">
      <p class="toc-heading">On this page</p>
      <div class="toc-inner">
        <span class="toc-track" aria-hidden="true"></span>
        <span class="toc-progress" id="tocProgress" aria-hidden="true"></span>
        <span class="toc-marker" id="tocMarker" aria-hidden="true"></span>
        <ul class="toc-list">
          <li><a class="toc-link" href="#endpoint">Build the endpoint</a></li>
          <li><a class="toc-link" href="#register">Register the URL</a></li>
          <li><a class="toc-link" href="#signatures">Verify the signature</a></li>
          <li><a class="toc-link" href="#responding">Respond fast, work later</a></li>
          <li><a class="toc-link" href="#retries">Retries and idempotency</a></li>
          <li><a class="toc-link" href="#monitoring">Monitoring and rotation</a></li>
        </ul>
      </div>
    </nav>

    <article class="toc-article" id="tocArticle">
      <h1>Setting up webhooks</h1>
      <p class="toc-lede">Webhooks push events to your server the moment they happen, so you can stop polling for changes that may never come. Six steps take you from an empty route to an endpoint you can trust in production.</p>

      <section class="toc-section" id="endpoint">
        <h2>Build the endpoint</h2>
        <p>Start with a single route that accepts POST requests and nothing else. It needs no authentication of its own, because the request is authenticated by its signature rather than by a session, and adding a login in front of it is the most common reason a first webhook never arrives.</p>
        <p>Read the request body as raw bytes before any middleware parses it. Signature verification runs over the exact bytes that were sent, and a JSON parser that reorders keys or normalises whitespace will produce a body that no longer matches the signature you were given.</p>
      </section>

      <section class="toc-section" id="register">
        <h2>Register the URL</h2>
        <p>Add the endpoint in your dashboard and subscribe it to the specific events you handle, not to everything. A subscription that fires on every event type in the catalogue means every future addition arrives at a handler that has no branch for it, and your logs fill with unknown types you cannot act on.</p>
        <p>Register separate endpoints for test and live traffic. Sharing one URL across both makes it far too easy to act on a test event with production data, and the two streams are impossible to tell apart once they are interleaved in the same log.</p>
      </section>

      <section class="toc-section" id="signatures">
        <h2>Verify the signature</h2>
        <p>Every delivery carries a signature header computed from the raw body and a secret only the two of you hold. Recompute it on receipt and compare the two values with a constant-time comparison; a plain equality check leaks timing information that can be used to guess the signature byte by byte.</p>
        <p>Signatures usually include a timestamp, and the timestamp is as important as the digest. Reject anything older than a few minutes so a captured request cannot be replayed later, and make sure the clock on your server is actually synchronised before you tighten that window.</p>
      </section>

      <section class="toc-section" id="responding">
        <h2>Respond fast, work later</h2>
        <p>Return a 2xx status as soon as the signature checks out. Providers hold the connection open while you work and treat a slow response as a failure, so anything beyond writing the event to a queue or a table belongs in a background job rather than in the request.</p>
        <p>That split also keeps your handler honest under load. When a batch of events arrives at once, a route that only records them stays fast regardless of how expensive the downstream work is, and the queue absorbs the burst instead of your web tier.</p>
      </section>

      <section class="toc-section" id="retries">
        <h2>Retries and idempotency</h2>
        <p>Assume every event will arrive more than once. Retries after a timeout, a redelivery from the dashboard and an ordinary network hiccup all produce duplicates, and a handler that charges a card or sends an email on each one will do it twice.</p>
        <p>Store the event id the first time you see it and treat a repeat as a no-op. Writing that record in the same transaction as the work itself is what makes the guarantee hold; two separate writes leave a window where the work is done and the record is not.</p>
      </section>

      <section class="toc-section" id="monitoring">
        <h2>Monitoring and rotation</h2>
        <p>Log every delivery with its id, type and outcome, and alert on the failure rate rather than on individual failures. Providers disable an endpoint that fails for long enough, and the first sign is usually a quiet gap in the data rather than an error anyone noticed.</p>
        <p>Rotate the signing secret on a schedule. Accept both the old and the new secret for the length of one rotation window, then drop the old one, so a delivery in flight during the change is still verified rather than rejected.</p>
      </section>
    </article>
  </main>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollToPlugin.min.js"></script>
  <script src="assets/script.js"></script>
  <script>
    // Demo-only: switches data-variant on <body>; also honours ?variant=name.
    (function () {
      var buttons = Array.prototype.slice.call(document.querySelectorAll('.variant-btn'));
      var names = buttons.map(function (b) { return b.dataset.variant; });
      function setVariant(name) {
        document.body.setAttribute('data-variant', name);
        buttons.forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.variant === name)); });
        // The rail is measured, so it re-measures on the resize the switch fires.
        requestAnimationFrame(function () { window.dispatchEvent(new Event('resize')); });
      }
      var wanted = new URLSearchParams(location.search).get('variant');
      var initial = names.indexOf(wanted) > -1 ? wanted : document.body.getAttribute('data-variant') || names[0];
      setVariant(initial);
      buttons.forEach(function (b) { b.addEventListener('click', function () { setVariant(b.dataset.variant); }); });

      // Demo-only: the documented data-rail-side option, shown as a control.
      var layout = document.getElementById('tocLayout');
      var sides = Array.prototype.slice.call(document.querySelectorAll('.side-btn'));
      sides.forEach(function (b) {
        b.addEventListener('click', function () {
          layout.setAttribute('data-rail-side', b.dataset.side);
          sides.forEach(function (o) { o.setAttribute('aria-pressed', String(o === b)); });
          requestAnimationFrame(function () { window.dispatchEvent(new Event('resize')); });
        });
      });
    })();
  </script>
</body>
</html>
/**
 * Scrollspy Table of Contents Rail
 *
 * A sticky table of contents beside a long article. A marker slides down the
 * rail to whichever section is in view, the active label brightens, a thin
 * progress line fills as the article is read, and clicking a link scrolls the
 * page to that section.
 *
 * @plugins ScrollTrigger, ScrollToPlugin
 */

(function () {
(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init, { once: true });
    } else {
        init();
    }
})(function initScrollspyTocRail() {
    if (typeof gsap === 'undefined') {
        console.warn('[scrollspy-toc-rail] GSAP is required but was not found.');
        return;
    }
    if (typeof ScrollTrigger === 'undefined' || typeof ScrollToPlugin === 'undefined') {
        console.warn('[scrollspy-toc-rail] ScrollTrigger and ScrollToPlugin are required.');
        return;
    }

    gsap.registerPlugin(ScrollTrigger, ScrollToPlugin);

    var rail = document.getElementById('tocRail');
    var marker = document.getElementById('tocMarker');
    var progress = document.getElementById('tocProgress');
    var article = document.getElementById('tocArticle');
    if (!rail || !marker || !progress || !article) return;

    var links = Array.prototype.slice.call(rail.querySelectorAll('.toc-link'));
    var sections = links.map(function (link) {
        return document.querySelector(link.getAttribute('href'));
    }).filter(Boolean);
    if (!links.length || links.length !== sections.length) return;

    var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    var T = reduce ? 0 : 1; // duration multiplier: reduced motion lands instantly
    var activeIndex = -1;

    // Helpers are declared before use: build:demos un-hoists function declarations.
    var railTop = function () {
        return marker.parentNode.getBoundingClientRect().top;
    };

    var measure = function (index) {
        var rect = links[index].getBoundingClientRect();
        return { y: rect.top - railTop(), height: rect.height };
    };

    var setActive = function (index, instant) {
        if (index < 0 || index >= links.length || index === activeIndex) return;
        activeIndex = index;
        links.forEach(function (link, i) { link.classList.toggle('is-active', i === index); });
        var box = measure(index);
        gsap.to(marker, {
            y: box.y,
            height: box.height,
            duration: instant ? 0 : 0.45 * T,
            ease: 'power3.out',
            overwrite: 'auto'
        });
    };

    var remeasure = function () {
        if (activeIndex < 0) return;
        var box = measure(activeIndex);
        gsap.set(marker, { y: box.y, height: box.height });
    };

    // The marker carries no CSS transform and keeps top: 0, so the measured y
    // is the whole offset. A CSS top or margin here would double it.
    gsap.set(marker, { y: 0, height: links[0].getBoundingClientRect().height });
    gsap.set(progress, { scaleY: 0, transformOrigin: 'top center' });

    // Reading progress: the fill is scrubbed across the whole article, from the
    // first section reaching the top band to the last one leaving it.
    ScrollTrigger.create({
        trigger: article,
        start: 'top 30%',
        end: 'bottom 60%',
        scrub: true,
        onUpdate: function (self) {
            gsap.set(progress, { scaleY: self.progress });
        }
    });

    // One trigger per section. The band sits a third of the way down the
    // viewport, so the active entry changes when a heading reaches reading
    // position rather than when it first appears.
    sections.forEach(function (section, index) {
        ScrollTrigger.create({
            trigger: section,
            start: 'top 32%',
            end: 'bottom 32%',
            onToggle: function (self) {
                if (self.isActive) setActive(index);
            }
        });
    });

    links.forEach(function (link, index) {
        link.addEventListener('click', function (event) {
            event.preventDefault();
            setActive(index);
            gsap.to(window, {
                duration: reduce ? 0 : 0.8,
                ease: 'power2.inOut',
                scrollTo: { y: sections[index], offsetY: 60, autoKill: false },
                onComplete: function () {
                    // Move the keyboard caret with the view, so Tab continues
                    // from the section that was just jumped to.
                    sections[index].setAttribute('tabindex', '-1');
                    sections[index].focus({ preventScroll: true });
                }
            });
        });
    });

    setActive(0, true);

    // Fonts change link heights, and so does a resize or a variant swap.
    if (document.fonts && document.fonts.ready) {
        document.fonts.ready.then(function () {
            remeasure();
            ScrollTrigger.refresh();
        });
    }
    window.addEventListener('resize', function () {
        remeasure();
        ScrollTrigger.refresh();
    });

    // The thumbnail generator wants the rail mid-article, where the marker has
    // travelled and the progress line has something in it.
    window.__thumbnail = function () {
        var target = sections[Math.min(2, sections.length - 1)];
        window.scrollTo(0, Math.max(0, target.offsetTop - 60));
        ScrollTrigger.update();
    };
});
})();
!function(){var e;e=function(){if("undefined"!=typeof gsap)if("undefined"!=typeof ScrollTrigger&&"undefined"!=typeof ScrollToPlugin){gsap.registerPlugin(ScrollTrigger,ScrollToPlugin);var e=document.getElementById("tocRail"),t=document.getElementById("tocMarker"),o=document.getElementById("tocProgress"),n=document.getElementById("tocArticle");if(e&&t&&o&&n){var r=Array.prototype.slice.call(e.querySelectorAll(".toc-link")),i=r.map(function(e){return document.querySelector(e.getAttribute("href"))}).filter(Boolean);if(r.length&&r.length===i.length){var c=window.matchMedia("(prefers-reduced-motion: reduce)").matches,l=c?0:1,a=-1,g=function(e){var o=r[e].getBoundingClientRect();return{y:o.top-t.parentNode.getBoundingClientRect().top,height:o.height}},u=function(e,o){if(!(e<0||e>=r.length||e===a)){a=e,r.forEach(function(t,o){t.classList.toggle("is-active",o===e)});var n=g(e);gsap.to(t,{y:n.y,height:n.height,duration:o?0:.45*l,ease:"power3.out",overwrite:"auto"})}},s=function(){if(!(a<0)){var e=g(a);gsap.set(t,{y:e.y,height:e.height})}};gsap.set(t,{y:0,height:r[0].getBoundingClientRect().height}),gsap.set(o,{scaleY:0,transformOrigin:"top center"}),ScrollTrigger.create({trigger:n,start:"top 30%",end:"bottom 60%",scrub:!0,onUpdate:function(e){gsap.set(o,{scaleY:e.progress})}}),i.forEach(function(e,t){ScrollTrigger.create({trigger:e,start:"top 32%",end:"bottom 32%",onToggle:function(e){e.isActive&&u(t)}})}),r.forEach(function(e,t){e.addEventListener("click",function(e){e.preventDefault(),u(t),gsap.to(window,{duration:c?0:.8,ease:"power2.inOut",scrollTo:{y:i[t],offsetY:60,autoKill:!1},onComplete:function(){i[t].setAttribute("tabindex","-1"),i[t].focus({preventScroll:!0})}})})}),u(0,!0),document.fonts&&document.fonts.ready&&document.fonts.ready.then(function(){s(),ScrollTrigger.refresh()}),window.addEventListener("resize",function(){s(),ScrollTrigger.refresh()}),window.__thumbnail=function(){var e=i[Math.min(2,i.length-1)];window.scrollTo(0,Math.max(0,e.offsetTop-60)),ScrollTrigger.update()}}}}else console.warn("[scrollspy-toc-rail] ScrollTrigger and ScrollToPlugin are required.");else console.warn("[scrollspy-toc-rail] GSAP is required but was not found.")},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e,{once:!0}):e()}();
* { box-sizing: border-box; margin: 0; padding: 0; }

/* ---------------------------------------------------------------------
   Themes. Two values of one design: `light` (the default) and `dark`.
   Every colour, radius, rule width and marker shape the rail and the page
   around it use is a custom property, so a theme is one block of overrides
   on [data-variant]. Set it on <body> in the demo, or on the element that
   wraps the rail and the article in your own page. Nothing in
   assets/script.js reads the theme name.
   --------------------------------------------------------------------- */
body,
body[data-variant="light"] {
  color-scheme: light;
  --ground: #f2f2f1;            /* the page */
  --ground-2: #e9e9e7;          /* the demo toolbar strip */
  --raised: #fbfbfa;            /* not used by the rail; kept for the toolbar */
  --hover: #e2e2df;
  --ink: #141412;               /* headings, the active entry */
  --ink-2: #4b4b47;             /* body copy */
  --ink-3: #676762;             /* rail label, resting entries */
  --line: rgba(20, 20, 18, 0.12);
  --line-strong: rgba(20, 20, 18, 0.22);
  --accent: #ff4d00;            /* signal orange: the marker, and only the marker */
  --accent-ink: #141412;        /* text ON the accent (::selection) */
  --shadow: 0 1px 2px rgba(20, 20, 18, 0.06), 0 4px 12px rgba(20, 20, 18, 0.06);
  --font: 'Inter', system-ui, -apple-system, sans-serif;
  --radius: 10px;
  --radius-sm: 6px;
  --radius-xs: 4px;

  /* the rail itself */
  --track: #dcdcd9;             /* the unread rule, solid */
  --progress: #ffbb99;          /* how far the reader has come, solid */
  --marker: var(--accent);
  --track-w: 2px;
  --marker-w: 2px;
  --marker-radius: 999px;
  --link-fg: var(--ink-3);
  --link-fg-active: var(--ink);
  --focus-ring: var(--accent);
}

body[data-variant="dark"] {
  color-scheme: dark;
  --ground: #141414;
  --ground-2: #0f0f0f;
  --raised: #1c1c1c;
  --hover: #232323;
  --ink: #f1efea;
  --ink-2: #c9c7c1;
  --ink-3: #9a988f;
  --line: rgba(241, 239, 234, 0.12);
  --line-strong: rgba(241, 239, 234, 0.22);
  --accent: #ff4d00;
  --accent-ink: #141412;
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);

  --track: #2e2e2e;
  --progress: #6e2c0e;
}

/* Lenis and CSS smooth scrolling fight the ScrollToPlugin tween. */
html { overflow-x: clip; scroll-behavior: auto; }

body {
  min-height: 100svh;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--font);
  font-size: 0.875rem;
  line-height: 1.45;
  -webkit-font-smoothing: antialiased;
  padding-top: 44px;                    /* the demo toolbar strip */
}
::selection { background: var(--accent); color: var(--accent-ink); }

/* --------------------------------- layout --------------------------------
   The rail beside the document it reads. Swap `data-rail-side="right"` on
   .toc-layout and the rail sits on the far side; nothing measured changes.
   -------------------------------------------------------------------------- */
.toc-layout {
  display: grid;
  grid-template-columns: 200px minmax(0, 640px);
  justify-content: center;
  gap: 48px;
  max-width: 920px;
  margin: 0 auto;
  padding: 40px 20px 45vh;
}
.toc-layout[data-rail-side="right"] { grid-template-columns: minmax(0, 640px) 200px; }
.toc-layout[data-rail-side="right"] .toc-rail { order: 2; }

/* ---------------------------------- rail --------------------------------- */
.toc-rail {
  position: sticky;
  top: 76px;                            /* 44px toolbar + the page's own 32px */
  align-self: start;
}
.toc-heading {
  font-size: 0.75rem;
  font-weight: 500;
  color: var(--ink-3);
  margin-bottom: 10px;
  padding-left: 14px;
}
.toc-inner { position: relative; }

/* The unread rule, the fill that follows reading progress, and the marker
   share one left edge. The marker keeps top: 0 and takes a measured y from
   GSAP, so no CSS offset is ever added to the tween. */
.toc-track,
.toc-progress {
  position: absolute;
  left: 0;
  top: 0;
  width: var(--track-w);
  border-radius: 999px;
}
.toc-track { height: 100%; background: var(--track); }
.toc-progress { height: 100%; background: var(--progress); transform-origin: top center; }
.toc-marker {
  position: absolute;
  left: 0;
  top: 0;
  width: var(--marker-w);
  height: 24px;
  border-radius: var(--marker-radius);
  background: var(--marker);
  z-index: 2;
}
.toc-list { list-style: none; }
.toc-link {
  display: block;
  min-height: 32px;
  padding: 7px 0 7px 14px;
  color: var(--link-fg);
  text-decoration: none;
  font-size: 0.8125rem;
  font-weight: 500;
  line-height: 1.4;
  transition: color 0.2s ease;
}
.toc-link:hover { color: var(--link-fg-active); }
.toc-link.is-active { color: var(--link-fg-active); }
.toc-link:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
  border-radius: var(--radius-xs);
}

/* -------------------------------- document -------------------------------
   The page the rail reads: demo furniture, styled from the same tokens so
   the theme toggle flips both. Your own article markup goes here; the rail
   only needs each section to carry the id its entry points at.
   -------------------------------------------------------------------------- */
.toc-article { max-width: 640px; color: var(--ink-2); }
.toc-article > h1 {
  font-size: 1.375rem;
  font-weight: 600;
  letter-spacing: -0.015em;
  color: var(--ink);
  margin-bottom: 8px;
}
.toc-article > .toc-lede { margin-bottom: 32px; max-width: 62ch; }
.toc-section { padding-bottom: 40px; }
.toc-section h2 {
  font-size: 1.0625rem;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--ink);
  margin-bottom: 10px;
  scroll-margin-top: 76px;
}
.toc-section:focus { outline: none; }
.toc-section:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 6px; border-radius: var(--radius-sm); }
.toc-section p {
  font-size: 0.9375rem;
  line-height: 1.65;
  max-width: 62ch;
  margin-bottom: 12px;
}
.toc-section p:last-child { margin-bottom: 0; }

@media (max-width: 760px) {
  .toc-layout {
    grid-template-columns: 112px minmax(0, 1fr);
    gap: 16px;
    padding: 24px 16px 45vh;
    max-width: none;
  }
  .toc-layout[data-rail-side="right"] { grid-template-columns: minmax(0, 1fr) 112px; }
  .toc-rail { top: 56px; }
  .toc-heading { display: none; }
  .toc-link { font-size: 0.75rem; padding-left: 10px; }
  .toc-article > h1 { font-size: 1.1875rem; }
  .toc-section h2 { font-size: 1rem; }
  .toc-section p { font-size: 0.875rem; }
}

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}

Sticky table of contents beside a long document, with a marker that slides to the section in view and a reading-progress line down the rail.

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, plain CSS custom properties for colours

  • assets/script.js: readable, commented source with an onReady guard

  • One ScrollTrigger per section, so the active entry changes at reading position rather than at the viewport edge

  • Marker slides on measured offsets, so labels can wrap to any height

  • Reading-progress line scrubbed across the whole article

  • Clicking an entry scrolls with ScrollToPlugin and moves focus to that section

  • Re-measures after web fonts load and on resize, then refreshes ScrollTrigger

  • Works with no JavaScript: the entries are plain anchors and still jump

  • Reduced motion keeps every state change but drops the travel time to zero

Quick Start

<link rel="stylesheet" href="assets/style.css">
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollToPlugin.min.js"></script>
<script src="assets/script.js"></script>

Copy the .toc-rail nav and the .toc-layout grid from index.html. The script finds tocRail, tocMarker, tocProgress and tocArticle by id on load, so keep those ids or update the selectors at the top of assets/script.js.

The rail's structure is the whole contract: every .toc-link is an anchor whose href points at the id of one section in the article. The script builds its section list from those hrefs, so adding, removing or reordering entries needs no JavaScript change, and a link pointing at nothing is skipped rather than throwing.

Demo furniture. The strip across the top of index.html (the prompt, the rail-side buttons and the Light/Dark toggle) is demo-only: its markup, CSS and script live in index.html and never in the two shipped assets. The webhooks article is furniture too, in the sense that it is there for the rail to read; replace it with your own content and keep the section ids the entries point at.

Keyboard and ARIA

  • The rail is a <nav> with aria-label="On this page" and an ordinary list of anchors, so a screen reader announces it as a named navigation landmark and reads the entry count.
  • Tab moves through the entries in document order. Enter or Space activates one, exactly as for any link; the visible focus ring is the accent at 2px with a 2px offset.
  • Activating an entry scrolls the page and then moves the keyboard caret to the destination section (tabindex="-1" plus focus({ preventScroll: true })), so the next Tab continues from the section that was jumped to rather than from the rail.
  • The marker, the track and the progress line are aria-hidden, because they duplicate the state already carried by the active entry.
  • With JavaScript blocked, the entries are still anchors to in-page ids and still jump.

How It Works

One trigger per section. Each section gets a ScrollTrigger whose start and end sit on a band a third of the way down the viewport. onToggle fires when that band is inside the section, which is what makes the rail change at reading position instead of when a heading first appears at the bottom of the screen.

A measured marker. The marker carries no CSS transform and keeps top: 0, so the y GSAP tweens is the entire offset. Each move measures the target link against the rail with getBoundingClientRect and tweens both y and height, which means a label that wraps to two lines gets a taller marker rather than a misaligned one. A CSS top or margin on the marker would be added to the measurement and double it.

Progress and clicks. A single scrubbed ScrollTrigger over the article sets the progress line's scaleY from its own progress value, so the fill is exact rather than interpolated from the active section. Clicking an entry cancels the default jump and hands the scroll to ScrollToPlugin with an offset for the sticky header, then focuses the destination section so a keyboard user carries on from there.

Themes

The component ships in two themes, light (the default) and dark: one design at two sets of token values. Every colour, radius, rule width and marker shape the rail and the page around it use is a CSS custom property, and each theme is one block in assets/style.css, so the script is identical across both and reads no theme name.

Pick one: set the attribute on <body> in the demo, or on the element that wraps the rail and the article in your own page.

<body data-variant="dark">

Leaving the attribute off gives you light. The demo's toggle and the ?variant=dark URL parameter only set this attribute; neither is part of the component.

Re-value it for your brand: the token list is short and lives at the top of assets/style.css.

Token What it colours
--ground, --ground-2, --raised, --hover the page and the surfaces on it
--ink, --ink-2, --ink-3 headings and the active entry, body copy, the rail label and resting entries
--line, --line-strong hairlines
--accent, --accent-ink the marker, the focus ring and ::selection
--track, --progress the unread rule and the read portion of it
--marker, --marker-w, --marker-radius, --track-w the marker's colour, width and shape, and the rule's width
--link-fg, --link-fg-active the two entry states
--font, --radius, --radius-sm, --radius-xs the face and the corner radii

Give text a solid colour rather than a translucent one, so contrast can be measured against the surface behind it. To add a third theme, copy either body[data-variant="..."] block, rename it and change the values; nothing in assets/script.js needs to change.

Customisation

  • Rail side: data-rail-side="right" on .toc-layout puts the rail on the far side of the document. Nothing measured changes, because the marker's offset is read from the rail rather than from the page.
  • Reading band: the start: 'top 32%' and end: 'bottom 32%' pair on the per-section triggers. Move both together, or the bands overlap and two entries fight.
  • Scroll offset on click: offsetY: 60 in the ScrollToPlugin tween. Set it to the height of your fixed header.
  • Scroll duration: duration: 0.8 in the same tween, or drop the plugin entirely and call section.scrollIntoView({ behavior: 'smooth' }).
  • Progress span: the trigger: article ScrollTrigger's start and end. Ending at bottom 60% fills the line as the last section reaches reading position rather than when the page bottoms out.
  • Marker travel: duration: 0.45 and ease: 'power3.out' in setActive.
  • Reduced motion sets the duration multiplier T to 0, so the marker jumps and the page scroll is instant.

Requirements

  • GSAP 3.12+ (demo uses 3.15.0), plus the ScrollTrigger and ScrollToPlugin plugins
  • No build step, no framework