Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 03:13:33Get Lifetime →
loopkit
freeNew149 lines · framer-motion

Scroll Timeline Line

The line only moves one way. A full-width, pinned scroll section with a centre line that draws itself downward as the section scrolls, lighting up milestone dots in turn, with entries alternating left and right of the line.

Use it for
A rollout plan, changelog highlight reel or case-study timeline that benefits from a literal sense of forward progress.
Themes
Light + dark
License
Commercial use
Live

A month, start to finish

The line only moves one way

Week 1

Connected

The first integration goes live behind a feature flag.

Week 2

Observed

Real traffic starts flowing through, read-only.

Week 3

Trusted

Low-risk actions get a yes without waiting on a person.

Week 4

Default

It's just how the team works now, not a pilot anymore.

tsx
"use client";

import * as React from "react";
import { motion, useMotionValue, useReducedMotion, useScroll, useTransform, type MotionValue } from "framer-motion";
import { cn } from "@/lib/utils";

function useReducedMotionSafe() {
  const prefersReduced = useReducedMotion();
  const [mounted, setMounted] = React.useState(false);
  React.useEffect(() => setMounted(true), []);
  return mounted ? !!prefersReduced : false;
}

/**
 * Full-width scroll section: a centre line draws itself downward as the
 * section scrolls, lighting up milestone dots in turn, with entries
 * alternating left and right of the line.
 */

export interface TimelineEntry {
  tag: string;
  title: string;
  body: string;
}

export interface ScrollTimelineLineProps {
  eyebrow?: string;
  title?: string;
  entries?: TimelineEntry[];
  progress?: number;
  autoplay?: boolean;
  className?: string;
}

const DEFAULT_ENTRIES: TimelineEntry[] = [
  { tag: "Week 1", title: "Connected", body: "The first integration goes live behind a feature flag." },
  { tag: "Week 2", title: "Observed", body: "Real traffic starts flowing through, read-only." },
  { tag: "Week 3", title: "Trusted", body: "Low-risk actions get a yes without waiting on a person." },
  { tag: "Week 4", title: "Default", body: "It's just how the team works now, not a pilot anymore." },
];

function useSectionProgress(ref: React.RefObject<HTMLElement | null>, progressProp: number | undefined, autoplay: boolean) {
  const reduce = useReducedMotionSafe();
  const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] });
  const fixed = progressProp != null;
  const tall = !reduce && !fixed && !autoplay;
  const mv = useMotionValue(reduce ? 1 : progressProp ?? 0);

  React.useEffect(() => {
    if (reduce) mv.set(1);
    else if (fixed) mv.set(progressProp as number);
  }, [reduce, fixed, progressProp, mv]);

  React.useEffect(() => {
    if (reduce || fixed || !autoplay) return;
    const dur = 6400;
    const t0 = performance.now();
    let raf = 0;
    let visible = true;
    const el = ref.current;
    const io = el ? new IntersectionObserver(([e]) => (visible = e.isIntersecting)) : null;
    if (io && el) io.observe(el);
    const tick = (t: number) => {
      raf = requestAnimationFrame(tick);
      if (!visible || document.hidden) return;
      const elapsed = (t - t0) % (dur * 2);
      mv.set(elapsed < dur ? elapsed / dur : 2 - elapsed / dur);
    };
    raf = requestAnimationFrame(tick);
    return () => {
      cancelAnimationFrame(raf);
      io?.disconnect();
    };
  }, [reduce, fixed, autoplay, mv, ref]);

  React.useEffect(() => {
    if (reduce || fixed || autoplay) return;
    return scrollYProgress.on("change", (v) => mv.set(v));
  }, [reduce, fixed, autoplay, scrollYProgress, mv]);

  return { p: mv, reduce: !!reduce, tall };
}

function Milestone({ entry, index, total, p, reduce }: { entry: TimelineEntry; index: number; total: number; p: MotionValue<number>; reduce: boolean }) {
  const start = index / total;
  const lit = useTransform(p, [start, start + 0.08], [0, 1], { clamp: true });
  const opacity = useTransform(p, [start, start + 0.1], [0.35, 1], { clamp: true });
  const dotOpacity = useTransform(lit, (l) => 0.4 + l * 0.6);
  const left = index % 2 === 0;

  return (
    <div className="relative grid grid-cols-[1fr_auto_1fr] items-center gap-4 py-3 sm:py-4">
      <motion.div style={{ opacity: reduce ? 1 : opacity }} className={cn("text-right", !left && "invisible")}>
        <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">{entry.tag}</span>
        <h3 className="font-display text-lg font-bold tracking-tight">{entry.title}</h3>
        <p className="text-xs leading-relaxed text-muted-foreground">{entry.body}</p>
      </motion.div>
      <motion.span
        style={{ backgroundColor: reduce ? "var(--signal)" : undefined, opacity: reduce ? 1 : dotOpacity }}
        className="relative z-10 size-3.5 shrink-0 rounded-full bg-signal ring-4 ring-background"
      />
      <motion.div style={{ opacity: reduce ? 1 : opacity }} className={cn("text-left", left && "invisible")}>
        <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">{entry.tag}</span>
        <h3 className="font-display text-lg font-bold tracking-tight">{entry.title}</h3>
        <p className="text-xs leading-relaxed text-muted-foreground">{entry.body}</p>
      </motion.div>
    </div>
  );
}

export function ScrollTimelineLine({
  eyebrow = "A month, start to finish",
  title = "The line only moves one way",
  entries = DEFAULT_ENTRIES,
  progress,
  autoplay = true,
  className,
}: ScrollTimelineLineProps) {
  const ref = React.useRef<HTMLElement>(null);
  const { p, reduce, tall } = useSectionProgress(ref, progress, autoplay);
  const lineScale = useTransform(p, [0, 1], [0, 1]);

  return (
    <section ref={ref} className={cn("relative bg-background", tall ? "h-[300vh]" : "py-20 sm:py-28", className)} aria-label={title}>
      <div className={cn("overflow-hidden", tall && "sticky top-0 h-dvh")}>
        <div className="mx-auto flex h-full w-full max-w-2xl flex-col justify-center gap-6 px-5 py-10 sm:px-8">
          <div className="text-center">
            <p className="font-mono text-[11px] uppercase tracking-[0.22em] text-muted-foreground">{eyebrow}</p>
            <h2 className="mt-3 font-display text-[clamp(1.7rem,3.8vw,2.6rem)] font-extrabold leading-[1.05] tracking-[-0.03em]">{title}</h2>
          </div>
          <div className="relative">
            <span className="absolute left-1/2 top-0 h-full w-[2px] -translate-x-1/2 bg-border" aria-hidden />
            <motion.span
              aria-hidden
              style={{ scaleY: reduce ? 1 : lineScale }}
              className="absolute left-1/2 top-0 h-full w-[2px] origin-top -translate-x-1/2 bg-signal"
            />
            {entries.map((entry, i) => (
              <Milestone key={entry.tag} entry={entry} index={i} total={entries.length} p={p} reduce={!!reduce} />
            ))}
          </div>
        </div>
      </div>
    </section>
  );
}

export default ScrollTimelineLine;

How it behaves

  • →The centre line's fill height and each milestone dot's brightness are both slices of one shared, section-scoped scroll progress value.
  • →Each entry's text fades from dim to full opacity as the line reaches its dot, independent of the others.
  • →At rest, whichever milestones fall before the current progress are lit and legible, and the rest are dimmed but still present — a valid partial state.
  • →autoplay sweeps progress from empty to full and back on a timer when there's no scroll to drive it.
  • →prefers-reduced-motion fills the line completely and shows every entry at full opacity immediately.

Accessibility

  • ✓Entries stay in a single reading order in the DOM; the left/right alternation is purely visual, not tab order.
  • ✓Dimmed (not-yet-reached) entries remain in the accessibility tree at reduced opacity, not hidden or removed.
  • ✓Honours prefers-reduced-motion by skipping the line-fill animation.
  • ✓Honours prefers-reduced-motion.