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 1
Connected
The first integration goes live behind a feature flag.
Week 2
Observed
Real traffic starts flowing through, read-only.
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 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.
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.