---
name: timeline-scroll-animation
description: Use when you want "Clear, professional, narrative" - Animates timeline items as the user moves through history or process steps.
---

# Timeline Scroll Animation

> **Category:** Scroll  -  **Personality:** Clear, professional, narrative
>
> **Best use:** Roadmaps, histories, processes
>
> **Ratings:** Professional 5/5 - Casual 3/5 - Exotic 2/5 - Visual impact 4/5 - Difficulty 3/5 - Performance cost 2/5

## What it is

A vertical timeline that tells a story as you scroll: a central **progress line draws itself** in sync with scroll position, each **milestone fades and slides in** as it enters the viewport, and each **node lights up** the moment the drawing tip passes it. It pairs two independent mechanisms — an `IntersectionObserver` for the per-item entrance and a `requestAnimationFrame` scroll handler for the continuous line — so the page feels guided without hijacking the scroll. Ideal for company histories, product roadmaps, and multi-step processes. Under `prefers-reduced-motion` (or with JS disabled) it degrades to the finished, fully-drawn timeline with every milestone visible.

## Dependencies / CDN

**None - pure CSS + vanilla JS.** No scroll library is needed; the line is driven by one CSS custom property.

The demo's fonts are optional flavour (an editorial serif for the years + a grotesk for body):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;600;700&family=Newsreader:ital,opsz,wght@0,6..72,500;1,6..72,500&display=swap" rel="stylesheet">
```

## HTML

Semantic ordered list; the line and nodes are decorative (`aria-hidden`), the year is a real `<time>`:

```html
<section class="tsa-stage" id="tsaStage">
  <div class="tsa-track" id="tsaTrack">
    <div class="tsa-line" aria-hidden="true"><span class="tsa-progress"></span></div>
    <ol class="tsa-items">
      <li class="tsa-item tsa-left">
        <span class="tsa-node" aria-hidden="true"></span>
        <div class="tsa-card">
          <time class="tsa-year" datetime="2018">2018</time>
          <h3 class="tsa-mtitle">The first forecast</h3>
          <p class="tsa-desc">Two atmospheric scientists and one stubborn whiteboard…</p>
        </div>
      </li>
      <li class="tsa-item tsa-right">…</li>   <!-- alternate tsa-left / tsa-right -->
    </ol>
  </div>
</section>
```

## CSS

```css
/* the track holds the line; --tsa-p (0..1) is how far it has drawn. Default 1 = "finished"
   so reduced-motion / no-JS users see the complete timeline. */
.tsa-stage{--tsa-p:1; --accent:#0e9b78; --rail:#ddd4c1}
.tsa-track{position:relative}
.tsa-line{position:absolute;inset:0 auto 0 50%;width:2px;transform:translateX(-50%);background:var(--rail)}
.tsa-progress{position:absolute;left:0;top:0;width:100%;height:calc(var(--tsa-p)*100%);
  background:linear-gradient(180deg,var(--accent),#0a6f57);will-change:height}
.tsa-progress::after{                       /* glowing "comet" at the drawing tip */
  content:"";position:absolute;left:50%;bottom:0;width:13px;height:13px;border-radius:50%;
  transform:translate(-50%,50%);background:var(--accent);opacity:0;
  box-shadow:0 0 0 5px rgba(14,155,120,.16),0 0 22px 5px rgba(14,155,120,.55)}

.tsa-items{list-style:none;margin:0;padding:0}
.tsa-item{position:relative;display:flex;margin-bottom:48px}
.tsa-item.tsa-left{justify-content:flex-start}
.tsa-item.tsa-right{justify-content:flex-end}
.tsa-card{width:calc(50% - 48px);background:#fffdf7;border:1px solid #e8e0ce;border-radius:16px;padding:20px 24px}

.tsa-node{position:absolute;top:30px;left:50%;width:17px;height:17px;border-radius:50%;
  transform:translate(-50%,-50%);background:#fffdf7;border:2px solid var(--accent);z-index:3;
  transition:border-color .45s,box-shadow .45s}
.tsa-node::after{content:"";position:absolute;inset:3px;border-radius:50%;background:var(--accent);transition:background .45s}

/* --- the motion layer is gated on .tsa-js, which JS adds only when motion is allowed --- */
@media (prefers-reduced-motion: no-preference){
  .tsa-stage.tsa-js{--tsa-p:0}                                  /* JS will grow it from 0 */
  .tsa-js .tsa-progress::after{opacity:1}
  .tsa-js .tsa-item{opacity:0;transition:opacity .7s cubic-bezier(.22,.61,.36,1),transform .7s cubic-bezier(.22,.61,.36,1)}
  .tsa-js .tsa-left{transform:translate(-34px,16px)}
  .tsa-js .tsa-right{transform:translate(34px,16px)}
  .tsa-js .tsa-item.tsa-show{opacity:1;transform:none}          /* IntersectionObserver adds .tsa-show */
  .tsa-js .tsa-node{border-color:var(--rail)}                   /* nodes start grey… */
  .tsa-js .tsa-node::after{background:var(--rail)}
  .tsa-js .tsa-item.tsa-reached .tsa-node{border-color:var(--accent);box-shadow:0 0 0 5px rgba(14,155,120,.14)}
  .tsa-js .tsa-item.tsa-reached .tsa-node::after{background:var(--accent)}  /* …light when the line passes */
}

/* collapse the centred line to a left rail on small screens */
@media (max-width:720px){
  .tsa-line{left:21px}
  .tsa-item.tsa-left,.tsa-item.tsa-right{justify-content:flex-start}
  .tsa-card{width:100%;margin-left:46px}
  .tsa-node{left:21px}
}
@media (max-width:720px) and (prefers-reduced-motion: no-preference){
  .tsa-js .tsa-left,.tsa-js .tsa-right{transform:translate(0,28px)}
}
```

## JavaScript

```js
(function(){
  var stage=document.getElementById('tsaStage'), track=document.getElementById('tsaTrack');
  var items=[].slice.call(document.querySelectorAll('.tsa-item'));
  if(!stage||!track||!items.length) return;
  if(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return; // CSS already shows all

  stage.classList.add('tsa-js');                       // switch on the hidden -> reveal states

  /* 1) reveal each milestone as it enters the viewport */
  var io=new IntersectionObserver(function(es){
    es.forEach(function(e){ if(e.isIntersecting){ e.target.classList.add('tsa-show'); io.unobserve(e.target); } });
  },{threshold:.25, rootMargin:'0px 0px -12% 0px'});
  items.forEach(function(it){ io.observe(it); });

  /* 2) draw the line on scroll + light nodes as the tip passes them.
        offsetTop is immune to the reveal transforms, so node centres stay correct while items are still hidden. */
  var NODE_TOP=30, centers=[], trackH=1;
  function posWithin(el,anc){ var y=0; while(el&&el!==anc){ y+=el.offsetTop; el=el.offsetParent; } return y; }
  function measure(){ trackH=track.offsetHeight||1; centers=items.map(function(it){ return posWithin(it,track)+NODE_TOP; }); }

  var ticking=false;
  function update(){
    ticking=false;
    var r=track.getBoundingClientRect();
    var p=(window.innerHeight*0.62 - r.top)/(r.height||1);   // reading line ~62% down the viewport
    p=p<0?0:p>1?1:p;
    stage.style.setProperty('--tsa-p', p.toFixed(4));
    var tip=p*trackH;
    for(var i=0;i<items.length;i++){
      if(tip>=centers[i]-4){ items[i].classList.add('tsa-reached'); items[i].classList.add('tsa-show'); }
      else items[i].classList.remove('tsa-reached');
    }
  }
  function onScroll(){ if(!ticking){ ticking=true; requestAnimationFrame(update); } }

  measure(); update();
  addEventListener('scroll', onScroll, {passive:true});
  addEventListener('resize', function(){ measure(); update(); });
  if(document.fonts&&document.fonts.ready) document.fonts.ready.then(function(){ measure(); update(); });
})();
```

## How it works

- **The line is one CSS variable.** `.tsa-progress` height is `calc(var(--tsa-p)*100%)`. The scroll handler maps a reading line (62 % down the viewport) to a `0..1` value and writes it to `--tsa-p`. Because the default value is `1`, the line is *already finished* for anyone who never runs the script.
- **Entrance vs. line are decoupled.** `IntersectionObserver` is responsible only for fading/sliding each card in once (`unobserve` after the first reveal). The line and node states come from the scroll handler — two simple jobs instead of one tangled one.
- **Nodes sync to the tip, not the viewport.** Each node's centre offset within the track is measured once with `offsetTop` (which ignores CSS transforms, so measuring works even while items are translated/hidden). A node lights when `--tsa-p * trackHeight` passes that offset, so the dot ignites exactly as the drawing tip reaches it.
- **`.tsa-js` is the graceful-degradation switch.** The hidden initial state lives entirely inside `@media (prefers-reduced-motion: no-preference) .tsa-js{…}`. JS adds `tsa-js`; if JS is absent or motion is reduced, the class never appears and the natural CSS state is the fully-revealed timeline.
- **`requestAnimationFrame` throttling** coalesces scroll events to one write per frame; the listener is `passive` so it never blocks scrolling.

## Customization

- **Layout:** centred-alternating by default; the `max-width:720px` block flips it to a single left rail. To start as a rail everywhere, just drop the centred rules and keep the rail ones.
- **Palette:** change `--accent` / `--rail` on `.tsa-stage`. The progress gradient, node, comet and "reached" glow all derive from it.
- **Trigger point:** move the reading line by editing `*0.62` (lower = the line leads further ahead of the reader). Tune the entrance with the IO `threshold` / `rootMargin`.
- **Feel:** swap the easing (`cubic-bezier(.22,.61,.36,1)`) and the translate distance in `.tsa-left/.tsa-right` for a softer or snappier arrival. Add a `@keyframes` pulse to `.tsa-progress::after` for a living comet (gate it inside the no-preference query).

## Accessibility & performance

- **Reduced motion = all shown.** The whole hidden/animated layer is inside `prefers-reduced-motion: no-preference`, and the JS bails on `reduce`, so those users get the complete timeline with no movement — no information is hidden behind an animation.
- **Semantics:** use `<ol>`/`<li>` for the ordered milestones and `<time datetime="…">` for the year; mark the line and nodes `aria-hidden` since they're decorative.
- **Cheap to run:** only one element's `height` changes per frame, and it's `position:absolute` inside a fixed-size track, so it repaints rather than reflowing the page. Add `will-change:height` (already set). For maximum smoothness you can swap to `transform:scaleY(var(--tsa-p))` with `transform-origin:top` and move the comet separately.

## Gotchas

- **Don't measure node positions with `getBoundingClientRect` while items are transformed** — the reveal `translate` shifts them and your nodes light at the wrong scroll point. Use the transform-immune `offsetTop` chain (as above).
- **Default the line to "drawn," not "empty."** If `--tsa-p` defaulted to `0`, a reduced-motion or no-JS user would see a blank rail. Keep the dynamic `--tsa-p:0` start scoped to `.tsa-js` inside the no-preference query.
- **`IntersectionObserver` never fires for items you instantly jump past** (deep links, scroll restoration). That's why `update()` also adds `.tsa-show` when the line has already reached an item — the line and the cards can never disagree.
- **`overflow:hidden` + `border-radius` on the stage clips the comet's glow** at the edges. That's usually what you want for a framed panel; widen the padding if you need the glow to breathe.
- **Re-measure after fonts load.** Web-font swaps change card heights and therefore node offsets; the `document.fonts.ready` re-measure prevents the nodes from drifting off the line.
