Web InnoventixFreeCode

Scroll Reveal Timeline

Original · free

A vertical timeline whose connecting line fills and whose milestones fade in as they enter the viewport.

byWeb InnoventixReact + Tailwind
scrolltimelinereveal
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/scroll-reveal-timeline.json
scroll-reveal-timeline.tsx
"use client";

import { useRef, type ReactNode } from "react";
import {
  motion,
  useScroll,
  useSpring,
  useTransform,
  type Variants,
} from "motion/react";

type Milestone = {
  date: string;
  tag: string;
  title: string;
  body: string;
  icon: ReactNode;
};

const MILESTONES: Milestone[] = [
  {
    date: "Q1 2021",
    tag: "Origin",
    title: "Two laptops and a shared doc",
    body: "We shipped the first prototype from a rented co-working desk, onboarding three pilot teams who believed in the idea before there was a landing page.",
    icon: (
      <path d="M12 3v6m0 0 3-3m-3 3L9 6m-3 9h12l-1.5 6h-9L6 15Z" />
    ),
  },
  {
    date: "Q4 2021",
    tag: "Traction",
    title: "First 1,000 paying accounts",
    body: "Word of mouth outpaced our ad spend. We rewrote the sync engine over one long weekend and cut median load time from 2.4s to 380ms.",
    icon: (
      <path d="M3 17l6-6 4 4 8-8m0 0h-5m5 0v5" />
    ),
  },
  {
    date: "Q3 2022",
    tag: "Scale",
    title: "Global infrastructure, one region at a time",
    body: "Edge nodes across four continents brought p99 latency under 90ms for every customer. Uptime crossed 99.98% and stayed there.",
    icon: (
      <path d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 0c2.5 2.4 3.75 5.4 3.75 9S14.5 18.6 12 21m0-18C9.5 5.4 8.25 8.4 8.25 12S9.5 18.6 12 21M3.5 9h17M3.5 15h17" />
    ),
  },
  {
    date: "Q2 2023",
    tag: "Team",
    title: "Fifty people, still no meetings on Fridays",
    body: "We grew the team fivefold without diluting the craft. A written-first culture meant every new engineer shipped to production in their first week.",
    icon: (
      <path d="M16 19a4 4 0 0 0-8 0m10-4a3 3 0 0 0-3-3m-6 0a3 3 0 0 0-3 3m9-8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Zm5 1a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm-14 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z" />
    ),
  },
  {
    date: "Q1 2024",
    tag: "Today",
    title: "A platform, not just a product",
    body: "An open API and a marketplace of 200+ integrations turned early adopters into builders. The roadmap is now written as much by them as by us.",
    icon: (
      <path d="M12 2 4 6v6c0 5 3.5 8 8 10 4.5-2 8-5 8-10V6l-8-4Zm-2.5 9.5 2 2 4-4.5" />
    ),
  },
];

const cardVariants: Variants = {
  hidden: { opacity: 0, y: 48 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.65, ease: [0.22, 1, 0.36, 1] },
  },
};

const nodeVariants: Variants = {
  hidden: { scale: 0, opacity: 0 },
  visible: {
    scale: 1,
    opacity: 1,
    transition: { duration: 0.5, ease: [0.34, 1.56, 0.64, 1], delay: 0.1 },
  },
};

export default function ScrollRevealTimeline() {
  const trackRef = useRef<HTMLDivElement>(null);

  const { scrollYProgress } = useScroll({
    target: trackRef,
    offset: ["start 65%", "end 65%"],
  });

  const fill = useSpring(scrollYProgress, {
    stiffness: 120,
    damping: 28,
    restDelta: 0.001,
  });

  const glowOpacity = useTransform(fill, [0, 0.05, 0.95, 1], [0, 1, 1, 0.6]);

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-5 py-24 text-slate-900 sm:px-8 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes szh-pulse {
          0%, 100% { transform: scale(1); opacity: 0.55; }
          50% { transform: scale(1.9); opacity: 0; }
        }
        @keyframes szh-drift {
          0% { transform: translateY(0) translateX(0); }
          50% { transform: translateY(-18px) translateX(10px); }
          100% { transform: translateY(0) translateX(0); }
        }
        @media (prefers-reduced-motion: reduce) {
          .szh-pulse, .szh-drift { animation: none !important; }
        }
      `}</style>

      {/* Decorative ambient blobs */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0">
        <div className="szh-drift absolute -left-24 top-24 h-72 w-72 rounded-full bg-gradient-to-br from-indigo-300/40 to-violet-300/30 blur-3xl dark:from-indigo-600/20 dark:to-violet-700/20" />
        <div className="szh-drift absolute -right-20 bottom-32 h-80 w-80 rounded-full bg-gradient-to-br from-fuchsia-300/30 to-sky-300/30 blur-3xl dark:from-fuchsia-700/15 dark:to-sky-700/15" style={{ animationDelay: "1.5s" }} />
        <div
          className="absolute inset-0 opacity-[0.5] dark:opacity-[0.35]"
          style={{
            backgroundImage:
              "radial-gradient(circle at 1px 1px, rgba(99,102,241,0.15) 1px, transparent 0)",
            backgroundSize: "38px 38px",
            maskImage:
              "radial-gradient(ellipse 70% 60% at 50% 40%, black, transparent)",
            WebkitMaskImage:
              "radial-gradient(ellipse 70% 60% at 50% 40%, black, transparent)",
          }}
        />
      </div>

      <div className="relative mx-auto max-w-3xl">
        {/* Header */}
        <motion.div
          initial={{ opacity: 0, y: 24 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, amount: 0.6 }}
          transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
          className="mx-auto mb-20 max-w-xl text-center"
        >
          <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-white/70 px-4 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 backdrop-blur dark:border-indigo-500/30 dark:bg-white/5 dark:text-indigo-300">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Our journey
          </span>
          <h2 className="mt-5 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-600 bg-clip-text text-4xl font-bold tracking-tight text-transparent sm:text-5xl dark:from-white dark:via-slate-100 dark:to-slate-400">
            From a shared doc to a platform
          </h2>
          <p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Scroll to trace the milestones that shaped us. The line fills as you
            go; each moment arrives in its own time.
          </p>
        </motion.div>

        {/* Timeline track */}
        <div ref={trackRef} className="relative">
          {/* Rail — offset left on mobile, centered on desktop */}
          <div
            aria-hidden="true"
            className="absolute bottom-0 top-0 left-[19px] w-px sm:left-1/2 sm:-translate-x-1/2"
          >
            {/* Base rail */}
            <div className="absolute inset-0 w-px bg-slate-200 dark:bg-slate-800" />
            {/* Animated fill */}
            <motion.div
              style={{ scaleY: fill, transformOrigin: "top" }}
              className="absolute inset-0 w-px bg-gradient-to-b from-indigo-500 via-violet-500 to-fuchsia-500"
            />
            {/* Traveling glow at the fill head */}
            <motion.div
              style={{ scaleY: fill, transformOrigin: "top", opacity: glowOpacity }}
              className="absolute inset-0 w-px bg-gradient-to-b from-transparent via-transparent to-fuchsia-400 blur-[3px]"
            />
          </div>

          <ol className="relative space-y-14 sm:space-y-24">
            {MILESTONES.map((m, i) => {
              const alignRight = i % 2 === 1;
              return (
                <li key={m.date} className="relative">
                  <div
                    className={[
                      "relative flex items-start gap-6 sm:grid sm:grid-cols-[1fr_auto_1fr] sm:items-center sm:gap-0",
                    ].join(" ")}
                  >
                    {/* Node */}
                    <motion.div
                      variants={nodeVariants}
                      initial="hidden"
                      whileInView="visible"
                      viewport={{ once: true, amount: 0.8 }}
                      className="relative z-10 order-first flex h-10 w-10 shrink-0 items-center justify-center sm:order-none sm:col-start-2"
                    >
                      <span
                        aria-hidden="true"
                        className="szh-pulse absolute inset-0 rounded-full bg-violet-500/50"
                      />
                      <span className="relative flex h-10 w-10 items-center justify-center rounded-full border border-white/60 bg-gradient-to-br from-indigo-500 via-violet-500 to-fuchsia-500 text-white shadow-lg shadow-violet-500/30 ring-4 ring-slate-50 dark:border-white/20 dark:ring-slate-950">
                        <svg
                          viewBox="0 0 24 24"
                          className="h-5 w-5"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth={1.75}
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          aria-hidden="true"
                        >
                          {m.icon}
                        </svg>
                      </span>
                    </motion.div>

                    {/* Spacer for the empty column on desktop */}
                    <div
                      aria-hidden="true"
                      className={[
                        "hidden sm:block",
                        alignRight ? "sm:col-start-1" : "sm:col-start-3",
                      ].join(" ")}
                    />

                    {/* Card */}
                    <motion.div
                      variants={cardVariants}
                      initial="hidden"
                      whileInView="visible"
                      viewport={{ once: true, amount: 0.5 }}
                      className={[
                        "min-w-0 flex-1 sm:row-start-1",
                        alignRight
                          ? "sm:col-start-3 sm:pl-10 sm:text-left"
                          : "sm:col-start-1 sm:pr-10 sm:text-right",
                      ].join(" ")}
                    >
                      <article
                        className={[
                          "group relative overflow-hidden rounded-2xl border border-slate-200 bg-white/80 p-6 shadow-sm backdrop-blur-sm transition-shadow duration-300 hover:shadow-xl hover:shadow-violet-500/10 dark:border-white/10 dark:bg-white/[0.04]",
                        ].join(" ")}
                      >
                        {/* accent edge */}
                        <span
                          aria-hidden="true"
                          className={[
                            "absolute inset-y-0 w-1 bg-gradient-to-b from-indigo-500 via-violet-500 to-fuchsia-500",
                            alignRight ? "left-0" : "left-0 sm:left-auto sm:right-0",
                          ].join(" ")}
                        />
                        <div
                          className={[
                            "flex items-center gap-3",
                            alignRight ? "" : "sm:flex-row-reverse",
                          ].join(" ")}
                        >
                          <time className="text-sm font-bold tracking-tight text-violet-600 dark:text-violet-300">
                            {m.date}
                          </time>
                          <span className="rounded-full bg-indigo-50 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300">
                            {m.tag}
                          </span>
                        </div>
                        <h3 className="mt-3 text-lg font-semibold leading-snug text-slate-900 dark:text-white">
                          {m.title}
                        </h3>
                        <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                          {m.body}
                        </p>
                      </article>
                    </motion.div>
                  </div>
                </li>
              );
            })}
          </ol>
        </div>

        {/* Closing line */}
        <motion.p
          initial={{ opacity: 0, y: 20 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, amount: 0.8 }}
          transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
          className="mt-20 text-center text-sm font-medium text-slate-500 dark:text-slate-500"
        >
          And the next milestone is still being written.
        </motion.p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →