Skip to content
animorio
IT
All components

The minimap index

Navigation, Scroll, PhysicsCanvas, GSAPAdvancedCompressed weight: Demo 14 KBGSAP 28 KBFreeDownload the zip

An article’s table of contents as the minimap of a code editor: beside the text sits the whole article in miniature, drawn word by word where every word really is, with the section names alongside. A lens shows the part on screen: it slides as you scroll, dragged it pulls the article along and thrown it coasts on; the section being read takes the colour, and pointing at the minimap opens a magnified preview with the real words.

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 minimap index</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/newsreader@5.3.0/index.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/instrument-sans@5.3.0/index.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="stage">
    <!-- the reader: a short article that scrolls, and beside it the minimap, the same article in miniature drawn on a
         canvas from where every word really is. The lens on the minimap is the part on screen: scroll the article
         and it slides, drag it and the article follows, press a section and the article glides there -->
    <div class="reader" data-minimap>
      <div class="reader__now" aria-hidden="true">
        <span class="reader__now-label">Reading</span>
        <span class="reader__now-name" data-now>Opening</span>
      </div>

      <article class="article" id="minimap-article" tabindex="0" aria-label="Article" data-article>
        <h1 data-label="Opening">How we cut our build from forty minutes to nine</h1>
        <p class="article__by">Field notes from the platform team · 6 min read</p>
        <p>For two years nobody touched the build. It worked, it was slow, and every engineer had quietly arranged their day around it: start a build, make coffee, answer messages, come back, start again.</p>
        <p>This spring we finally measured it. What follows is what we found, what we changed, and the one change we would skip if we did it again.</p>

        <h2 data-label="Where time went">Where the time went</h2>
        <p>We expected the compiler to be the villain. It was not. Compiling the whole monorepo took eleven minutes on a cold machine; the other twenty-nine were spent downloading, unpacking and testing things that had not changed since the previous run.</p>
        <p>A single trace of one build, laid out on a timeline, made the case better than any meeting could. Long grey bars of waiting, short coloured bars of work.</p>
        <p>Three things accounted for almost all of it: a dependency download that ignored its own cache, a test suite that ran every test on every change, and a packaging step that rebuilt images nobody had asked for.</p>
        <blockquote>The fastest build step is the one you can prove you do not need to run.</blockquote>

        <h2 data-label="Better cache keys">Caching the right things</h2>
        <p>Our first cache stored the output of every step under a key made of its inputs. It helped less than we hoped, because the keys included timestamps, absolute paths and the version of a tool that changed every night.</p>
        <p>Once the keys described only what a step actually reads, the hit rate went from a third to nine in ten.</p>
        <pre><code>key = hash(sources, lockfile, toolchain)
if cache.has(key):
    restore(key)
else:
    run_step()
    cache.put(key, outputs)</code></pre>
        <p>We also stopped caching things that were cheaper to rebuild than to download. Small steps with large outputs were slower from the cache than from scratch, and they crowded out the entries that mattered.</p>
        <p>The lesson is boring and true: a cache is only as good as the honesty of its key.</p>

        <h2 data-label="Split tests">Splitting the tests</h2>
        <p>The test suite ran on one machine, in one long line. We split it by measured duration, not by folder, so that eight machines finish within seconds of each other.</p>
        <ul>
          <li>Slow tests are spread first, fast ones fill the gaps.</li>
          <li>Each shard reports how long it took, and the next split learns from it.</li>
          <li>A flaky test is quarantined automatically after two failures.</li>
        </ul>
        <p>Nine minutes of tests became seventy seconds, and the quarantine list became the most honest document in the company.</p>

        <h2 data-label="Remote builds">Remote execution</h2>
        <p>The last big step was to stop building on laptops at all. Actions run on a shared pool of machines close to the cache, and a laptop only sends what changed.</p>
        <p>The first week was rough. Tests that quietly depended on a file in someone’s home folder failed one after another, and each failure was a small archaeology project.</p>
        <p>This is the part that took longest and saved least. It is worth it for a large team; for us, the cache and the split tests had already done most of the work.</p>

        <h2 data-label="What we’d skip">What we would skip</h2>
        <p>If we started again we would measure first, fix the cache keys second and split the tests third, and only then ask whether remote execution is still needed.</p>
        <p>The build now takes nine minutes on a bad day. Nobody makes coffee during it any more, which, we are told, is its own kind of loss.</p>
      </article>

      <!-- the minimap: the article in miniature, the section names beside it, the lens over it -->
      <div class="map" data-map-pane>
        <div class="map__frame" data-map>
          <canvas class="map__canvas" aria-hidden="true" data-canvas></canvas>
          <div class="map__lens" tabindex="0" role="scrollbar" aria-controls="minimap-article" aria-orientation="vertical"
               aria-label="Visible part of the article" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" data-lens></div>
        </div>
        <nav class="map__nav" aria-label="Sections">
          <ol class="map__list" data-list></ol>
        </nav>
      </div>

      <!-- the preview: the zone under the pointer, magnified on a second canvas, with a beam back to the minimap -->
      <svg class="beam" aria-hidden="true" data-beam><path data-beam-path/></svg>
      <div class="peek" aria-hidden="true" data-peek>
        <span class="peek__name" data-peek-name></span>
        <canvas class="peek__canvas" data-peek-canvas></canvas>
      </div>
    </div>
  </main>

  <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.

47 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 minimap index” from Animorio and fit it into my site

What it does

On a white background, a paper-coloured reader split in two. On the left a short article that scrolls, How we cut our build from forty minutes to nine, with headings, paragraphs, a quote, a code block and a list. On the right the minimap: the same article in miniature, every word a small rounded grey bar where the word really is, the headings darker and thicker, the code on its own background, the quote with its rule. Beside the miniature the section names, each at the height of its heading and joined to it by a thin leader that bends if the name had to move. Over the miniature a rectangular lens with two small grips covers exactly the part of the article on screen. Scrolling the article, the lens slides on a slightly soft spring; dragging it, the article follows pulled by a spring, and let go with a swing the lens coasts and slows down. A click on a point of the miniature takes the lens there and the article runs after it; a click on a name glides the article to the section and the heading it lands on flashes orange for a moment. The section being read has its name in orange and heavier, and its stretch of miniature takes a soft wash of colour: changing section, the colour fades from one to the other. Pointing at the miniature a preview appears: the zone under the pointer, outlined on the miniature by an orange frame, magnified with the real words and the section’s name at its top, joined to the minimap by a pale beam. On a phone the minimap is a narrow strip on the right edge, the name of the section being read rolls in a bar at the top, and the preview shows while the finger drags the strip.

How it works

The words, one by one

A TreeWalker goes through every text node of the article; for every word a Range wraps it and getClientRects() says where it really is, line by line. Every word becomes a rectangle with its type size, its font (from getComputedStyle), the kind of block (heading, paragraph, code, quote) and the section it belongs to. The measurements are taken again when the reader changes size (ResizeObserver) and when the fonts have arrived.

The miniature on a canvas

The scale is the largest that fits the article’s whole height in the pane without going wider than MAP_WIDTH. Every word is a rounded bar half as tall as its type, centred on its line, on an offscreen canvas; a second offscreen canvas holds the same miniature in the accent colour. The visible one puts the grey together with, over every section, its coloured band at an opacity that fades: so the colour moves from section to section without redrawing the words.

Lens and article, both ways

Four modes: free (the article scrolls by itself and the lens follows it on a spring), held (the lens is under the hand and the article is pulled towards it by a critically damped spring, FOLLOW), thrown (the goal carries on with the hand’s speed and loses FRICTION a second) and glide (a chosen name: GSAP carries the scroll there). The step is capped at 1/30 of a second and the loop stops when everything is still.

The section being read

An IntersectionObserver with the article as its root and a rootMargin of -62% at the bottom watches the headings cross a line at 38% of the height: the section being read is the last heading above the line. At the bottom of the article the last section wins even if it is short. The names are set at the height of their heading and pushed apart if they touch, with a curved leader joining each one to its heading.

The preview on a second canvas

Pointing at the miniature, the zone under the pointer is as tall as the preview can show at the scale that fits the text column into its width. The second canvas redraws that zone with fillText, word by word, in each one’s font and colour; the box follows the pointer on a spring and an SVG path draws the beam between the frame on the miniature and its edge.

Values to change

NameFileDefaultWhat it changes
LENS_SPRINGscript.js[560, 0.86]The lens following the scroll: the strength of its spring and its damping (1 = no overshoot).
FOLLOWscript.js260How hard the article is pulled towards the dragged lens: lower, it trails further behind.
FRICTIONscript.js3.4How quickly a thrown lens slows down: the share of its speed it loses every second.
GLIDEscript.js0.95The longest the article takes to glide to a section, in seconds.
MAP_WIDTHscript.js118The widest the miniature may be, in pixels (it must also fit the whole height).
LABEL_GAPscript.js24The least room between two section names, in pixels.
WORDSscript.jstrueEvery word its own bar (on) or whole lines (off).

Try them in the Playground

When to use it

For long articles, documentation, technical notes, terms and conditions, changelogs: wherever readers want to see at a glance the shape of the whole text, how much is left and where the code and the quotes are. With a text of a single screen it is not needed.

On phones

Under 720 pixels the minimap becomes a strip 46 pixels wide on the right edge, without names; the name of the section being read rolls in a bar at the top of the reader. The finger drags the lens or taps the strip to jump, and while it drags the preview shows to the left of the strip (instead of the hover). Nothing spills over sideways.

With reduced motion

The lens follows the scroll without a spring, dragged it carries the article with no lag and let go it stops; a name goes to its section at once without gliding, the colour changes section without fading and the preview fades in, without sliding.

Pitfalls

  • Measuring whole blocks (getBoundingClientRect of every paragraph) gave solid rectangles, not a minimap: it needs the real lines, and for words a Range for each with getClientRects(), which returns one rectangle per line if the word wraps.
  • Redrawing the miniature every frame to colour the section was too much: the words are drawn once into two offscreen canvases (grey and coloured) and every frame only the sections’ bands are pasted with drawImage and globalAlpha.
  • Setting scrollTop from code fires the scroll event: in the held and thrown modes the lens must not follow the scroll (or lens and article chase each other), so the lens follows the goal, not the scroll.
  • A pointer moving from the canvas to the lens leaves the canvas: without checking whether it is still inside the minimap, the preview closed and reopened at every crossing.
  • With the article’s padding inside the scale the miniature was narrow and far from the names: the scale is worked out on the text column only and the canvas starts where the text does.

Libraries

GSAP 3.15.0, Newsreader (font variabile) 5.3.0, Instrument Sans (font variabile) 5.3.0.

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

Testing

Tested on 5 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