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



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
reduceemulation, 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
#fff19.75:1; year#a855f74.99:1 at 10.4 px (barely compliant); descriptionrgba(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-scrolland 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 withouttabindex="0", add it with a:focus-visiblestyle. The observer needs no pointer: wheel, finger, keyboard andscrollToreveal the same way (note thatscroll-behavior: smoothmakes any assignedscrollTopglide: ~350 ms measured for 419 px). - Screen readers and no JavaScript: six
divs with no semantics — preferol > liwith anaria-label("Step 3 of 6"). Atopacity: 0the 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>. Putaria-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.
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):
<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>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
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
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.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.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.