
The bar that becomes an island
Navigation, Physics, Scroll, SVG, Transitions
NewFree
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.
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>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
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 siteA 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 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.
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.
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.
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.
| Name | File | Default | What it changes |
|---|---|---|---|
LENS_SPRING | script.js | [560, 0.86] | The lens following the scroll: the strength of its spring and its damping (1 = no overshoot). |
FOLLOW | script.js | 260 | How hard the article is pulled towards the dragged lens: lower, it trails further behind. |
FRICTION | script.js | 3.4 | How quickly a thrown lens slows down: the share of its speed it loses every second. |
GLIDE | script.js | 0.95 | The longest the article takes to glide to a section, in seconds. |
MAP_WIDTH | script.js | 118 | The widest the miniature may be, in pixels (it must also fit the whole height). |
LABEL_GAP | script.js | 24 | The least room between two section names, in pixels. |
WORDS | script.js | true | Every word its own bar (on) or whole lines (off). |
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.
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.
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.
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.
Tested on 5 October 2026
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.
With your account’s email and password.
or with your email
You’re signed in as .
With your account, you can download the zip of every animation and every pack, for free.