← Charts

Timeline Reveal

A timeline whose line fills in as you scroll, each milestone’s dot popping and its text sliding in the moment the line reaches it. On wide screens it can alternate left and right of a central line.

Scroll inside ↓

  1. The idea

    A folder of favourite effects, saved from a hundred sites.

  2. First snippet

    Character Rise: one script tag, no build step.

  3. Fifty strong

    Text, scroll and hover effects, all copy and paste.

  4. No framework

    Plain JavaScript that drops into any site.

  5. A proper home

    Every animation with its own page and live demo.

  6. Ready to serve

    Charts, overlays and more, still free.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-timeline-reveal to a list of milestones, e.g. an <ol> with a <time>, heading and text in each item.

Options

Add any of these attributes to the same element.

data-colorLine and dot colour. Default #a855f7.
data-alternateMilestones take turns left and right of a central line, when there’s room.
data-atHow far down the screen the line’s tip sits, 0–1. Default 0.7.
data-repeatHide milestones again when you scroll back above them.

It follows the page or any scrolling box it’s in. With reduced motion the whole timeline is simply shown.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Timeline Reveal: a vertical timeline whose line fills in as you scroll,
    // each milestone's dot popping and its text sliding in the moment the
    // line reaches it. Works in the page or inside any scrolling box.
    // Usage:
    //   <ol data-timeline-reveal>
    //     <li><time>2019</time><h3>Founded</h3><p>Two people and a laptop.</p></li>
    //     <li><time>2021</time><h3>First hire</h3><p>…</p></li>
    //   </ol>
    // Each child is a milestone; whatever's inside it slides in, piece by
    // piece. Optional: data-color (line and dots, default #a855f7),
    // data-alternate (milestones take turns left and right of a central line,
    // when there's room), data-at (how far down the screen the line's tip
    // sits, 0–1, default 0.7), data-repeat (milestones hide again when you
    // scroll back up past them).
    var DEFAULTS = { color: '#a855f7', at: 0.7 };

    // Layout on the attribute, so it's right even with reduced motion.
    var css =
      '[data-timeline-reveal]{--tlr-color:#a855f7;--tlr-p:0;position:relative;margin:0;padding:4px 0 4px 40px;list-style:none;text-align:left}' +
      '[data-timeline-reveal]::before,[data-timeline-reveal]::after{content:"";position:absolute;left:13px;top:10px;bottom:10px;width:3px;border-radius:3px;background:rgba(120,100,150,.18)}' +
      '[data-timeline-reveal]::after{background:linear-gradient(var(--tlr-color),color-mix(in srgb,var(--tlr-color) 55%,#ec4899));transform-origin:top;transform:scaleY(var(--tlr-p))}' +
      '[data-timeline-reveal]>li{position:relative;margin:0;padding:0 0 26px}' +
      '[data-timeline-reveal]>li:last-child{padding-bottom:0}' +
      '[data-timeline-reveal]>li>*{margin:0}' +
      '[data-timeline-reveal] time{display:inline-block;margin-bottom:4px;padding:1px 9px;border-radius:99px;font-size:.8em;font-weight:700;letter-spacing:.02em;' +
      'background:color-mix(in srgb,var(--tlr-color) 14%,transparent);color:color-mix(in srgb,var(--tlr-color) 70%,#000)}' +
      '.tlr-dot{position:absolute;left:-34px;top:.25em;z-index:1;width:17px;height:17px;border-radius:50%;background:#fff;border:3.5px solid var(--tlr-color);box-sizing:border-box}' +
      '.tlr-ring{position:absolute;inset:-4px;border-radius:50%;border:2px solid var(--tlr-color);opacity:0}' +
      '.tlr-alt{padding-left:0}' +
      '.tlr-alt::before,.tlr-alt::after{left:calc(50% - 1.5px)}' +
      '.tlr-alt>li{width:50%;padding-right:34px;text-align:right}' +
      '.tlr-alt>li:nth-child(even){margin-left:50%;padding-right:0;padding-left:34px;text-align:left}' +
      '.tlr-alt>li>.tlr-dot{left:auto;right:-8.5px}' +
      '.tlr-alt>li:nth-child(even)>.tlr-dot{left:-8.5px;right:auto}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function scroller(el) {
      for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
        var o = getComputedStyle(p).overflowY;
        if ((o === 'auto' || o === 'scroll') && p.scrollHeight > p.clientHeight) return p;
      }
      return null;
    }

    function setup(el, reduced) {
      var d = el.dataset;
      if (d.color) el.style.setProperty('--tlr-color', d.color);
      var items = Array.from(el.children).map(function (li) {
        var dot = document.createElement('span');
        dot.className = 'tlr-dot';
        dot.setAttribute('aria-hidden', 'true');
        dot.innerHTML = '<span class="tlr-ring"></span>';
        var parts = Array.from(li.children);
        li.insertBefore(dot, li.firstChild);
        return { li: li, dot: dot, ring: dot.firstChild, parts: parts, shown: false };
      });
      var t = { el: el, items: items, box: scroller(el), at: d.at !== undefined ? +d.at : DEFAULTS.at, repeat: d.repeat !== undefined && d.repeat !== 'false', p: -1 };

      // Alternate sides only when there's room for both.
      if (d.alternate !== undefined && d.alternate !== 'false') {
        var fit = function () { el.classList.toggle('tlr-alt', el.clientWidth >= 560); };
        fit();
        if (window.ResizeObserver) new ResizeObserver(fit).observe(el);
      }

      if (reduced) { el.style.setProperty('--tlr-p', 1); return null; }
      items.forEach(function (it) {
        anime.utils.set(it.dot, { scale: 0 });
        anime.utils.set(it.parts, { opacity: 0 });
      });
      return t;
    }

    function side(t, it) {
      if (!t.el.classList.contains('tlr-alt')) return 1;
      return t.items.indexOf(it) % 2 ? 1 : -1;
    }

    function show(t, it) {
      it.shown = true;
      anime.animate(it.dot, { scale: [0, 1], duration: 600, ease: 'outBack(3)' });
      anime.animate(it.ring, { scale: [0.6, 2], opacity: [0.7, 0], duration: 800, delay: 120, ease: 'out(2)' });
      anime.animate(it.parts, { opacity: [0, 1], x: [24 * side(t, it), 0], duration: 620, delay: anime.stagger(70, { start: 80 }), ease: 'out(4)' });
    }

    function hide(t, it) {
      it.shown = false;
      anime.animate(it.dot, { scale: 0, duration: 250, ease: 'in(2)' });
      anime.animate(it.parts, { opacity: 0, x: 12 * side(t, it), duration: 250, ease: 'in(2)' });
    }

    function update(t) {
      var view = t.box ? t.box.getBoundingClientRect() : { top: 0, height: window.innerHeight };
      var r = t.el.getBoundingClientRect();
      if (!r.height) return; // hidden for now
      var tip = view.top + view.height * t.at;
      // The line's own top and bottom (it runs from the first dot to the last).
      var top = r.top + 10, len = Math.max(1, r.height - 20);
      var p = Math.max(0, Math.min(1, (tip - top) / len));
      if (p === t.p) return;
      t.p = p;
      t.el.style.setProperty('--tlr-p', p);
      var reach = top + p * len;
      t.items.forEach(function (it) {
        var y = it.dot.getBoundingClientRect().top + 8;
        // Everything above the bottom of the box counts as reached at the end.
        var hit = y <= reach + 2 || (p >= 1 && y <= view.top + view.height);
        if (hit && !it.shown) show(t, it);
        else if (!hit && it.shown && t.repeat) hide(t, it);
      });
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var list = [];
      document.querySelectorAll('[data-timeline-reveal]').forEach(function (el) {
        if (el._tlr) return;
        el._tlr = true;
        var t = setup(el, reduced);
        if (t) list.push(t);
      });
      if (!list.length) return;
      var frame = 0;
      var tick = function () {
        if (frame) return;
        frame = requestAnimationFrame(function () {
          frame = 0;
          list.forEach(function (t) {
            // The scrolling box may only become scrollable once laid out.
            if (!t.box) t.box = scroller(t.el);
            update(t);
          });
        });
      };
      // Capturing catches scrolling inside any box, not just the page.
      document.addEventListener('scroll', tick, { capture: true, passive: true });
      window.addEventListener('resize', tick);
      tick();
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<ol data-timeline-reveal>
  <li><time>2019</time><h3>Founded</h3><p>Two people and a laptop.</p></li>
  <li><time>2021</time><h3>First hire</h3><p>…</p></li>
  <li><time>2024</time><h3>Ten thousand users</h3><p>…</p></li>
</ol>