Skip to content
animorio
IT
All components

The table of contents rail

Navigation, Scroll, SVG, PhysicsGSAPMediumCompressed weight: Demo 10 KBGSAP 28 KBFreeDownload the zip

Scroll inside the preview

An article’s table of contents as on the best documentation sites: beside the entries runs a rail that follows the outline of the headings and steps in with a soft bend for every sub-heading, and its lit stretch covers exactly the part of the page on screen, its two ends on springs. A pressed entry glides the page there; on a phone the table of contents becomes a bar with the current section.

The code

A folder that runs on its own: index.html, style.css, script.js and the libraries from a CDN. The React and Vue tabs hold the same demo as a ready component, which switches its animations off when it leaves the page.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>The table of contents rail</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/schibsted-grotesk@5.3.0/index.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="layout" data-layout>
    <!-- the table of contents: on a wide screen a column on the right that stays put, on a phone a bar at the top
         that opens it. The rail beside the entries is an SVG path drawn from where the entries are; its lit stretch
         covers the sections on screen -->
    <aside class="toc" data-toc>
      <button class="toc__bar" type="button" aria-expanded="false" aria-controls="toc-panel" data-bar>
        <svg class="toc__ring" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="9" pathLength="1" data-ring/></svg>
        <span class="toc__bar-words"><span class="toc__bar-label">On this page</span><span class="toc__bar-now" data-now>Overview</span></span>
        <svg class="toc__chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg>
      </button>
      <div class="toc__panel" id="toc-panel" data-panel>
        <p class="toc__title" id="toc-title">On this page</p>
        <nav class="toc__nav" aria-labelledby="toc-title">
          <svg class="rail" aria-hidden="true" data-rail>
            <path class="rail__track" data-track/>
            <path class="rail__read" data-read/>
            <path class="rail__ghost" data-ghost/>
            <path class="rail__halo" data-halo/>
            <path class="rail__lit" data-lit/>
            <circle class="rail__head" r="3.5" data-head/>
          </svg>
          <ol class="toc__list" data-list>
            <li><a href="#overview" data-depth="2">Overview</a></li>
            <li><a href="#how-signing-works" data-depth="2">How signing works</a></li>
            <li><a href="#the-signature-header" data-depth="3">The signature header</a></li>
            <li><a href="#rotating-the-secret" data-depth="3">Rotating the secret</a></li>
            <li><a href="#two-secrets-at-once" data-depth="4">Two secrets at once</a></li>
            <li><a href="#verify-a-request" data-depth="2">Verify a request</a></li>
            <li><a href="#with-the-sdk" data-depth="3">With the SDK</a></li>
            <li><a href="#by-hand" data-depth="3">By hand</a></li>
            <li><a href="#retries-and-replays" data-depth="2">Retries and replays</a></li>
            <li><a href="#the-retry-schedule" data-depth="3">The retry schedule</a></li>
            <li><a href="#rejecting-old-events" data-depth="3">Rejecting old events</a></li>
            <li><a href="#test-locally" data-depth="2">Test locally</a></li>
            <li><a href="#going-live" data-depth="2">Going live</a></li>
          </ol>
        </nav>
        <div class="toc__foot">
          <span class="toc__left" data-left>3 min read</span>
          <button class="toc__up" type="button" data-up>
            <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 13V3M4 7l4-4 4 4"/></svg>
            Back to top
          </button>
        </div>
      </div>
    </aside>

    <!-- the article it indexes: plain headings and short paragraphs, only there to be read and scrolled -->
    <main class="doc" data-doc>
      <h2 id="overview" tabindex="-1">Overview</h2>
      <p>A webhook endpoint is a public URL. Anyone who finds it can send it a request that looks exactly like a real event, and if your code acts on it, a stranger can mark orders as paid or cancel bookings.</p>
      <p>A signature on every event closes that door: your code repeats the sender’s computation and acts only when the two results match.</p>

      <h2 id="how-signing-works" tabindex="-1">How signing works</h2>
      <p>Each endpoint has its own signing secret, shown once when the endpoint is created. The secret never travels with the events: only what it produces does.</p>
      <p>A signature covers the moment the event was sent and its body, so a request captured on the way cannot be replayed hours later.</p>

      <h3 id="the-signature-header" tabindex="-1">The signature header</h3>
      <p>Every event carries a header with a timestamp and one or more signatures, separated by commas. Each signature is an HMAC-SHA256 of the timestamp, a full stop and the raw body.</p>

      <h3 id="rotating-the-secret" tabindex="-1">Rotating the secret</h3>
      <p>Rotate a secret when someone who knew it leaves the team, when it may have been logged by accident, or simply once a year, and choose how long the old one keeps working.</p>

      <h4 id="two-secrets-at-once" tabindex="-1">Two secrets at once</h4>
      <p>While both secrets are valid, every event is signed with each of them. Code that accepts any of the signatures keeps working through the change, without a single rejected event.</p>

      <h2 id="verify-a-request" tabindex="-1">Verify a request</h2>
      <p>Verification comes before anything else in your handler: before parsing, before logging the body, before touching the database. Anything that fails gets an error and no further work.</p>

      <h3 id="with-the-sdk" tabindex="-1">With the SDK</h3>
      <p>The official libraries do the whole check in one call, including the tolerance on the timestamp and the secrets in rotation, and throw an error when it fails.</p>

      <h3 id="by-hand" tabindex="-1">By hand</h3>
      <p>Without a library the check is short enough to write yourself. Compare the signatures in constant time, and check every one in the header, not just the first.</p>

      <h2 id="retries-and-replays" tabindex="-1">Retries and replays</h2>
      <p>An event counts as delivered when your endpoint answers with a success status within ten seconds. Anything else, a timeout included, is a failure, and the event is sent again later.</p>

      <h3 id="the-retry-schedule" tabindex="-1">The retry schedule</h3>
      <p>Failed events are tried again seven times over about a day and a half, waiting longer each time. Each new attempt is signed again with a fresh timestamp.</p>

      <h3 id="rejecting-old-events" tabindex="-1">Rejecting old events</h3>
      <p>Reject events whose timestamp is more than five minutes away from your server’s clock: a request recorded on the way is then useless almost as soon as it is captured.</p>

      <h2 id="test-locally" tabindex="-1">Test locally</h2>
      <p>The command line tool forwards real, signed events to a URL on your own machine, with a temporary secret for the session, so a failed check shows up at once.</p>

      <h2 id="going-live" tabindex="-1">Going live</h2>
      <p>Before switching to live mode, make sure the raw body is verified first, the live secret sits in your secret manager, old events are rejected and slow work goes to a queue.</p>
      <p>From then on your endpoint acts only on events that are genuine.</p>
    </main>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
  <script src="script.js"></script>
</body>
</html>

Playground

Change values, colours and text with the controls and watch the animation respond live. Or write in the code yourself. Then download the folder with your changes.

61 values to change

Or ask Claude Code for it

With the Animorio connector, Claude Code takes it from here, guide included, and fits it into your project. Paste this request:

Take “The table of contents rail” from Animorio and fit it into my site

What it does

On a white background, on the right, an article’s table of contents, On this page, which stays put while you scroll; on the left the article it indexes, only headings and short paragraphs on verifying webhook signatures. Beside the thirteen entries runs a thin grey rail that follows the levels of the headings: it steps in with a small S bend for every sub-heading and back out after it. The stretch of rail beside the sections on screen is green, with a soft glow and a small hollow circle at its end, and covers exactly as much as you can see: half of a section on screen, half of its entry lit. The rail already read, above, is a pale green. The entries of the sections on screen turn dark, the one being read green. Scrolling, the lit stretch slides down following the bends; pressing a far entry the page glides there, the front end of the stretch runs and the back one follows more softly, so the stretch pulls out and then gathers, and the heading you land on flashes green for a moment. Pointing at an entry, the piece of rail it would light is faintly tinted. Under the table of contents, the minutes of reading left and Back to top. On a phone the table of contents is a bar at the top of the screen with a ring that fills as you read and the name of the current section, which rolls at every change; pressed, it opens the list with its rail.

How it works

The rail from the entries

Each entry has a level (data-depth) and from it an x: INDENT pixels more for each level. The real positions of the entries (offsetTop and offsetHeight) give, for each entry, where it starts and ends. The rail is a function of height: at every y there is one x, the entry’s, and between two entries at different levels the x goes from one to the other along a smoothstep bend BEND pixels tall, centred on the line between them. The SVG path is made of points taken from this function: the two ends, and nine points along every bend.

The lit stretch

Each section of the article runs from its heading to the next, and the last one to its last paragraph. The window (below the bar, on a phone) becomes two heights on the rail: of the first visible section the fraction already gone past the top edge, of the last the fraction that has reached the bottom edge, laid on their entries. The lit stretch is a second path, taken from the same function between those two heights: it follows the bends by itself.

Two ends on springs

The two ends of the stretch do not jump: each is a number with a speed, pulled towards its goal by a spring (SPRING) and damped. The end on the side you are going uses the full spring, the other a softer one (TRAIL) with the damping scaled to match, so it does not wobble: on a long jump the stretch pulls out and then gathers. The step is capped at 1/30 of a second and the path is only rewritten when one of the two heights has changed.

Jumping to a section

A pressed entry takes the page to its heading (minus the scroll-margin-top, which on a phone leaves room for the bar) with a GSAP tween on a number that calls scrollTo on every update: the duration grows with the distance up to GLIDE seconds. The wheel, a finger or the scrolling keys stop the trip. On arrival the heading takes the focus (it has tabindex=-1), the address takes its #, and the green colour fades away.

On a phone

Under 900 pixels the table of contents becomes a bar stuck to the top of the screen: an SVG ring filled with stroke-dashoffset from the fraction read, and the name of the current section rolling up or down. Pressed, the list opens with a clip-path coming down and the entries arriving one after the other; the rail is measured again because the entries are now bigger. A chosen entry closes the list and glides the page.

Values to change

NameFileDefaultWhat it changes
INDENTscript.js12The pixels the rail steps in for each level of heading.
BENDscript.js14The height of each bend of the rail, in pixels.
SPRINGscript.js[210, 24]The two ends of the lit stretch: the strength of their spring and its damping.
TRAILscript.js0.35How much softer the spring of the trailing end is: lower, the stretch pulls out longer on jumps.
GLIDEscript.js1.1The longest the page takes to glide to a section, in seconds.
READscript.js230Words read in a minute, for the minutes left.
HALOscript.jstrueA soft glow round the lit stretch.

Try them in the Playground

When to use it

For documentation, guides, long articles, terms of service: any page with headings and sub-headings where readers want to know where they are and jump to a point. With fewer than four headings it is not needed.

On phones

Under 900 pixels the table of contents becomes a bar stuck to the top of the screen, with a ring of how much you have read and the name of the current section; a tap opens the list with its rail, a tapped entry closes it and goes there, a tap outside closes. The article goes to one column as wide as the screen, and nothing spills over sideways.

With reduced motion

The lit stretch follows the scroll without springs, an entry goes to its section at once without gliding, the name in the bar changes without rolling and the phone’s list fades open.

Pitfalls

  • Between two entries at different levels the rail bends: the lit stretch must use the same function as the rail, not a rectangle or a dash pattern on top, or at the steps it leaves the rail.
  • The last sections are short and never reach a third of the window: at the bottom of the page the line that decides the section being read slides down to the bottom edge, or Going live would never become the current one.
  • With the same spring for both ends the stretch travelled stiffly; with a softer spring behind it pulls out, but the damping must be reduced by the square root of the ratio between the springs, or the back end arrives slowly as if through treacle.
  • The jump with scrollTo on every update must stop as soon as the reader touches the wheel or the screen: otherwise the page wrenches the scrolling out of their hands.
  • A classic script cannot name a constant top: it is already window.top, and the declaration stops the whole script.

Libraries

GSAP 3.15.0, Schibsted Grotesk (font variabile) 5.3.0.

Compressed with gzip, the three demo files weigh 10 KB; the libraries: GSAP 28 KB.

Testing

Tested on 4 October 2026

  • Desktop: Done
  • Phone: Done
  • Reduced motion: Done
  • Safari on iPhone: Done

License

Use it in your own projects and in your clients’ projects. Do not resell it or redistribute it as is. GSAP stays under its own Standard license. Full license.

All components