Roadmap Timeline
Original · freeproduct roadmap timeline
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-roadmap.json"use client";
import { useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Status = "shipped" | "active" | "planned" | "exploring";
type Filter = Status | "all";
interface Milestone {
id: string;
quarter: string;
title: string;
status: Status;
category: string;
summary: string;
highlights: string[];
eta: string;
}
const MILESTONES: Milestone[] = [
{
id: "otel",
quarter: "Q4 2025",
title: "OpenTelemetry-native ingest",
status: "shipped",
category: "Ingestion",
summary:
"Drop-in OTLP endpoints for traces, metrics, and logs — no proprietary agent to install.",
highlights: [
"gRPC and HTTP OTLP endpoints with automatic gzip compression",
"Backwards-compatible with existing Prometheus scrape configs",
"p99 ingest latency held under 180ms at 2M spans per minute",
],
eta: "Shipped Nov 2025",
},
{
id: "sso",
quarter: "Q1 2026",
title: "SAML SSO and SCIM provisioning",
status: "shipped",
category: "Security",
summary:
"Enterprise identity done properly: single sign-on plus automated user lifecycle.",
highlights: [
"SAML 2.0 verified with Okta, Entra ID, and Google Workspace",
"SCIM 2.0 for just-in-time provisioning and instant deprovisioning",
"Per-team role mapping driven by IdP group claims",
],
eta: "Shipped Feb 2026",
},
{
id: "anomaly",
quarter: "Q2 2026",
title: "Adaptive anomaly detection v2",
status: "active",
category: "Analytics",
summary:
"Seasonality-aware baselines that stop paging you at 3am for the nightly batch job.",
highlights: [
"Weekly and daily seasonality decomposition per metric",
"Confidence bands tuned by your historical alert feedback",
"In preview with 40 design-partner teams right now",
],
eta: "Rolling out now",
},
{
id: "cost",
quarter: "Q2 2026",
title: "Cost attribution dashboard",
status: "active",
category: "Cost",
summary:
"See exactly which services, teams, and query patterns drive your observability bill.",
highlights: [
"Per-label cardinality breakdown with a 14-day trend line",
"Budget alerts that fire before overage, not after",
"Export to CSV or pull the same numbers from the billing API",
],
eta: "Beta in Jul 2026",
},
{
id: "logmetrics",
quarter: "Q3 2026",
title: "Log-based metrics",
status: "planned",
category: "Analytics",
summary:
"Turn any structured log field into a first-class metric without re-instrumenting code.",
highlights: [
"Define metrics from a log query with live preview as you type",
"Backfill up to 30 days of history at creation time",
"Shares alerting rules and dashboards with native metrics",
],
eta: "Targeting Aug 2026",
},
{
id: "terraform",
quarter: "Q3 2026",
title: "Terraform provider — general availability",
status: "planned",
category: "Platform",
summary:
"Manage dashboards, monitors, and SLOs as code with a fully supported provider.",
highlights: [
"Import existing resources with a single command",
"Drift detection in CI straight from plan output",
"Versioned modules for the most common monitor patterns",
],
eta: "Targeting Sep 2026",
},
{
id: "copilot",
quarter: "Q4 2026",
title: "On-call incident copilot",
status: "exploring",
category: "AI",
summary:
"An assistant that correlates signals and drafts a first-pass summary while you page in.",
highlights: [
"Suggests likely root-cause services from trace topology",
"Drafts a timeline from deploys, alerts, and log spikes",
"Human-in-the-loop by design — it never resolves an incident on its own",
],
eta: "Research phase",
},
{
id: "residency",
quarter: "Q4 2026",
title: "Regional data residency",
status: "exploring",
category: "Compliance",
summary:
"Keep telemetry in-region for EU and APAC workloads to meet residency requirements.",
highlights: [
"Frankfurt and Singapore storage regions",
"Region pinning configurable per project",
"Scoping SOC 2 and ISO 27001 coverage for the new regions",
],
eta: "Scoping",
},
];
const STATUS_META: Record<
Status,
{ label: string; badge: string; dot: string; glow: string }
> = {
shipped: {
label: "Shipped",
badge:
"bg-emerald-500/10 text-emerald-700 ring-1 ring-emerald-500/25 dark:text-emerald-300 dark:ring-emerald-400/25",
dot: "bg-emerald-500",
glow: "ring-emerald-500/40",
},
active: {
label: "In progress",
badge:
"bg-indigo-500/10 text-indigo-700 ring-1 ring-indigo-500/25 dark:text-indigo-300 dark:ring-indigo-400/25",
dot: "bg-indigo-500",
glow: "ring-indigo-500/40",
},
planned: {
label: "Planned",
badge:
"bg-sky-500/10 text-sky-700 ring-1 ring-sky-500/25 dark:text-sky-300 dark:ring-sky-400/25",
dot: "bg-sky-500",
glow: "ring-sky-500/40",
},
exploring: {
label: "Exploring",
badge:
"bg-amber-500/10 text-amber-700 ring-1 ring-amber-500/25 dark:text-amber-300 dark:ring-amber-400/25",
dot: "bg-amber-500",
glow: "ring-amber-500/40",
},
};
const FILTERS: readonly { id: Filter; label: string }[] = [
{ id: "all", label: "All" },
{ id: "shipped", label: "Shipped" },
{ id: "active", label: "In progress" },
{ id: "planned", label: "Planned" },
{ id: "exploring", label: "Exploring" },
];
function StatusIcon({ status }: { status: Status }) {
const common = "h-3.5 w-3.5";
if (status === "shipped") {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" className={common} aria-hidden="true">
<path d="m20 6-11 11-5-5" />
</svg>
);
}
if (status === "active") {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" className={common} aria-hidden="true">
<path d="M12 3a9 9 0 1 0 9 9" />
<path d="M12 7v5l3 2" />
</svg>
);
}
if (status === "planned") {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" className={common} aria-hidden="true">
<rect x="3" y="4.5" width="18" height="16" rx="2" />
<path d="M3 9h18M8 2.5v4M16 2.5v4" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" className={common} aria-hidden="true">
<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1" />
</svg>
);
}
const focusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950";
export default function RoadmapTimeline() {
const uid = useId();
const reduce = useReducedMotion();
const [filter, setFilter] = useState<Filter>("all");
const [expandedId, setExpandedId] = useState<string | null>("anomaly");
const visible =
filter === "all"
? MILESTONES
: MILESTONES.filter((m) => m.status === filter);
const shippedCount = MILESTONES.filter((m) => m.status === "shipped").length;
const toggle = (id: string) =>
setExpandedId((prev) => (prev === id ? null : id));
return (
<section className="relative w-full overflow-hidden bg-slate-50 py-20 text-slate-900 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes tlrm-ping {
0% { transform: scale(1); opacity: 0.55; }
70%, 100% { transform: scale(2.1); opacity: 0; }
}
@keyframes tlrm-rise {
from { transform: translateY(6px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.tlrm-ping { animation: tlrm-ping 2.4s cubic-bezier(0.4, 0, 0.2, 1) infinite; }
@media (prefers-reduced-motion: reduce) {
.tlrm-ping { animation: none !important; }
}
`}</style>
{/* decorative background */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -left-24 top-10 h-72 w-72 rounded-full bg-indigo-500/10 blur-3xl dark:bg-indigo-500/15" />
<div className="absolute -right-20 bottom-0 h-80 w-80 rounded-full bg-violet-500/10 blur-3xl dark:bg-violet-500/15" />
</div>
<div className="relative mx-auto max-w-5xl px-5 sm:px-8">
{/* header */}
<header className="max-w-2xl">
<span className="inline-flex items-center gap-2 rounded-full bg-white px-3 py-1 text-xs font-semibold uppercase tracking-wider text-slate-600 ring-1 ring-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:ring-slate-800">
<span className="relative flex h-2 w-2">
<span className="tlrm-ping absolute inline-flex h-full w-full rounded-full bg-emerald-500" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
</span>
Public roadmap
</span>
<h2 className="mt-5 text-3xl font-bold tracking-tight sm:text-4xl">
What we’re building at Signalpost
</h2>
<p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-400">
An honest look at where our observability platform is headed. Dates are
targets, not promises — priorities shift as we learn from you.
</p>
</header>
{/* stats */}
<dl className="mt-8 flex flex-wrap gap-x-8 gap-y-4">
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
Delivered
</dt>
<dd className="mt-1 text-2xl font-semibold tabular-nums">
{shippedCount}
<span className="text-slate-400 dark:text-slate-500">
{" "}
/ {MILESTONES.length}
</span>
</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
Current focus
</dt>
<dd className="mt-1 text-2xl font-semibold">Q2 2026</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
Last updated
</dt>
<dd className="mt-1 text-2xl font-semibold">Jul 2026</dd>
</div>
</dl>
{/* filters */}
<div className="mt-10 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div
role="group"
aria-label="Filter roadmap by status"
className="flex flex-wrap gap-2"
>
{FILTERS.map((f) => {
const isActive = filter === f.id;
const count =
f.id === "all"
? MILESTONES.length
: MILESTONES.filter((m) => m.status === f.id).length;
return (
<button
key={f.id}
type="button"
aria-pressed={isActive}
onClick={() => setFilter(f.id)}
className={`inline-flex items-center gap-2 rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors ${focusRing} ${
isActive
? "bg-slate-900 text-white dark:bg-white dark:text-slate-900"
: "bg-white text-slate-600 ring-1 ring-slate-200 hover:bg-slate-100 hover:text-slate-900 dark:bg-slate-900 dark:text-slate-300 dark:ring-slate-800 dark:hover:bg-slate-800 dark:hover:text-white"
}`}
>
{f.label}
<span
className={`rounded-full px-1.5 py-0.5 text-[0.68rem] font-semibold tabular-nums ${
isActive
? "bg-white/20 dark:bg-slate-900/15"
: "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400"
}`}
>
{count}
</span>
</button>
);
})}
</div>
<p aria-live="polite" className="text-sm text-slate-500 dark:text-slate-400">
Showing {visible.length} of {MILESTONES.length}
</p>
</div>
{/* timeline */}
<div className="relative mt-10">
<span
aria-hidden="true"
className="pointer-events-none absolute left-5 top-6 bottom-6 w-px bg-gradient-to-b from-slate-200 via-slate-200 to-transparent dark:from-slate-800 dark:via-slate-800"
/>
<motion.ul key={filter} className="relative space-y-1">
{visible.map((m, i) => {
const meta = STATUS_META[m.status];
const isOpen = expandedId === m.id;
const btnId = `${uid}-btn-${m.id}`;
const panelId = `${uid}-panel-${m.id}`;
return (
<motion.li
key={m.id}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reduce ? 0 : 0.4,
delay: reduce ? 0 : i * 0.05,
ease: [0.22, 1, 0.36, 1],
}}
className="relative grid grid-cols-[2.5rem_minmax(0,1fr)] gap-x-3 sm:gap-x-5"
>
{/* node */}
<div className="relative flex justify-center pt-5">
<span className="relative z-10 grid h-9 w-9 place-items-center rounded-full border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<span className={`h-2.5 w-2.5 rounded-full ${meta.dot}`} />
{m.status === "active" ? (
<span
aria-hidden="true"
className={`tlrm-ping absolute inset-0 rounded-full ring-2 ${meta.glow}`}
/>
) : null}
</span>
</div>
{/* card */}
<div className="pb-5 sm:pb-6">
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm transition-shadow hover:shadow-md dark:border-slate-800 dark:bg-slate-900/70">
<h3 className="m-0">
<button
id={btnId}
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => toggle(m.id)}
className={`flex w-full items-start gap-4 p-5 text-left ${focusRing} focus-visible:rounded-2xl`}
>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
{m.quarter}
</span>
<span className="h-1 w-1 rounded-full bg-slate-300 dark:bg-slate-700" />
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
{m.category}
</span>
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.7rem] font-semibold ${meta.badge}`}
>
<StatusIcon status={m.status} />
{meta.label}
</span>
</span>
<span className="mt-2 block text-lg font-semibold leading-snug text-slate-900 dark:text-white">
{m.title}
</span>
<span className="mt-1.5 block text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{m.summary}
</span>
</span>
<span
aria-hidden="true"
className={`mt-1 shrink-0 text-slate-400 transition-transform duration-200 motion-reduce:transition-none dark:text-slate-500 ${
isOpen ? "rotate-180" : ""
}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<path d="m6 9 6 6 6-6" />
</svg>
</span>
</button>
</h3>
<AnimatePresence initial={false}>
{isOpen ? (
<motion.div
key="panel"
id={panelId}
role="region"
aria-labelledby={btnId}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.28, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="border-t border-slate-100 px-5 pb-5 pt-4 dark:border-slate-800">
<ul className="space-y-2.5">
{m.highlights.map((h) => (
<li key={h} className="flex gap-3 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
<span className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${meta.dot}`} aria-hidden="true" />
<span>{h}</span>
</li>
))}
</ul>
<p className="mt-4 inline-flex items-center gap-1.5 rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="h-3.5 w-3.5" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
{m.eta}
</p>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
</motion.li>
);
})}
</motion.ul>
</div>
<p className="mt-10 border-t border-slate-200 pt-6 text-sm text-slate-500 dark:border-slate-800 dark:text-slate-400">
Have a feature you’re waiting on? Roadmap priorities are shaped by
customer feedback — tell us what would move the needle for your team.
</p>
</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 →
Vertical Timeline
Originalvertical timeline with dots

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

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.

