Web InnoventixFreeCode

Vertical Timeline

Original · free

vertical timeline with dots

byWeb InnoventixReact + Tailwind
tlverticaltimelines
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/tl-vertical.json
tl-vertical.tsx
"use client";

import { useId, useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Category = "product" | "growth" | "team" | "funding";

interface TimelineEvent {
  id: string;
  date: string;
  category: Category;
  title: string;
  summary: string;
  detail: string;
  metricValue: string;
  metricLabel: string;
}

interface CategoryStyle {
  label: string;
  dot: string;
  badge: string;
  chipActive: string;
}

const CATEGORY_STYLE: Record<Category, CategoryStyle> = {
  product: {
    label: "Product",
    dot: "border-indigo-500 bg-indigo-500",
    badge:
      "border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-400/25 dark:bg-indigo-400/10 dark:text-indigo-300",
    chipActive:
      "border-indigo-500 bg-indigo-500 text-white dark:border-indigo-400 dark:bg-indigo-400 dark:text-neutral-950",
  },
  growth: {
    label: "Growth",
    dot: "border-emerald-500 bg-emerald-500",
    badge:
      "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-400/25 dark:bg-emerald-400/10 dark:text-emerald-300",
    chipActive:
      "border-emerald-500 bg-emerald-500 text-white dark:border-emerald-400 dark:bg-emerald-400 dark:text-neutral-950",
  },
  team: {
    label: "Team",
    dot: "border-amber-500 bg-amber-500",
    badge:
      "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-300",
    chipActive:
      "border-amber-500 bg-amber-500 text-white dark:border-amber-400 dark:bg-amber-400 dark:text-neutral-950",
  },
  funding: {
    label: "Funding",
    dot: "border-violet-500 bg-violet-500",
    badge:
      "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-400/25 dark:bg-violet-400/10 dark:text-violet-300",
    chipActive:
      "border-violet-500 bg-violet-500 text-white dark:border-violet-400 dark:bg-violet-400 dark:text-neutral-950",
  },
};

const EVENTS: TimelineEvent[] = [
  {
    id: "e1",
    date: "Mar 2019",
    category: "team",
    title: "Meridian begins",
    summary: "Two engineers leave their infra jobs to fix flaky observability.",
    detail:
      "Ana Rossi and Dev Patel rented a single desk above a bakery in Porto and shipped the first query engine prototype in six weeks — a columnar store that could scan a full week of application logs in under 300 milliseconds.",
    metricValue: "6 wks",
    metricLabel: "First prototype",
  },
  {
    id: "e2",
    date: "Nov 2019",
    category: "product",
    title: "Open-source core released",
    summary: "The storage engine goes public under Apache-2.0.",
    detail:
      "We open-sourced the columnar core and stayed on the front page of Hacker News for two days. 4,200 GitHub stars in the first week turned a weekend side project into a real roadmap and our first ten outside contributors.",
    metricValue: "4,200",
    metricLabel: "Stars in week one",
  },
  {
    id: "e3",
    date: "Jun 2020",
    category: "funding",
    title: "Seed round closed",
    summary: "$2.4M to turn the engine into a product people can buy.",
    detail:
      "Backed by Cormorant Ventures and eleven angels who had all run their own on-call rotations. The pitch fit on one slide: engineers should not have to pay per gigabyte just to understand the systems they already operate.",
    metricValue: "$2.4M",
    metricLabel: "Seed raised",
  },
  {
    id: "e4",
    date: "Feb 2021",
    category: "product",
    title: "Hosted beta launches",
    summary: "Managed Meridian Cloud opens to 500 waitlisted teams.",
    detail:
      "The hosted product added live tail, saved queries, and alert rules. Median onboarding dropped from two days to nineteen minutes once we rewrote the ingestion agent in Rust and shipped a one-line install script.",
    metricValue: "19 min",
    metricLabel: "Median setup time",
  },
  {
    id: "e5",
    date: "Sep 2022",
    category: "growth",
    title: "10,000 teams onboard",
    summary: "Adoption crosses five figures across 60 countries.",
    detail:
      "A single conference talk on taming cardinality explosions sent 3,000 signups in one weekend. We spent the following quarter on nothing but reliability work and cut p99 query latency by 70 percent under real load.",
    metricValue: "70%",
    metricLabel: "Faster p99 queries",
  },
  {
    id: "e6",
    date: "Apr 2023",
    category: "funding",
    title: "Series A",
    summary: "$18M led by Northwind to build the enterprise tier.",
    detail:
      "The round funded SOC 2 Type II certification, role-based access control, and a fully independent European data region. We grew from 14 to 39 people over the year without ever opening a physical office.",
    metricValue: "$18M",
    metricLabel: "Series A raised",
  },
  {
    id: "e7",
    date: "Jan 2025",
    category: "product",
    title: "Enterprise & self-host GA",
    summary: "Air-gapped deployments ship to regulated industries.",
    detail:
      "Banks and hospitals can now run the complete platform inside their own network with zero outbound calls. Across every customer, Meridian now ingests four petabytes of telemetry every single day.",
    metricValue: "4 PB",
    metricLabel: "Ingested per day",
  },
];

const FILTERS: { key: Category | "all"; label: string }[] = [
  { key: "all", label: "All" },
  { key: "product", label: "Product" },
  { key: "growth", label: "Growth" },
  { key: "team", label: "Team" },
  { key: "funding", label: "Funding" },
];

const FOCUS_RING =
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-neutral-950";

export default function VerticalTimeline() {
  const reduce = useReducedMotion();
  const uid = useId();
  const [filter, setFilter] = useState<Category | "all">("all");
  const [open, setOpen] = useState<Set<string>>(() => new Set(["e1"]));

  const visible = useMemo(
    () => (filter === "all" ? EVENTS : EVENTS.filter((e) => e.category === filter)),
    [filter],
  );

  const allOpen = visible.length > 0 && visible.every((e) => open.has(e.id));

  const toggle = (id: string) => {
    setOpen((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  const toggleAll = () => {
    setOpen((prev) => {
      if (allOpen) {
        const next = new Set(prev);
        visible.forEach((e) => next.delete(e.id));
        return next;
      }
      const next = new Set(prev);
      visible.forEach((e) => next.add(e.id));
      return next;
    });
  };

  return (
    <section className="relative w-full bg-white px-4 py-20 text-neutral-900 sm:px-6 lg:px-8 dark:bg-neutral-950 dark:text-neutral-100">
      <style>{`
        @keyframes tlv-pulse {
          0%, 100% { opacity: 0.5; transform: scale(1); }
          50% { opacity: 0; transform: scale(2.1); }
        }
        @keyframes tlv-rise {
          from { opacity: 0; transform: translateY(6px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .tlv-pulse-ring { animation: tlv-pulse 2.4s ease-in-out infinite; }
        .tlv-rise { animation: tlv-rise 0.4s ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .tlv-pulse-ring, .tlv-rise { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-3xl">
        <header className="mb-10">
          <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 dark:border-indigo-400/25 dark:bg-indigo-400/10 dark:text-indigo-300">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" aria-hidden="true" />
            2019 &rarr; 2025
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
            How Meridian was built
          </h2>
          <p className="mt-3 max-w-xl text-base leading-relaxed text-neutral-600 dark:text-neutral-400">
            Six years, two founders, and a stubborn belief that engineers should
            never pay per gigabyte to understand their own systems. Filter the
            milestones, or open one to read the full story.
          </p>
        </header>

        <div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
          <div
            role="group"
            aria-label="Filter milestones by category"
            className="flex flex-wrap gap-2"
          >
            {FILTERS.map((f) => {
              const active = filter === f.key;
              const activeClass =
                f.key === "all"
                  ? "border-neutral-900 bg-neutral-900 text-white dark:border-neutral-100 dark:bg-neutral-100 dark:text-neutral-950"
                  : CATEGORY_STYLE[f.key].chipActive;
              return (
                <button
                  key={f.key}
                  type="button"
                  aria-pressed={active}
                  onClick={() => setFilter(f.key)}
                  className={`rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors ${FOCUS_RING} ${
                    active
                      ? activeClass
                      : "border-neutral-200 bg-white text-neutral-600 hover:border-neutral-300 hover:text-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:border-neutral-700 dark:hover:text-neutral-100"
                  }`}
                >
                  {f.label}
                </button>
              );
            })}
          </div>

          <button
            type="button"
            onClick={toggleAll}
            disabled={visible.length === 0}
            className={`shrink-0 rounded-full border border-neutral-200 bg-white px-3.5 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:border-neutral-300 hover:text-neutral-900 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:border-neutral-700 dark:hover:text-neutral-100 ${FOCUS_RING}`}
          >
            {allOpen ? "Collapse all" : "Expand all"}
          </button>
        </div>

        <p className="sr-only" role="status" aria-live="polite">
          Showing {visible.length} of {EVENTS.length} milestones.
        </p>

        {visible.length === 0 ? (
          <p className="rounded-2xl border border-dashed border-neutral-300 px-6 py-12 text-center text-sm text-neutral-500 dark:border-neutral-700 dark:text-neutral-400">
            No milestones in this category yet.
          </p>
        ) : (
          <ol className="relative">
            <div
              className="absolute left-[22px] top-2 bottom-2 w-0.5 -translate-x-1/2 rounded-full bg-gradient-to-b from-neutral-200 via-neutral-200 to-transparent dark:from-neutral-800 dark:via-neutral-800"
              aria-hidden="true"
            />
            <motion.div
              className="absolute left-[22px] top-2 w-0.5 -translate-x-1/2 origin-top rounded-full bg-gradient-to-b from-indigo-500 via-violet-500 to-emerald-500"
              style={{ height: "calc(100% - 16px)" }}
              initial={reduce ? false : { scaleY: 0 }}
              animate={{ scaleY: 1 }}
              transition={{ duration: 1.1, ease: [0.22, 1, 0.36, 1] }}
              aria-hidden="true"
            />

            {visible.map((event) => {
              const style = CATEGORY_STYLE[event.category];
              const isOpen = open.has(event.id);
              const btnId = `${uid}-btn-${event.id}`;
              const panelId = `${uid}-panel-${event.id}`;
              return (
                <li key={event.id} className="relative pb-9 pl-14 last:pb-0">
                  <span className="absolute left-[22px] top-1.5 flex h-4 w-4 -translate-x-1/2 items-center justify-center">
                    {isOpen && (
                      <span
                        className={`tlv-pulse-ring absolute inset-0 rounded-full ${style.dot}`}
                        aria-hidden="true"
                      />
                    )}
                    <span
                      className={`relative h-4 w-4 rounded-full border-2 transition-colors ${
                        isOpen
                          ? style.dot
                          : "border-neutral-300 bg-white dark:border-neutral-600 dark:bg-neutral-950"
                      }`}
                      aria-hidden="true"
                    />
                  </span>

                  <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
                    <time className="text-sm font-medium tabular-nums text-neutral-500 dark:text-neutral-400">
                      {event.date}
                    </time>
                    <span
                      className={`rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide ${style.badge}`}
                    >
                      {style.label}
                    </span>
                  </div>

                  <h3 className="mt-1">
                    <button
                      type="button"
                      id={btnId}
                      aria-expanded={isOpen}
                      aria-controls={panelId}
                      onClick={() => toggle(event.id)}
                      className={`group -mx-1 flex w-full items-start gap-2 rounded-lg px-1 py-0.5 text-left transition-colors ${FOCUS_RING}`}
                    >
                      <span className="flex-1 text-lg font-semibold leading-snug text-neutral-900 group-hover:text-indigo-600 dark:text-neutral-50 dark:group-hover:text-indigo-400">
                        {event.title}
                      </span>
                      <svg
                        viewBox="0 0 20 20"
                        fill="none"
                        className={`mt-1.5 h-4 w-4 shrink-0 text-neutral-400 transition-transform duration-300 group-hover:text-neutral-600 dark:group-hover:text-neutral-200 ${
                          isOpen ? "rotate-180" : ""
                        }`}
                        aria-hidden="true"
                      >
                        <path
                          d="M5 7.5 10 12.5 15 7.5"
                          stroke="currentColor"
                          strokeWidth="1.75"
                          strokeLinecap="round"
                          strokeLinejoin="round"
                        />
                      </svg>
                    </button>
                  </h3>

                  <p className="mt-1 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
                    {event.summary}
                  </p>

                  <AnimatePresence initial={false}>
                    {isOpen && (
                      <motion.div
                        key="panel"
                        id={panelId}
                        role="region"
                        aria-labelledby={btnId}
                        initial={reduce ? false : { height: 0, opacity: 0 }}
                        animate={{ height: "auto", opacity: 1 }}
                        exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
                        transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
                        className="overflow-hidden"
                      >
                        <div className="tlv-rise mt-3 rounded-2xl border border-neutral-200 bg-neutral-50/80 p-4 dark:border-neutral-800 dark:bg-neutral-900/60">
                          <p className="text-sm leading-relaxed text-neutral-700 dark:text-neutral-300">
                            {event.detail}
                          </p>
                          <div className="mt-4 flex items-baseline gap-2 border-t border-neutral-200 pt-3 dark:border-neutral-800">
                            <span className="text-2xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-50">
                              {event.metricValue}
                            </span>
                            <span className="text-xs font-medium uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
                              {event.metricLabel}
                            </span>
                          </div>
                        </div>
                      </motion.div>
                    )}
                  </AnimatePresence>
                </li>
              );
            })}
          </ol>
        )}
      </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 →