Vertical Timeline
Original · freevertical timeline with dots
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"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 → 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
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 quoteSimilar components
Browse all →
Centered Timeline
Originalcentred alternating timeline
Icons Timeline
Originaltimeline with icon nodes

Cards Timeline
Originaltimeline with card entries

Horizontal Timeline
Originalhorizontal timeline

Gradient Timeline
Originaltimeline with a gradient line

Activity Timeline
Originalactivity feed timeline

Roadmap Timeline
Originalproduct roadmap timeline

Numbered Timeline
Originalnumbered milestone timeline

Compact Timeline
Originalcompact log timeline

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

