Scroll Timeline
Vertical timeline that draws the center line and springs in each milestone as it enters view
scrolltimelinelayoutrevealstepper
Install dependencies
$npm install framer-motionPreview
Source Code
"use client";
import { useRef, type ReactNode, type RefObject } from "react";
import {
motion,
useScroll,
useTransform,
useInView,
type MotionValue,
} from "framer-motion";
export interface TimelineItem {
date: string;
title: string;
description?: string;
icon?: ReactNode;
}
export interface ScrollTimelineProps {
items: TimelineItem[];
/** Line color. Default: 'rgba(139,92,246,0.4)' */
lineColor?: string;
/** Progress line color (drawn portion). Default: '#8b5cf6' */
progressColor?: string;
/** Alternate left/right layout. Default: true */
alternating?: boolean;
/** Optional scrollable ancestor to track scroll progress against. Defaults to window. */
container?: RefObject<HTMLElement | null>;
className?: string;
}
interface TimelineNodeProps {
item: TimelineItem;
index: number;
alternating: boolean;
progressColor: string;
container?: RefObject<HTMLElement | null>;
}
function TimelineNode({
item,
index,
alternating,
progressColor,
container,
}: TimelineNodeProps) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, {
once: true,
margin: "-20% 0px -20% 0px",
root: container,
});
// Alternating: even items on left, odd on right (md+). On mobile always right-of-line.
const isLeft = alternating && index % 2 === 0;
const contentVariants = {
hidden: {
opacity: 0,
x: isLeft ? -40 : 40,
},
visible: {
opacity: 1,
x: 0,
transition: {
duration: 0.5,
ease: [0.22, 1, 0.36, 1] as [number, number, number, number],
delay: 0.08,
},
},
};
const dateVariants = {
hidden: { opacity: 0, y: -12 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring" as const,
stiffness: 200,
damping: 24,
delay: 0.0,
},
},
};
const nodeVariants = {
hidden: { scale: 0 },
visible: {
scale: 1,
transition: {
type: "spring" as const,
stiffness: 300,
damping: 20,
delay: 0.0,
},
},
};
const content = (
<motion.div
className="py-6"
variants={contentVariants}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
>
<motion.p
className="mb-1 text-sm font-medium text-gray-500"
variants={dateVariants}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
>
{item.date}
</motion.p>
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
{item.description && (
<p className="mt-1 text-sm leading-relaxed text-gray-400">{item.description}</p>
)}
</motion.div>
);
const node = (
<motion.div
className="flex h-10 w-10 items-center justify-center rounded-full border-2 border-current bg-[#0a0a0f]"
style={{ color: progressColor }}
variants={nodeVariants}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
>
{item.icon ? (
<span className="h-4 w-4 text-current">{item.icon}</span>
) : (
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: progressColor }}
/>
)}
</motion.div>
);
return (
<div ref={ref} className="relative w-full">
{/* Two-column grid: equal columns flank the central line. Node sits absolutely on the line. */}
<div
className={[
"grid w-full items-center",
alternating ? "md:grid-cols-2" : "grid-cols-1",
"grid-cols-1",
].join(" ")}
>
{alternating && isLeft ? (
<>
{/* md+: content on left, aligned right against the line */}
<div className="hidden md:block pr-10 text-right">{content}</div>
{/* mobile: content on right of left-aligned line */}
<div className="md:hidden pl-12">{content}</div>
{/* md+: empty right column */}
<div className="hidden md:block" />
</>
) : alternating ? (
<>
{/* md+: empty left column */}
<div className="hidden md:block" />
{/* md+ and mobile: content on right */}
<div className="md:pl-10 pl-12 text-left">{content}</div>
</>
) : (
<div className="pl-12 text-left">{content}</div>
)}
</div>
{/* Node — absolutely positioned on the line (md+ center, mobile left) */}
<div
className="absolute top-1/2 z-10 -translate-y-1/2 md:left-1/2 md:-translate-x-1/2 left-4"
>
{node}
</div>
</div>
);
}
interface ProgressLineProps {
scrollYProgress: MotionValue<number>;
lineColor: string;
progressColor: string;
}
function ProgressLine({ scrollYProgress, lineColor, progressColor }: ProgressLineProps) {
const scaleY = useTransform(scrollYProgress, [0, 1], [0, 1]);
return (
<>
{/* Background muted line — left-aligned on mobile, centered on md+ */}
<div
className="pointer-events-none absolute inset-y-0 w-0.5 left-[calc(1rem+1.25rem-1px)] md:left-1/2 md:-translate-x-1/2"
style={{ backgroundColor: lineColor }}
/>
{/* Animated progress line */}
<motion.div
className="pointer-events-none absolute inset-y-0 w-0.5 origin-top left-[calc(1rem+1.25rem-1px)] md:left-1/2 md:-translate-x-1/2"
style={{
backgroundColor: progressColor,
scaleY,
transformOrigin: "top",
}}
/>
</>
);
}
export default function ScrollTimeline({
items,
lineColor = "rgba(139,92,246,0.4)",
progressColor = "#8b5cf6",
alternating = true,
container,
className = "",
}: ScrollTimelineProps) {
const containerRef = useRef<HTMLDivElement>(null);
// When an ancestor scroll container is provided, track its own scroll 0→1
// (progress fills as the user scrolls the container). Without a container,
// track the timeline's position within the window viewport.
const { scrollYProgress } = useScroll(
container
? { container }
: { target: containerRef, offset: ["start 80%", "end 20%"] }
);
return (
<div
ref={containerRef}
className={["relative w-full max-w-3xl mx-auto px-4", className].join(" ")}
>
<ProgressLine
scrollYProgress={scrollYProgress}
lineColor={lineColor}
progressColor={progressColor}
/>
<div className="relative flex flex-col">
{items.map((item, index) => (
<TimelineNode
key={index}
item={item}
index={index}
alternating={alternating}
progressColor={progressColor}
container={container}
/>
))}
</div>
</div>
);
}