063

Scroll Timeline Chronicle

£10 or the whole Vault, £99

A scroll-scrubbed vertical chronology: entries magnetically hang in a reading band and ease through the handoff while a drawn spine, an odometer year and a depth-blur falloff all react to the same scroll.

ScrollTrigger Lenis advanced
6 more details
scrubpinningvelocitydepth-falloffodometer-counterviewport-aware-ticker

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

Updated

About this effect

An editorial chronology effect that turns a company history, product roadmap or milestone list into one scroll-scrubbed instrument, set as a ruled archive on a dark ground: records filed behind a folio number and a hairline margin, a spine drawn down that margin in chalk, and a giant odometer year in the corner as the one accent on the page. The active record is carried by weight, scale, depth and blur; the only other yellow is the marker that travels the spine. A single pinned stage holds every entry stacked in 3D; scroll position drives a continuous index, and an eased curve on the fraction between entries makes each one hang at rest, then commit in one movement as the next takes over.

Read the full effect overview

Everything else in the composition is driven from that same number: the spine draws itself down the margin, the year rolls digit by digit like an odometer, and entries either side of the active one fall back in depth with a blur and desaturation falloff. Scroll velocity stretches the whole column along the scroll and elongates the spine head into a liquid streak.

Scroll Timeline Chronicle - GSAP animation effect preview

What's included

9 items
  • One pinned ScrollTrigger drives every channel from a single scrubbed index, so nothing can drift out of sync
  • Magnetic snap: a power3.inOut curve on the between-entry fraction gives each handoff a hang at rest and one committed movement across, with no overshoot
  • Drawn spine with a travelling head, per-entry ticks that fill as they are passed, and a tick ray fired across at each handoff
  • Odometer year counter built from digit reels, with a per-digit lag so the year rolls rather than flicking
  • Depth falloff on inactive entries: translateZ, rotationX, opacity, blur and desaturation, all quantised so the filter is only rewritten when it changes
  • Velocity stretch on a frame-rate independent ticker that decays back to rest, gated to the viewport by a second ScrollTrigger
  • Optional external jump controls (data-chron-jump) that scroll to any entry, Lenis-aware, with aria-current kept in step
  • Complete without JavaScript: the chronicle is authored as a plain readable list and only becomes the pinned stage once the effect runs
  • Clickable ruled margin: every tick on the spine seeks to its entry, with the year revealed beside it on hover

Perfect for

5 use cases
  • Company history or about pages where the founding story is the sell
  • Product roadmaps and changelogs that need to feel like a release, not a table
  • Case-study process sections walking a project from brief to launch
  • Annual reports and impact pages where each year deserves its own beat
  • Conference or festival programmes counted down year by year

How it works

3 sections

A single pinned ScrollTrigger scrubs the whole section and converts progress into a floating index across the entries. The fraction between two entries is eased, which is what produces the magnetic hang at each stop and the committed movement through the handoff; every other channel reads that same eased index.

Each frame the script sets position, depth, rotation, opacity and a blur/saturate filter per entry from its distance to the index, scales the spine draw, positions the travelling head and rolls each digit reel to an interpolated value. Blur is quantised to half a pixel so the filter string is only rewritten when it actually changes.

Scroll speed is read on update and decays back to zero frame-rate independently, driving a stretch on the entry column and on the spine head. That loop is gated to the viewport by a second ScrollTrigger, and everything is torn down through a single cleanup path.

Plugins ScrollTrigger
Difficulty Advanced
Smooth scroll Lenis integration
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this effect'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.

Paid effect

Purchase to unlock the code.

Buying Scroll Timeline Chronicle opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse free effects

Or the whole library: the Vault, £99, one payment.

Documentation

Quick Start

1. Add to your HTML <head>:

Code snippet omitted: it ships with the download.

2. Add the chronicle to your <body>:

Code snippet omitted: it ships with the download.

3. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

The script finds every [data-chronicle], builds the digit reels and rail nodes, adds is-live, pins the stage and scrubs the whole composition from one ScrollTrigger.

Using It With Your Own Design

What the effect actually requires

  1. The data hooks, not the class names. script.js only ever queries data- attributes. Every chron__* class belongs to the stylesheet and can be renamed or replaced wholesale.
  2. [data-chron-entry] elements with a four-digit data-year. Anything can live inside them; the script never looks. Two entries is the minimum.
  3. [data-chron-stage] around them. This is the element that gets pinned, so it must be the full-height frame you want the reader to look at.
  4. [data-chron-list] as the stacking parent. In the live layout every entry occupies the same grid cell (grid-template-areas: 'stack' with align-content: center), so they are all centred on top of one another and the script's y offsets fan them out. The parent needs perspective for the depth falloff to read.

What is only the demo's CSS

The spine, the giant year, the rail nodes and the tick ray are all optional. Delete [data-chron-year], [data-chron-rail], [data-chron-head] or [data-chron-ray] and the script skips that channel — the entries still snap and fall back in depth. The jump chips in the toolbar are likewise optional; keep them if you want a keyboard route through the chronology.

Non-obvious CSS the effect depends on

  • The base styles are the no-JS design. The chronicle is authored as an ordinary readable <ol>; the pinned, stacked layout lives entirely under .chron.is-live, a class the script adds only in the motion branch. This is also why reduced motion needs no separate work: the class is never added, so the plain list is what a reduced-motion reader gets. Do not move the live rules out from under is-live.
  • overflow: hidden on the stage. Entries three steps away are still positioned, just faded; without the clip they extend past the frame.
  • The digit reel geometry. Each [data-chron-year] slot is overflow: hidden, height: 1em, and its reel is ten 1em cells with line-height: 1. The script sets yPercent: -value * 10, so any change to the cell height must keep all ten cells identical. Slot width must clear the widest digit's ink or the reel clips glyph sides instead of masking the roll.
  • Three nested elements at the spine head. [data-chron-head] carries the travelling y, [data-chron-stretch] carries the velocity stretch, [data-chron-pop] carries the handoff pop. Collapsing them into one element makes the three fight over transform.

Theming

The stylesheet is a dark ruled archive: the site's deep (--page: #141414) and chalk (--ink: #f1efea), hairline rules and one accent, yellow (--accent: #fff500, with --accent-ink: #141412 for anything set on it). The accent sits on exactly two things: the odometer year and the head that travels the spine, which is the reading band's marker. The spine itself, the ticks and the drawn length are chalk. The active record is told apart by weight, scale, depth and blur, never by colour.

Every fade composites toward the ground, and that is the one thing to be careful with when repointing this. Neighbouring entries are faded with 1 - ad * 0.32 in the script and sit around 0.68 opacity with a 3.5px blur, which is the point where they still read as shapes without competing with the active entry. The quiet inks (--ink-dim, --ink-faint) are chosen so the entry either side of the head still clears 4.5:1 after that fade; if you move to a light ground, re-measure both before relaxing them, because a faded glyph loses contrast faster on white than on a dark ground.

  • The spine head is a slab laid across the margin, a cursor on a ruler, rather than a dot, because a dot reads as a status light. It is also the shape the velocity stretch wants: squashing a bar reads as a smear, where squashing a round or diamond mark reads as a wobble.
  • The composition avoids the circle and the diagonal throughout: the margin ticks are rules, the prompt mark is a short rule, the head is a slab, and passed ticks lengthen rather than filling in. Everything is set at right angles, which is what keeps it reading as a ruled sheet rather than a dashboard.
  • The year index in the toolbar is plain text with no visible box, but each year carries a min-width/min-height press target so it still clears the 24px floor. Strip those and the index fails target-size on a phone.
  • The odometer sits above the stacked list (z-index: 3, pointer-events: none), so a faded neighbour passing behind it is covered rather than ghosted through the digits. It is parked in the corner below the reading band and the active entry is centred, so the two never meet at the frame sizes the stage is designed for; if your entries run much longer than the demo's, shrink the year before you let them overlap.
  • The ruled margin is a scrubber. Every tick is a real <button> that seeks to its entry, with the year riding out of the margin on hover so the control says where it is going before it goes. Three things make it actually work, and all three are easy to lose:
    • .chron__list is inset: 0 over the whole stage and comes after the spine in the DOM, so the spine needs z-index: 2 to sit above it. Without that the ticks respond to a scripted .click() and are completely dead to a real pointer, and the failure looks like nothing at all is wired up.
    • The head slab and the handoff ray are decoration, so both are pointer-events: none. The head parks on a tick, and until it was excluded that tick was the one you could never press.
    • The tick is 1.5px of hairline; the press target is a padded ::after. It is sized to just under half the gap between ticks, so it clears the 24px floor without one tick stealing its neighbour's presses.
  • The tick buttons are aria-hidden and tabindex="-1" on purpose. They duplicate the year index above them exactly, and making them focusable would put all thirteen years in the tab order twice for no added capability. Keyboard and screen reader users get the same navigation from the index; this is a pointer shortcut and nothing more.

Typography is Mona Sans throughout, loaded from Google Fonts with the four weights the demo uses (400 body, 500 labels, 600 titles, 700 folio and year). The odometer slots are a fixed 0.74em wide, measured against Mona Sans's bold digits; a narrower face can tighten that, a wider one must widen it or the reel clips the glyph sides. The folio number and the year are set without tabular-nums on purpose: Mona Sans slashes its tabular zero, which reads as instrument chrome, and the slots are fixed-width anyway.

Options

Set on the [data-chronicle] element.

Attribute Values Default Description
data-chronicle marker required Identifies a chronicle
data-step number 85 Scroll distance per entry, as a percentage of the stage height. Below about 55 the entries cross faster than the year can be read
data-gap number 0.3 Vertical distance between neighbouring entries, as a fraction of stage height. Scale it with your longest entry, see below
data-blur pixels 7 Peak blur applied to the furthest visible entry
data-lean percent 9 Maximum velocity stretch of the entry column along the scroll axis (the column scales, it never skews). 0 disables the stretch and the head stretch
data-scrub seconds 0.6 ScrollTrigger scrub smoothing. Higher trails further behind the scroll

data-gap is the one value your copy decides. Entries are absolutely positioned and offset by gap x stage height, so the gap has to clear the tallest entry or a neighbour's last line lands on the next entry's title. The default 0.3 suits a one-sentence entry. A paragraph of four or five lines wants about 0.46, which is what the demo runs: thirteen entries at 48 to 72 words each. Nothing warns you about this, because an overlapping entry is still faded and blurred and reads as intentional depth until you look at the line that has gone through a heading. Set your longest entry first, scroll to it, and raise the gap until it clears.

Per-entry: data-year (four digits) on each [data-chron-entry].

Hook Where Purpose
data-chron-stage inside the chronicle The element that gets pinned
data-chron-list inside the stage Stacking parent for the entries
data-chron-entry repeated One milestone
data-chron-rail optional Nodes are injected into it, one per entry
data-chron-draw optional Scaled on the Y axis to draw the spine
data-chron-head / -stretch / -pop optional Travelling head, velocity stretch, handoff pop
data-chron-ray optional Tick ray fired across at each handoff
data-chron-year optional Replaced with digit reels; keep a readable year in it as the no-JS state
data-chron-status optional Visually hidden live region announcing the active year
data-chron-jump anywhere on the page Buttons that scroll to entry N, in document order

The jump buttons may sit outside the chronicle (a toolbar, a sidebar). The script looks inside [data-chronicle] first and falls back to a document-wide lookup, and keeps aria-current on them in step with the active entry.

Accessibility

  • Reduced motion: gsap.matchMedia() never adds is-live, so the chronicle stays a plain, fully readable list in document order. Nothing is hidden and nothing is pinned.
  • Without JavaScript: identical. The live layout is the enhancement, not the baseline.
  • Keyboard: the jump controls are real <button> elements and carry aria-current. They scroll the page, so the reader's focus and scroll position stay in agreement.
  • Announcements: [data-chron-status] is a visually hidden role="status" region updated with the active year at each handoff.
  • Motion budget: the velocity stretch is clamped by data-lean and decays on a frame-rate independent curve, so a fast flick cannot leave the column stretched.

Performance

  • One pinned ScrollTrigger drives every channel, so nothing can drift out of sync.
  • The blur value is quantised to half a pixel and the filter string is only written when it changes, which is what keeps a full chronicle of simultaneously filtered elements affordable. The demo runs thirteen, which is the length a real company history tends to be; only the entries within three steps of the index carry a blur at all, so the cost is set by the falloff window and not by the entry count.
  • The velocity ticker is gated to the viewport by a second ScrollTrigger and removes itself if the chronicle leaves the document.
  • Keep the entry count modest. Every entry is measured each frame; a hundred-item changelog wants pagination, not a hundred entries.

Dependencies

  • GSAP 3.15.0
  • Mona Sans (Google Fonts; swap freely, see Theming)
  • ScrollTrigger
  • Lenis 1.3.17 (optional; smooth scroll is opt-out with data-smooth="off" on <html> or ?smooth=off in the URL)

Browser Support

Chrome, Firefox, Safari and Edge, current versions. The depth falloff uses translateZ with perspective and a blur() filter; both degrade to a plain fade in anything that does not support them.

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.