Web InnoventixFreeCode

Gradient Timeline

Original · free

timeline with a gradient line

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

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

type Status = "shipped" | "progress" | "planned";

interface Milestone {
  id: string;
  date: string;
  version: string;
  title: string;
  summary: string;
  status: Status;
  points: string[];
}

const MILESTONES: Milestone[] = [
  {
    id: "v1",
    date: "Feb 2024",
    version: "v1.0",
    title: "Meridian goes public",
    summary: "The first stable release: hosted event pipelines with a five-minute onboarding.",
    status: "shipped",
    points: [
      "Managed ingestion for Postgres, Segment, and raw HTTP events",
      "SOC 2 Type II controls in place from day one",
      "Sub-second p95 query latency on datasets up to 50M rows",
    ],
  },
  {
    id: "v14",
    date: "May 2024",
    version: "v1.4",
    title: "Real-time streaming ingest",
    summary: "Dropped end-to-end latency from minutes to under two seconds with a Kafka-backed path.",
    status: "shipped",
    points: [
      "Exactly-once delivery guarantees across replicas",
      "Backpressure handling that survives 10x traffic spikes",
      "Live tail view for debugging events as they land",
    ],
  },
  {
    id: "v2",
    date: "Sep 2024",
    version: "v2.0",
    title: "Self-serve dashboards",
    summary: "Non-engineers can now build and share boards without writing a line of SQL.",
    status: "shipped",
    points: [
      "Drag-and-drop chart builder with 14 visualization types",
      "Row-level permissions scoped to workspace roles",
      "Scheduled exports to Slack, email, and Google Sheets",
    ],
  },
  {
    id: "v26",
    date: "Jan 2026",
    version: "v2.6",
    title: "Semantic query layer",
    summary: "A shared metrics catalog so every team reads the same definition of 'active user'.",
    status: "progress",
    points: [
      "Version-controlled metric definitions in plain YAML",
      "Automatic lineage from raw table to final chart",
      "Currently in private beta with 40 design partners",
    ],
  },
  {
    id: "v3",
    date: "Q2 2026",
    version: "v3.0",
    title: "Warehouse-native models",
    summary: "Push compute down to Snowflake and BigQuery instead of copying data out.",
    status: "planned",
    points: [
      "Zero-copy connectors that respect existing warehouse RBAC",
      "Incremental materialization to keep cloud spend predictable",
      "Bring-your-own dbt project support",
    ],
  },
  {
    id: "v34",
    date: "Q4 2026",
    version: "v3.4",
    title: "Anomaly alerting",
    summary: "Forecast-aware alerts that page you before a metric quietly falls off a cliff.",
    status: "planned",
    points: [
      "Seasonality-aware baselines, not naive thresholds",
      "Root-cause hints that link the alert to the upstream change",
      "PagerDuty and Opsgenie routing out of the box",
    ],
  },
];

const STATUS_META: Record<Status, { label: string; badge: string; dot: string; ring: string }> = {
  shipped: {
    label: "Shipped",
    badge:
      "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
    dot: "bg-emerald-500",
    ring: "ring-emerald-500/40 dark:ring-emerald-400/40",
  },
  progress: {
    label: "In progress",
    badge: "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
    dot: "bg-amber-500",
    ring: "ring-amber-500/40 dark:ring-amber-400/40",
  },
  planned: {
    label: "Planned",
    badge: "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
    dot: "bg-sky-500",
    ring: "ring-sky-500/40 dark:ring-sky-400/40",
  },
};

type FilterKey = "all" | Status;

const FILTERS: { key: FilterKey; label: string }[] = [
  { key: "all", label: "All" },
  { key: "shipped", label: "Shipped" },
  { key: "progress", label: "In progress" },
  { key: "planned", label: "Planned" },
];

function ChevronIcon({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 20 20"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M6 8l4 4 4-4" />
    </svg>
  );
}

function StatusGlyph({ status, className }: { status: Status; className?: string }) {
  if (status === "shipped") {
    return (
      <svg className={className} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
        <path d="M4 10.5l3.5 3.5L16 5.5" />
      </svg>
    );
  }
  if (status === "progress") {
    return (
      <svg className={className} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true">
        <path d="M10 3.5a6.5 6.5 0 1 0 6.5 6.5" />
      </svg>
    );
  }
  return (
    <svg className={className} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <circle cx="10" cy="10" r="6.75" />
      <path d="M10 6.5V10l2.5 1.5" />
    </svg>
  );
}

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

  const counts = useMemo(() => {
    const base: Record<FilterKey, number> = {
      all: MILESTONES.length,
      shipped: 0,
      progress: 0,
      planned: 0,
    };
    for (const m of MILESTONES) base[m.status] += 1;
    return base;
  }, []);

  const visible = useMemo(
    () => (filter === "all" ? MILESTONES : MILESTONES.filter((m) => m.status === filter)),
    [filter],
  );

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

  const panelTransition = reduce ? { duration: 0 } : { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const };

  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">
      {/* atmospheric backdrop */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 opacity-70 [background:radial-gradient(38rem_38rem_at_15%_-5%,theme(colors.violet.500/0.12),transparent_60%),radial-gradient(32rem_32rem_at_100%_10%,theme(colors.sky.500/0.10),transparent_55%)] dark:opacity-100"
      />

      <div className="relative mx-auto max-w-3xl px-5 sm:px-6">
        <header className="mb-10 sm:mb-12">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium tracking-wide text-slate-600 backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-300">
            <span className="h-1.5 w-1.5 rounded-full bg-gradient-to-r from-sky-400 to-violet-500" />
            Product changelog
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
            The road to{" "}
            <span className="bg-gradient-to-r from-sky-500 via-violet-500 to-emerald-500 bg-clip-text text-transparent">
              Meridian 3.4
            </span>
          </h2>
          <p className="mt-3 max-w-xl text-sm leading-relaxed text-slate-600 sm:text-base dark:text-slate-400">
            Every release that took us from a scrappy launch to a warehouse-native analytics
            platform — and the milestones still ahead. Expand any entry for the details.
          </p>
        </header>

        {/* Filters */}
        <div
          role="group"
          aria-label="Filter milestones by status"
          className="mb-9 flex flex-wrap gap-2"
        >
          {FILTERS.map((f) => {
            const active = filter === f.key;
            return (
              <button
                key={f.key}
                type="button"
                aria-pressed={active}
                onClick={() => setFilter(f.key)}
                className={[
                  "inline-flex items-center gap-2 rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors",
                  "focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950",
                  active
                    ? "bg-gradient-to-r from-violet-600 to-sky-600 text-white shadow-sm"
                    : "border border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-700 dark:hover:text-slate-100",
                ].join(" ")}
              >
                {f.label}
                <span
                  className={[
                    "rounded-full px-1.5 py-0.5 text-[0.68rem] leading-none tabular-nums",
                    active ? "bg-white/20 text-white" : "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400",
                  ].join(" ")}
                >
                  {counts[f.key]}
                </span>
              </button>
            );
          })}
        </div>

        {/* Timeline */}
        <ol className="relative">
          {/* gradient rail */}
          <span
            aria-hidden="true"
            className="absolute left-4 top-3 bottom-3 w-0.5 rounded-full bg-gradient-to-b from-sky-400 via-violet-500 to-emerald-400"
          />
          {/* shimmer overlay travelling down the rail */}
          <span
            aria-hidden="true"
            className="tlgrad-flow absolute left-4 top-3 h-24 w-0.5 -translate-x-[1px] rounded-full bg-gradient-to-b from-transparent via-white/70 to-transparent dark:via-white/40"
          />

          <AnimatePresence initial={false} mode="popLayout">
            {visible.map((m, i) => {
              const meta = STATUS_META[m.status];
              const isOpen = open.has(m.id);
              const btnId = `${uid}-btn-${m.id}`;
              const panelId = `${uid}-panel-${m.id}`;
              return (
                <motion.li
                  key={m.id}
                  layout={!reduce}
                  initial={reduce ? false : { opacity: 0, y: 12 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
                  transition={reduce ? { duration: 0 } : { duration: 0.3, delay: i * 0.04, ease: [0.22, 1, 0.36, 1] as const }}
                  className="relative pb-6 pl-12 last:pb-0 sm:pl-14"
                >
                  {/* node on the rail */}
                  <span
                    aria-hidden="true"
                    className={[
                      "absolute left-4 top-4 flex h-6 w-6 -translate-x-1/2 items-center justify-center rounded-full text-white ring-4 ring-slate-50 dark:ring-slate-950",
                      meta.dot,
                    ].join(" ")}
                  >
                    <span
                      className={[
                        "absolute inset-0 rounded-full ring-2",
                        meta.ring,
                        isOpen && !reduce ? "tlgrad-pulse" : "",
                      ].join(" ")}
                    />
                    <StatusGlyph status={m.status} className="relative h-3.5 w-3.5" />
                  </span>

                  <div
                    className={[
                      "rounded-2xl border bg-white transition-colors dark:bg-slate-900/60",
                      isOpen
                        ? "border-violet-300 shadow-lg shadow-violet-500/5 dark:border-violet-500/40"
                        : "border-slate-200 dark:border-slate-800",
                    ].join(" ")}
                  >
                    <h3>
                      <button
                        id={btnId}
                        type="button"
                        aria-expanded={isOpen}
                        aria-controls={panelId}
                        onClick={() => toggle(m.id)}
                        className="group flex w-full items-start gap-3 rounded-2xl px-5 py-4 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950"
                      >
                        <div className="min-w-0 flex-1">
                          <div className="flex flex-wrap items-center gap-2">
                            <span className="font-mono text-xs font-semibold text-violet-600 dark:text-violet-400">
                              {m.version}
                            </span>
                            <span className="text-xs text-slate-400 dark:text-slate-500">
                              {m.date}
                            </span>
                            <span
                              className={[
                                "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.68rem] font-medium",
                                meta.badge,
                              ].join(" ")}
                            >
                              {meta.label}
                            </span>
                          </div>
                          <p className="mt-1.5 text-base font-semibold leading-snug text-slate-900 dark:text-slate-100">
                            {m.title}
                          </p>
                          <p className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                            {m.summary}
                          </p>
                        </div>
                        <ChevronIcon
                          className={[
                            "mt-1 h-5 w-5 shrink-0 text-slate-400 transition-transform duration-300 group-hover:text-slate-600 dark:group-hover:text-slate-200",
                            isOpen ? "rotate-180" : "",
                          ].join(" ")}
                        />
                      </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={panelTransition}
                          className="overflow-hidden"
                        >
                          <ul className="space-y-2.5 border-t border-slate-100 px-5 pb-5 pt-4 dark:border-slate-800">
                            {m.points.map((point) => (
                              <li
                                key={point}
                                className="flex items-start gap-2.5 text-sm leading-relaxed text-slate-700 dark:text-slate-300"
                              >
                                <span
                                  aria-hidden="true"
                                  className={["mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", meta.dot].join(" ")}
                                />
                                <span>{point}</span>
                              </li>
                            ))}
                          </ul>
                        </motion.div>
                      )}
                    </AnimatePresence>
                  </div>
                </motion.li>
              );
            })}
          </AnimatePresence>
        </ol>

        {visible.length === 0 && (
          <p className="py-8 text-center text-sm text-slate-500 dark:text-slate-400">
            No milestones match this filter yet.
          </p>
        )}
      </div>

      <style>{`
        @keyframes tlgrad-flow {
          0% { transform: translate(-1px, 0); opacity: 0; }
          12% { opacity: 1; }
          88% { opacity: 1; }
          100% { transform: translate(-1px, calc(100% + 100vh)); opacity: 0; }
        }
        @keyframes tlgrad-pulse {
          0% { transform: scale(1); opacity: 0.9; }
          70% { transform: scale(1.9); opacity: 0; }
          100% { transform: scale(1.9); opacity: 0; }
        }
        .tlgrad-flow {
          animation: tlgrad-flow 5.5s cubic-bezier(0.4, 0, 0.2, 1) infinite;
        }
        .tlgrad-pulse {
          animation: tlgrad-pulse 2.2s ease-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .tlgrad-flow, .tlgrad-pulse {
            animation: none !important;
          }
          .tlgrad-flow { opacity: 0 !important; }
        }
      `}</style>
    </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 →