Scroll✨ Premium

Timeline Scroll

Six milestones along a 2 px line whose height tracks the scroll to the exact percentage — scrollTop ÷ range (419 px) × 100 — plus an IntersectionObserver at threshold 0.4 that reveals each card once and for all: a 500 ms fade and a 20 px slide. Scroll back up and the line retreats while the cards stay; three of the six are already revealed before the first scroll.

JavaScriptCSSTimeline

You'll get access to the interactive demo with a free account.

This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Scroll has 70 effects, including 12 free. Explore the category →

3 usage examples

Steps of a court case — case-tracking portal — Timeline Scroll example 1

① Steps of a court case — case-tracking portal

WhenCase file portal: the lawyer or the party opens the procedural timeline (complaint filed, formal investigation, referral for trial, hearing and deliberation, service of the judgment) and scrolls; a column on the right shows progress.
WhyThe line's height is a continuous variable, tied directly to the scroll: it expresses the share of the path completed more finely than a row of dots, and the same ratio feeds the percentage and the count of milestones reached in the right-hand column. The cards, meanwhile, give the detail of each act — two pieces of information in a single interaction.
Settings340 × 300 px zone (242 px range), five 84 px steps; the line runs from the centre of the first milestone to the centre of the last (top: 69px; bottom: 135px, 338 px) so that 0% and 100% mean "first" and "last step"; dot recentred on the line (left: -20px), descriptions at .55 (5.3:1). The column reads the ratio on the same scroll event: percentage, bar, and milestones reached = dots whose centre is above the tip (69 + r × 338 px). The two upcoming steps keep a hollow dot under .visible.
Consultation history — shared health record — Timeline Scroll example 2

② Consultation history — shared health record

WhenWeb interface of a health record on a locum doctor's tablet: a patient's visits, prescription, hospital stay, exam and vaccine on a dated axis, discovered in sequence on the first read-through of the history.
WhyThe observer's one-way behaviour is the decisive point here: a revealed entry never disappears, so the doctor can scroll back to compare a hospital stay in May with an echocardiogram in June without either of them fading — the opposite of fx-0577's two-way mode, chosen deliberately for comparing antecedents. The reviewed-entries counter never goes down, for the same reason.
Settings368 × 316 px zone (256 px range), six entries of 70 to 85 px typed by a badge (visit, prescription, hospital stay, exam, vaccine), #22d3ee → #3b82f6 gradient and cyan dot, lower-case date; line from first to last milestone (top: 69px; bottom: 121px, 382 px). Own instance (#hs-scroll, #hs-fill, [data-tl2]): the sold code only drives #timeline-scroll. Counter fed by a MutationObserver on the visible class.
Traceability of a production batch — industrial quality tool — Timeline Scroll example 3

③ Traceability of a production batch — industrial quality tool

WhenQuality management system: a batch's flow (raw material receipt, mixing, moulding, dimensional check, packaging, shipping) on a vertical axis, with a deviation recorded at the fourth step; the quality manager scrolls to locate it and measure what is already validated.
WhyThe dual mechanism encodes two things at once: the batch's overall progress through the line's height — green, then amber at the deviation — and the detail of each step through its card, whose dot changes colour with the status. A progress bar alone would not say where the deviation is; a list alone would not say what share of the flow is done.
Settings340 × 300 px zone (296 px range), six steps; #22c55e gradient up to 56% then #f59e0b from 64% of the line, the deviation sitting at 62% (dot at 322 px on a 407 px line, from 69 to 476 px); deviation milestone in amber with a 14 px glow, blocked steps with a hollow grey #475569 dot, times in monospace; "flow covered" column on the same ratio.

How it works

The #timeline-scroll block (.scroll-container, 280px tall, overflow: hidden auto, scroll-behavior: smooth) holds .timeline-inner (position: relative, padding 20px 16px 20px 40px): an absolute 2 px track .timeline-line at left: 24px, stretched from top: 20px to bottom: 20px — so over the whole content, not the viewport — and inside it #timeline-fill at height: 0%, a vertical gradient #6366f1 → #a855f7 → #ec4899, transition: height .15s linear. Each .timeline-item[data-timeline] starts at opacity: 0 and translateX(20px) with a .5s cubic-bezier(.16, 1, .3, 1) transition; its 10 px dot (#1a1a2e, border at 10% white) turns blue #6366f1 with a 12 px glow under .visible. Measured in Chromium: 699 px of content — a 60 px spacer on top, six cards of 79.8 px, two 60 px spacers at the bottom — hence a scroll range of 419 px; a 658.8 px line, dots centred at 89, 169, 249, 328, 408 and 488 px, and 3 px to the left of the line (dot centre at 22 px, line from 24 to 26 px).

First channel, the line. A scroll listener writes scrollTop / (scrollHeight − clientHeight) × 100 into the fill's style.height, as a percentage of the line: 9.55% at 40 px of scroll, 100% at 419 px, 0% back at the top. Because the line spans the whole content, the tip sits, measured from the top of the frame, at 20 + 240 × r px (r = scroll ratio): it sweeps from 20 to 260 px of the 280 px frame and never leaves view. The 150 ms transition makes it trail the wheel: after a 210 px jump, 18 px of fill at 13 ms, 165 px at 80 ms, the targeted 330 px (50.1%) at 163 ms. No animation-timeline: a plain listener.

Second channel, the cards. An IntersectionObserver with root: timelineScroll, threshold: .4 and rootMargin: '0px' adds visible as soon as 40% of a card's height (31.9 px) is inside the frame, and never removes it — one-way, where fx-0577 (Reveal on Scroll) removes it on exit. Measured: 43% opacity 50 ms after the threshold, 72% at 100 ms, 93% at 200 ms, settled at 500 ms. The two channels are not in sync: cards 4, 5 and 6 are revealed at 72, 152 and 232 px of scroll (17, 36 and 55%) while the line only reaches their dot at 196, 247 and 298 px (47, 59 and 71%) — each card appears 66 to 124 px before the line catches up with it, and the remaining 45% of the range only fills 191 px of empty line below the last milestone. On load, cards 1 and 2 already carry visible in the sold HTML and card 3 (50.6% inside the frame) is revealed by the observer: nothing animates before card 4.

Accessibility

  • prefers-reduced-motion is absent from the code: measured under reduce emulation, the cards' 500 ms transition (72% opacity at 100 ms, identical), the 150 ms fill and the label's 2 s pulse are unchanged. The motion is reader-driven, but the 20 px slide and the animated fill are still animations: add @media (prefers-reduced-motion: reduce) { .timeline-item, .timeline-line-fill, .timeline-dot { transition: none } .scroll-hint-label { animation: none } .scroll-container { scroll-behavior: auto } } — cards appear at once, the line jumps, the information is kept.
  • Contrast: title #fff 19.75:1; year #a855f7 4.99:1 at 10.4 px (barely compliant); description rgba(255,255,255,.4) at 11.2 px = 3.77:1, below the required 4.5:1 — raise it to .5 (5.3:1). The "Scroll to explore" label peaks at 2.62:1 at rest and stays on screen, pulsing, once the scroll is over: remove it or hide it at 100%. The unlit dot (#1a1a2e, 1.16:1) is a deliberately invisible decorative state. On a light background nothing holds (description 1.0:1, year 3.96:1): redefine all four colours.
  • Keyboard and touch: measured in Chromium, Tab focuses #timeline-scroll and each down arrow scrolls 40 px — 9.5% of line, and cards revealed notch by notch; Firefox and Safari do not focus a scrolling block without tabindex="0", add it with a :focus-visible style. The observer needs no pointer: wheel, finger, keyboard and scrollTo reveal the same way (note that scroll-behavior: smooth makes any assigned scrollTop glide: ~350 ms measured for 419 px).
  • Screen readers and no JavaScript: six divs with no semantics — prefer ol > li with an aria-label ("Step 3 of 6"). At opacity: 0 the cards stay in the accessibility tree: all the content is read even before it is revealed, which is fine. Without script (measured): the first two cards show thanks to their hard-coded class, the other four stay at opacity 0 and the line stays empty — add <noscript><style>.timeline-item{opacity:1;transform:none}</style></noscript>. Put aria-hidden="true" on the dot, the line and the label.

Browser compatibility

ES2015 (const, arrow functions, NodeList.forEach) and IntersectionObserver with the root option — Chrome 51+, Firefox 55+, Safari 12.1+, Edge 15+. The CSS relies on the two-value form overflow: hidden auto (Chrome 68+, Firefox 61+, Safari 12.1+) and on scroll-behavior: smooth (ignored by Safari before 15.4, harmless). No animation-timeline or scroll-timeline: line and cards are driven by a scroll listener and an intersection observer, so the effect works on Firefox, which does not implement CSS scroll-driven animations. Zero dependencies.

Chrome 68+✓ Full
Firefox 61+✓ Full (native scrollbar)
Safari 12.1+✓ Full (smooth scrolling ignored before 15.4)
Edge 79+✓ Full
Mobile iOS 12.2+✓ Full (touch scrolling of the block)
Android Chrome✓ Full

Without JavaScript (measured): the block scrolls, the first two cards show (the visible class is written in the HTML), the other four stay invisible and the line stays empty. Without IntersectionObserver (Safari < 12.1), new IntersectionObserver throws a ReferenceError before the scroll listener is attached: neither cards nor line. Without overflow: hidden auto (Chrome < 68, Firefox < 61), the block stretches to its content height and nothing scrolls.

The code

HTML structure to paste into your page (CSS + JS available with a premium account):

index.html — structure
<div class="demo-preview">
  <div class="scroll-container" id="timeline-scroll">
    <div class="timeline-inner">
      <div class="timeline-line"><div class="timeline-line-fill" id="timeline-fill"></div></div>
      <div class="timeline-spacer"></div>
      <div class="timeline-item" data-timeline>
        <div class="timeline-dot"></div>
        <div class="timeline-year">…</div>
        <div class="timeline-title">…</div>
        <div class="timeline-desc">…</div>
      </div>
      <!-- × 6 -->
      <div class="timeline-spacer"></div>
      <div class="timeline-spacer"></div>
    </div>
  </div>
  <span class="scroll-hint-label">…</span>
</div>
🔒 Unlock the full code — from €2.99 the first month

Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Block height (CSS — .scroll-container { height: 280px }) 280 px Sets the viewport and therefore the range (content − height: 419 px here). Taller means more cards revealed on load (3 of 6 at 280 px); the line's tip always sits at 20 + (height − 40) × r px from the top of the frame.
Reveal threshold (JS — threshold: .4) .4 (40% of the card inside the frame, i.e. 31.9 px) 0 reveals at the first pixel, 1 requires the whole card — at 280 px tall, the last one only at the end of the range. Add rootMargin: '0px 0px -20% 0px' to reveal later without touching the threshold.
One-way (JS — e.isIntersecting && e.target.classList.add("visible")) never removed Add else e.target.classList.remove("visible") for a two-way rendering (that is fx-0577). To release the observer once a card is revealed: observer.unobserve(e.target) in the true branch — the sold code never observes fewer than six cards.
Card entrance (CSS — .timeline-item { transition: .5s cubic-bezier(.16, 1, .3, 1) } and translateX(20px)) .5 s heavily damped (72% at 100 ms), 20 px slide .25s when several cards enter at once during a fast scroll; ease-out for a more linear entrance; translateX(0) for a fade only, translateY(16px) for a rise.
Line trail (CSS — .timeline-line-fill { transition: height .15s linear }) 150 ms linear Smooths the wheel's jolts; measured, the tip reaches its target 163 ms after a jump. 0s for a line glued to the scroll, .4s for ink running behind the thumb.
Colours (CSS — gradient #6366f1, #a855f7, #ec4899; dot and glow #6366f1 / rgba(99,102,241,.4); year #a855f7) indigo → purple → pink The gradient is painted on the fill, so it unveils from top to bottom; two colours are enough, or an intermediate stop at the height of a key milestone (scene 3: green, then amber at the deviation). The glow reuses the dot colour at 40%.
Line extent (CSS — .timeline-line { top: 20px; bottom: 20px } and the 60 px .timeline-spacers) the whole content: 69 px above the first dot, 191 px below the last With the two trailing spacers, 100% arrives well after the last card. Set top and bottom to the centres of the first and last dots (scenes on this page) so that a full line means "last step".
Dot position (CSS — .timeline-dot { left: -23px }) −23 px (centre at 22 px, line at 25 px) The dot sits 3 px left of the line. left: -20px centres it exactly; if you change the padding-left of .timeline-inner (40 px) or the line's left (24 px), recompute: dot left = line + 1 − 5 − card padding (20 px).

FAQ

As is, the fill height is the scroll ratio: 50% of line does not mean three steps out of six. Measured, the tip passes the six dots at 10.5, 22.6, 34.7, 46.8, 58.9 and 71% of the range — then fills 191 px of empty line. For a business reading ("step 3 of 5"), replace the listener's computation with the position of the current milestone: timelineFill.style.height = (item.offsetTop + 9 − 20) + 'px' (dot centre minus the line's top) — the line becomes independent of the scroll and you keep the observer for the cards' entrance. The middle ground used in this page's scenes: keep the ratio, but stretch the line from the centre of the first dot to the centre of the last (adjusted top and bottom) so that 0% and 100% coincide with the first and last steps, and have a column read the same ratio and count the dots the tip has passed.
Three things, in the code. fx-0577 removes the visible class when the card leaves (isIntersecting ? add : remove, threshold .3): its blocks replay on every pass, fx-0582's stay (measured: scrolled back to the top after a full pass, visible = 111111 and line at 0%). fx-0582 adds the line and its scroll listener, which fx-0577 lacks. And the entrance differs: a 20 px horizontal slide over 500 ms versus 30 px vertical over 600 ms. Pick fx-0577 for a marketing page where every section must come alive again, fx-0582 when what has been seen must stay — history, procedure, traceability — and the share covered matters.
Not by pasting the code twice: the three consts are global and the second script stops on SyntaxError: Identifier 'timelineScroll' has already been declared (measured: the second timeline neither fills nor reveals). Even wrapped, the script targets a single #timeline-scroll and observes every [data-timeline] on the page with that one block as root — cards from another block are never intersecting. Wrap the code in document.querySelectorAll('.scroll-container').forEach(zone => { const fill = zone.querySelector('.timeline-line-fill'), items = zone.querySelectorAll('[data-timeline]'); … }): one observer and one listener per block, ids removed. That is how scenes 2 and 3 of this page are instantiated.