More Scroll Snippets
Scroll Year Timeline — Free GSAP Scrub Counter Snippet
Scroll Year Timeline · Scroll · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Scroll Year Timeline — Scrub Through Decades With a Giant Year Counter

The scroll year timeline turns company-history sections into a time machine: a giant year counter scrubs from 1998 to 2026 as you scroll, era cards crossfade beneath it as their years arrive, and a rail fills to show where you are in the span. Scrolling up literally rewinds history. This snippet builds it with GSAP ScrollTrigger (from a CDN), a numeric proxy odometer, and one shared timeline.
The year is a tweened number, not an animation
GSAP tweens a proxy object's y property from 1998 to 2026; onUpdate rounds it and writes textContent. Tweens interpolate any numeric property — not just CSS — so the year counter is just math surfaced into the DOM. Math.round (rather than floor) keeps forward and backward scrubbing symmetric around each year boundary, and font-variant-numeric: tabular-nums locks digit widths so the giant number doesn't shudder horizontally as digits flip.
One tween drives the counter and the rail together
The same onUpdate that writes the year also sets the rail's width from (y − START) / (END − START). Deriving both from one interpolated value means the number and the progress bar can't disagree — a classic scrollytelling bug when two separate animations drift by a frame or two under fast scrubbing.
Era cards own equal thirds of the timeline
The counter tween's duration is set to CARDS (3 units), and each card fades in around position i and out at i + 0.85. Since the counter spans those same 3 units linearly, card boundaries land at exact years: card two appears as the counter passes ~2007, card three at ~2017. Change START/END or the card count and the year-to-card mapping recomputes itself — no hand-synced year constants anywhere.
Crossfades overlap, entrance leads exit
Each card enters at i − 0.15 while the previous card exits at (i−1) + 0.85 — a 0.3-unit overlap where both are semi-visible, moving through each other vertically (incoming rises from +30, outgoing lifts to −30). That continuous hand-off reads as eras flowing into each other rather than slides being swapped, and it stays coherent when scrubbed backward because both tweens live on the same timeline.
Pinned for a deliberate 250% journey
The stage pins for 2.5 viewport-heights, giving 28 years roughly 20px of scroll per year — slow enough that milestone years are visible as they tick past, fast enough that the section doesn't overstay. scrub: 0.4 smooths wheel detents into a continuous time-slide.
Gradient text via background-clip
The year uses the background-clip: text trick — an indigo-to-cyan gradient painted through transparent glyphs — which survives the per-frame text rewrites because only the node's text content changes, never its styles.
Customizing it
Set START/END to your company's span, add cards (the mapping adapts), or swap cards for images per era. Related patterns: a scroll timeline dots rail for event-based histories, count up for viewport-triggered counters, a scrollytelling chart when eras carry data, and a vertical timeline for the static fallback.
Build with AI
Build, Understand, Optimize, and Extend It With AI
You do not have to work out the proxy-object tweening or the card-to-year mapping by hand. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly how tweening a plain counter object's y property lets a non-CSS value like a year number get scrubbed by ScrollTrigger, and why the timeline's duration is deliberately set equal to the card count. The same assistant can help optimize it — checking whether writing textContent on every onUpdate tick could be batched or whether it is already cheap enough at typical scrub rates, or whether Math.round versus Math.floor actually matters for how symmetric the reverse-scrub feels. It is just as useful for extending the effect: ask it to attach a small data point or stat to each era card that counts up alongside the year, support a non-linear timeline where some decades get more scroll budget than others, or add a background color shift keyed to the same counter value. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a "scroll year timeline" odometer effect in plain HTML, CSS, and JavaScript using GSAP and its ScrollTrigger plugin (load both from a CDN, no build step).
Requirements:
- A pinned stage showing a giant year number, a row of era cards stacked on top of each other (absolutely positioned so only one is visible at a time), and a thin progress rail beneath them.
- Tween a plain JavaScript object's numeric property (not a DOM element) from a start year to an end year using ease: none, and in that tween's onUpdate callback, round the current value and write it into the year element's textContent, and separately compute (currentValue - startYear) / (endYear - startYear) as a percentage and set that as the progress rail's width — both derived from the exact same interpolated value in the same callback, not from two independent tweens, so they can never drift apart.
- Set the counter tween's duration equal to the number of era cards (for example 3), and add each era card's enter/exit animation at fractional positions relative to its index on that same timeline (entering around position i, exiting around i + 0.85), so each card's screen time corresponds to an equal, automatically-computed fraction of the year range with no hardcoded year-to-card mapping.
- Card transitions must overlap slightly, with the incoming card's fade/rise beginning before the outgoing card's fade/fall finishes, so eras hand off with a brief crossfade rather than a hard cut.
- Apply font-variant-numeric: tabular-nums to the year element so the digits do not change width as they change, preventing the large number from jittering horizontally during fast scrubbing.
- The whole timeline must be scrubbed and fully reversible, with the pinned stage spanning a fixed multiple of the viewport height (for example 250%) regardless of how many years the range spans.Step by step
How to Use
- 1Add the GSAP CDNsInclude gsap and ScrollTrigger from the CDN panel.
- 2Paste HTML, CSS, and JSThe stage renders at 1998 with the first era card.
- 3Scroll into the stageIt pins and the year begins counting upward.
- 4Watch the handoffsEra cards crossfade as their years arrive.
- 5Scroll back upHistory rewinds — counter, rail, and cards together.
- 6Set your own spanChange START, END, and the era cards.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
GSAP tweens a plain object's y property from START to END on a pinned, scrubbed trigger; onUpdate rounds it into textContent. Tweens interpolate any numeric property, so the counter is pure math surfaced to the DOM. Rounding (not flooring) keeps year boundaries symmetric whether you scrub forward or backward.
The counter tween's duration equals the card count, so timeline position i corresponds linearly to a computed year. Each card fades in around position i and out at i + 0.85 on the same timeline — meaning card changes land at exact fractions of the year span, and changing START, END, or the card count remaps everything automatically.
Both are written in the same onUpdate from the same interpolated value: the year is Math.round(y) and the rail width is (y − START)/(END − START). With one source value there's no second animation to fall out of phase — the classic scrollytelling drift bug can't occur.
font-variant-numeric: tabular-nums renders every digit at equal width, so 1999 → 2000 doesn't reflow the glyph box. Without it, proportional digits (1 being narrow) make the number visibly breathe during fast scrubs. The gradient survives rewrites because background-clip: text is a style on the element, and only its text content changes.
Keep the counter as a ref write, not framework state — setting state 60 times a second would re-render per scroll tick. Build the timeline in a mount effect (useEffect, onMounted, ngAfterViewInit) inside gsap.context scoped to the stage ref and revert on cleanup so the pin unregisters. Era cards can render from an array; layout and the gradient text map directly to Tailwind utilities.