Scroll Timeline

Vertical timeline that draws the center line and springs in each milestone as it enters view

scrolltimelinelayoutrevealstepper
Install dependencies
$npm install framer-motion
Preview

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>
  );
}