Self-Drawing Vertical Timeline with Pop-In Items

A company history timeline in a fixed-height scrolling frame. A gradient line draws down the middle as the frame scrolls, led by a glowing tip. When the tip reaches a milestone its dot snaps in with a ring burst, the card springs into place and the year slides out on the opposite side. On desktop the cards alternate left and right of the line; on phones the line moves to the left edge and the cards stack in one column. It scrolls itself once on load to show the effect.

Code

import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { motion, useMotionValue, useMotionValueEvent, useReducedMotion, useSpring, useTransform } from "framer-motion";

const milestones = [
  { year: "2017", title: "Two desks above a print shop", body: "Odile and Rafael rent a back room in Porto and start drawing a grotesque for a friend's bakery.", tag: "Founded" },
  { year: "2018", title: "Corvid Sans ships", body: "The first family goes on sale in nine weights. It pays the rent by its third month.", tag: "First release" },
  { year: "2020", title: "Everything goes variable", body: "The whole catalogue is rebuilt on a single weight and width axis, cutting file sizes by more than half.", tag: "Rebuild" },
  { year: "2022", title: "A studio of twelve", body: "Type designers, two engineers and a full-time support desk, now spread across four time zones.", tag: "Team" },
  { year: "2024", title: "Licences made simple", body: "One page of plain language replaces a forty page agreement. Refund requests drop to nearly zero.", tag: "Licensing" },
  { year: "2025", title: "The toolchain goes open", body: "The build scripts behind every Tessel font are published for any foundry to use.", tag: "Open source" },
];

const DEMO_SECONDS = 10;

function easeDemo(t: number) {
  if (t < 0.1) return (5 * t * t) / 0.9;
  if (t > 0.9) return 1 - (5 * (1 - t) * (1 - t)) / 0.9;
  return (t - 0.05) / 0.9;
}

/** Scrolls the frame slowly once so the preview shows the effect. Stops for good on any input. */
function useDemoScroll(ref: RefObject<HTMLDivElement>, enabled: boolean, seconds: number) {
  useEffect(() => {
    const el = ref.current;
    if (!el || !enabled) return;
    let raf = 0;
    let last = 0;
    let t = 0;
    let done = false;
    const inputs = ["wheel", "touchstart", "pointerdown", "keydown"];
    const tick = (now: number) => {
      if (done) return;
      const dt = last ? Math.min(now - last, 50) : 0;
      last = now;
      t = Math.min(1, t + dt / (seconds * 1000));
      el.scrollTop = easeDemo(t) * (el.scrollHeight - el.clientHeight);
      if (t < 1) raf = requestAnimationFrame(tick);
    };
    const io = new IntersectionObserver(
      ([entry]) => {
        cancelAnimationFrame(raf);
        last = 0;
        if (entry.isIntersecting && !done) raf = requestAnimationFrame(tick);
      },
      { threshold: 0.25 }
    );
    const stop = () => {
      done = true;
      cancelAnimationFrame(raf);
    };
    io.observe(el);
    inputs.forEach((name) => el.addEventListener(name, stop, { passive: true }));
    return () => {
      stop();
      io.disconnect();
      inputs.forEach((name) => el.removeEventListener(name, stop));
    };
  }, [ref, enabled, seconds]);
}

export default function SelfDrawingVerticalTimelineWithPopInItems() {
  const reduced = !!useReducedMotion();
  const frame = useRef<HTMLDivElement>(null);
  const list = useRef<HTMLOListElement>(null);
  const dots = useRef<(HTMLSpanElement | null)[]>([]);
  const dotYs = useRef<number[]>([]);
  const target = useMotionValue(0);
  // The spring is what makes the line feel drawn by hand instead of glued to the scrollbar.
  const tip = useSpring(target, { stiffness: 70, damping: 20, mass: 0.6 });
  const height = useMotionValue(1);
  const scaleY = useTransform([tip, height], ([t, h]: number[]) => Math.max(0, Math.min(1, t / h)));
  const [reached, setReached] = useState(reduced ? milestones.length : 0);

  useDemoScroll(frame, !reduced, DEMO_SECONDS);

  useEffect(() => {
    if (reduced) setReached(milestones.length);
  }, [reduced]);

  useEffect(() => {
    const el = frame.current;
    const ol = list.current;
    if (!el || !ol) return;
    let raf = 0;
    const measure = () => {
      raf = 0;
      const listRect = ol.getBoundingClientRect();
      const h = ol.offsetHeight;
      height.set(h);
      dotYs.current = dots.current.map((d) => (d ? d.getBoundingClientRect().top + d.offsetHeight / 2 - listRect.top : 0));
      const max = el.scrollHeight - el.clientHeight;
      const p = max > 0 ? el.scrollTop / max : 1;
      // The tip follows a line 80% down the frame, and is guaranteed to reach the end at full scroll.
      const anchor = el.getBoundingClientRect().top + el.clientHeight * 0.8 - listRect.top;
      const next = Math.max(0, Math.min(h, Math.max(anchor, p * h)));
      if (reduced) {
        target.jump(h);
        tip.jump(h);
      } else target.set(next);
    };
    const onScroll = () => {
      if (!raf) raf = requestAnimationFrame(measure);
    };
    measure();
    el.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      cancelAnimationFrame(raf);
      el.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
    };
  }, [reduced, target, tip, height]);

  useMotionValueEvent(tip, "change", (y) => {
    const count = dotYs.current.filter((d) => d <= y + 2).length;
    setReached((prev) => (count > prev ? count : prev));
  });

  return (
    <section className="bg-violet-100 px-4 py-10 sm:py-14">
      <div className="mx-auto max-w-4xl overflow-hidden rounded-3xl bg-white shadow-xl shadow-violet-900/10 ring-1 ring-violet-900/10">
        <div
          ref={frame}
          tabIndex={0}
          role="region"
          aria-label="Tessel Type company timeline"
          className="relative h-[640px] overflow-y-auto overscroll-contain focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-600"
        >
          <div className="px-5 pb-16 pt-10 sm:px-10">
            <div className="md:text-center">
              <p className="text-xs font-semibold uppercase tracking-[0.2em] text-violet-700">Tessel Type ยท Our story</p>
              <h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl">Eight years of drawing letters</h2>
              <p className="mt-3 text-base text-slate-600 md:mx-auto md:max-w-md">From a borrowed back room to fonts on forty thousand websites.</p>
            </div>

            <ol ref={list} className="relative mt-10">
              <span aria-hidden="true" className="absolute bottom-0 left-4 top-0 -ml-px w-0.5 rounded-full bg-slate-200 md:left-1/2" />
              <motion.span
                aria-hidden="true"
                className="absolute bottom-0 left-4 top-0 -ml-[1.5px] w-[3px] origin-top rounded-full bg-gradient-to-b from-violet-500 via-fuchsia-500 to-rose-400 md:left-1/2"
                style={{ scaleY }}
              />
              {!reduced && (
                <motion.span aria-hidden="true" className="absolute left-4 top-0 -ml-2 -mt-2 h-4 w-4 md:left-1/2" style={{ y: tip }}>
                  <span className="absolute inset-0 animate-ping rounded-full bg-fuchsia-400 opacity-60" />
                  <span className="absolute inset-1 rounded-full bg-fuchsia-500 shadow-[0_0_14px_4px_rgba(217,70,239,0.55)]" />
                </motion.span>
              )}

              {milestones.map((m, i) => {
                const on = i < reached;
                const left = i % 2 === 0;
                return (
                  <li key={m.year} className="relative pb-8 pl-12 last:pb-2 md:grid md:grid-cols-2 md:gap-x-20 md:pb-5 md:pl-0">
                    <span
                      ref={(node) => {
                        dots.current[i] = node;
                      }}
                      aria-hidden="true"
                      className="absolute left-4 top-5 -ml-3 h-6 w-6 md:left-1/2"
                    >
                      <motion.span
                        className="absolute inset-0 rounded-full border-2 border-fuchsia-400"
                        initial={false}
                        animate={on && !reduced ? { scale: [1, 2.6], opacity: [0.9, 0] } : { scale: 1, opacity: 0 }}
                        transition={{ duration: 0.8, ease: "easeOut" }}
                      />
                      <motion.span
                        className="absolute inset-0 flex items-center justify-center rounded-full bg-white ring-2 ring-violet-500"
                        initial={false}
                        animate={on ? { scale: 1, opacity: 1 } : { scale: 0, opacity: 0 }}
                        transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 14 }}
                      >
                        <span className="h-2 w-2 rounded-full bg-violet-600" />
                      </motion.span>
                    </span>

                    <motion.div
                      className={`md:row-start-1 ${left ? "md:col-start-1" : "md:col-start-2"}`}
                      initial={false}
                      animate={on ? { opacity: 1, y: 0, scale: 1, rotate: 0 } : { opacity: 0, y: 28, scale: 0.88, rotate: left ? -2 : 2 }}
                      transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 260, damping: 17, delay: 0.06 }}
                      style={{ transformOrigin: left ? "100% 20%" : "0% 20%" }}
                    >
                      <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
                        <div className="flex items-center justify-between gap-3">
                          <span className="rounded-full bg-violet-100 px-2.5 py-1 text-xs font-semibold text-violet-800">{m.tag}</span>
                          <span className="text-sm font-semibold tabular-nums text-slate-600 md:sr-only">{m.year}</span>
                        </div>
                        <h3 className="mt-3 text-lg font-semibold tracking-tight text-slate-900">{m.title}</h3>
                        <p className="mt-1.5 text-sm leading-6 text-slate-600">{m.body}</p>
                      </div>
                    </motion.div>

                    <motion.p
                      aria-hidden="true"
                      className={`hidden self-start pt-2 text-6xl font-semibold tabular-nums tracking-tighter md:row-start-1 md:block ${left ? "md:col-start-2 text-left" : "md:col-start-1 text-right"}`}
                      initial={false}
                      animate={on ? { opacity: 1, x: 0 } : { opacity: 0, x: left ? -24 : 24 }}
                      transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 140, damping: 18, delay: 0.14 }}
                    >
                      <span className="bg-gradient-to-br from-violet-600 to-rose-400 bg-clip-text text-transparent">{m.year}</span>
                    </motion.p>
                  </li>
                );
              })}
            </ol>

            <div className="mt-8 rounded-2xl bg-slate-900 p-6 text-center">
              <p className="text-lg font-semibold text-white">Next on the line: your project.</p>
              <a href="#" className="mt-4 inline-flex h-11 items-center rounded-full bg-white px-5 text-sm font-semibold text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fuchsia-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900">
                Browse the catalogue
              </a>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/self-drawing-vertical-timeline-with-pop-in-items.json

When to use it

How to customize it

Accessibility

Questions

How does the line draw itself on scroll?

Two lines sit in the same place: a grey track and a gradient line with `transform-origin: top`. The gradient line's `scaleY` is the tip position divided by the list height. The tip position comes from the scroll offset and is passed through a spring, which is why the line appears to be drawn instead of jumping.

How do items know when to pop in?

The vertical position of each dot is measured once and again on resize. Whenever the tip moves, the component counts how many dots it has passed and stores the highest count in state. Each item animates from its hidden pose to its resting pose when its index is below that count.

Why is the dot wrapped in two elements?

The outer element positions the dot on the line with Tailwind classes. The inner one is scaled by framer-motion. Animating transform on an element that is also positioned with a translate class would overwrite the translate, so the two jobs are kept apart.

Can I make it one-sided on desktop too?

Yes. Remove the `md:grid md:grid-cols-2` classes on the list item, the `md:left-1/2` classes on the line, tip and dot, and the large year paragraph. The phone layout then applies at every width.

How do I use it with the page scroll?

Remove the frame's fixed height and overflow, listen to `window` scroll, and replace the frame measurements with `window.innerHeight` and a top of 0. The rest of the logic stays the same.

Related: Reading Progress Bar with Section Reveals, Sticky Scroll Storytelling with Pinned Visual, Animated Tabs with Sliding Highlight and Crossfade.

More timelines and steppers or all free UI components. Start for free to have a full site built around it.

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap