Web InnoventixFreeCode

Roadmap Timeline

Original · free

product roadmap timeline

byWeb InnoventixReact + Tailwind
tlroadmaptimelines
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-roadmap.json
tl-roadmap.tsx
"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&rsquo;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&rsquo;re waiting on? Roadmap priorities are shaped by
          customer feedback — tell us what would move the needle for your team.
        </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 →