Web InnoventixFreeCode

Centered Timeline

Original · free

centred alternating timeline

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

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

type Category = "product" | "growth" | "team";
type Filter = Category | "all";

interface Milestone {
  id: string;
  date: string;
  title: string;
  category: Category;
  summary: string;
  metric: { value: string; label: string };
  details: string[];
}

const MILESTONES: Milestone[] = [
  {
    id: "m1",
    date: "Q1 2023",
    title: "Three founders, one whiteboard",
    category: "team",
    summary:
      "Cadence began as a weekend prototype for tracking the decisions design teams keep re-litigating.",
    metric: { value: "3", label: "people" },
    details: [
      "Signed a six-person studio as customer zero before writing a line of production code.",
      "Rented a corner of a shared workspace in Lisbon and a single external monitor.",
      "Set a rule we still keep: ship something a real user can touch every Friday.",
    ],
  },
  {
    id: "m2",
    date: "Q3 2023",
    title: "Private beta ships",
    category: "product",
    summary:
      "We opened the editor to 120 hand-picked teams and rebuilt the core twice on their feedback.",
    metric: { value: "120", label: "beta teams" },
    details: [
      "Cut first-run onboarding from fourteen steps down to four.",
      "Replaced the homegrown sync layer with CRDTs after a near-miss data-loss scare.",
      "Median time-to-first-project dropped below five minutes.",
    ],
  },
  {
    id: "m3",
    date: "Q2 2024",
    title: "Crossed 1,000 active teams",
    category: "growth",
    summary: "Word of mouth did the work our tiny ad budget never could.",
    metric: { value: "1,000", label: "active teams" },
    details: [
      "Net revenue retention held at 118% through the quarter.",
      "A free tier launched in March now feeds roughly 40% of paid conversions.",
      "Support first-response stayed under two hours despite eight-times the volume.",
    ],
  },
  {
    id: "m4",
    date: "Q4 2024",
    title: "Real-time collaboration goes live",
    category: "product",
    summary: "Multiplayer editing shipped after nine unglamorous months of latency work.",
    metric: { value: "<80ms", label: "edit latency" },
    details: [
      "Cursor and selection updates settle under eighty milliseconds across regions.",
      "Presence, inline comments, and version history landed in one release.",
      "Rolled out to every plan — collaboration never sits behind a paywall.",
    ],
  },
  {
    id: "m5",
    date: "Q2 2025",
    title: "Series A — $18M",
    category: "growth",
    summary: "We raised to invest in security, reliability, and a lot fewer 3am pages.",
    metric: { value: "$18M", label: "raised" },
    details: [
      "Led by a fund that had quietly been a paying customer for over a year.",
      "Kept the round deliberately small to protect team ownership.",
      "Earmarked a third of it for infrastructure before anything else.",
    ],
  },
  {
    id: "m6",
    date: "Q1 2026",
    title: "SOC 2 Type II & EU data residency",
    category: "product",
    summary: "Enterprise asked, and we spent two quarters earning the checkmarks honestly.",
    metric: { value: "99.98%", label: "uptime" },
    details: [
      "Completed a clean SOC 2 Type II audit with zero exceptions.",
      "Added EU-only data residency for regulated and public-sector customers.",
      "Published a live status page and a plain-English security overview.",
    ],
  },
];

const CATEGORY_META: Record<
  Category,
  { label: string; dot: string; text: string; chip: string }
> = {
  product: {
    label: "Product",
    dot: "bg-indigo-500",
    text: "text-indigo-600 dark:text-indigo-400",
    chip: "bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/25",
  },
  growth: {
    label: "Growth",
    dot: "bg-emerald-500",
    text: "text-emerald-600 dark:text-emerald-400",
    chip: "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/25",
  },
  team: {
    label: "Team",
    dot: "bg-amber-500",
    text: "text-amber-600 dark:text-amber-400",
    chip: "bg-amber-50 text-amber-800 ring-1 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/25",
  },
};

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

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

export default function CenteredTimeline() {
  const reduce = useReducedMotion();
  const baseId = useId();
  const [activeFilter, setActiveFilter] = useState<Filter>("all");
  const [openId, setOpenId] = useState<string | null>("m4");
  const headerRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const counts = useMemo<Record<Filter, number>>(() => {
    const base: Record<Filter, number> = { all: MILESTONES.length, product: 0, growth: 0, team: 0 };
    for (const m of MILESTONES) base[m.category] += 1;
    return base;
  }, []);

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

  function focusAt(index: number) {
    const n = visible.length;
    if (n === 0) return;
    const wrapped = ((index % n) + n) % n;
    headerRefs.current[wrapped]?.focus();
  }

  function onHeaderKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        focusAt(index + 1);
        break;
      case "ArrowUp":
        e.preventDefault();
        focusAt(index - 1);
        break;
      case "Home":
        e.preventDefault();
        focusAt(0);
        break;
      case "End":
        e.preventDefault();
        focusAt(visible.length - 1);
        break;
      default:
        break;
    }
  }

  return (
    <section className="relative w-full overflow-hidden bg-white px-4 py-20 text-slate-900 sm:px-6 sm:py-28 lg:px-8 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes tlc-ripple {
          0% { transform: translate(-50%, -50%) scale(1); opacity: 0.5; }
          100% { transform: translate(-50%, -50%) scale(2.7); opacity: 0; }
        }
        @keyframes tlc-flow {
          0% { transform: translateY(-120%); }
          100% { transform: translateY(520%); }
        }
        .tlc-ripple { animation: tlc-ripple 1.9s ease-out infinite; }
        .tlc-flow { animation: tlc-flow 5.5s linear infinite; }
        @media (prefers-reduced-motion: reduce) {
          .tlc-ripple, .tlc-flow { animation: none !important; }
          .tlc-flow { opacity: 0; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 top-0 h-64 bg-gradient-to-b from-indigo-50/70 to-transparent dark:from-indigo-500/[0.06]"
      />

      <div className="relative mx-auto max-w-5xl">
        <header className="mx-auto max-w-2xl text-center">
          <span className="inline-flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-slate-600 ring-1 ring-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:ring-slate-800">
            <span aria-hidden="true" className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Our story
          </span>
          <h2 className="mt-5 text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
            The road from prototype to platform
          </h2>
          <p className="mt-4 text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Six moments that changed how Cadence works. Expand any milestone to see what actually
            happened, what broke, and what we learned along the way.
          </p>
        </header>

        <div
          role="group"
          aria-label="Filter milestones by category"
          className="mt-9 flex flex-wrap items-center justify-center gap-2"
        >
          {FILTERS.map((f) => {
            const active = activeFilter === f.key;
            return (
              <button
                key={f.key}
                type="button"
                aria-pressed={active}
                onClick={() => {
                  setActiveFilter(f.key);
                  setOpenId(null);
                }}
                className={[
                  "inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors motion-reduce:transition-none",
                  FOCUS_RING,
                  active
                    ? "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-50 hover:text-slate-900 dark:bg-slate-900 dark:text-slate-300 dark:ring-slate-700 dark:hover:bg-slate-800 dark:hover:text-white",
                ].join(" ")}
              >
                {f.label}
                <span
                  className={[
                    "rounded-full px-1.5 py-0.5 text-[0.6875rem] font-semibold tabular-nums",
                    active
                      ? "bg-white/20 text-white dark:bg-slate-900/10 dark:text-slate-900"
                      : "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400",
                  ].join(" ")}
                >
                  {counts[f.key]}
                </span>
              </button>
            );
          })}
        </div>

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

        <ol className="relative mt-14">
          <div
            aria-hidden="true"
            className="absolute bottom-0 left-6 top-0 w-0.5 -translate-x-1/2 overflow-hidden rounded-full bg-gradient-to-b from-slate-200 via-slate-200 to-transparent md:left-1/2 dark:from-slate-800 dark:via-slate-800"
          >
            <span className="tlc-flow absolute inset-x-0 h-24 bg-gradient-to-b from-transparent via-indigo-400/80 to-transparent dark:via-indigo-400/60" />
          </div>

          {visible.map((m, i) => {
            const meta = CATEGORY_META[m.category];
            const side: "left" | "right" = i % 2 === 0 ? "left" : "right";
            const isOpen = openId === m.id;
            const headerId = `${baseId}-h-${m.id}`;
            const panelId = `${baseId}-p-${m.id}`;

            return (
              <motion.li
                key={m.id}
                initial={reduce ? undefined : { opacity: 0, y: 28 }}
                whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
                viewport={{ once: true, amount: 0.3 }}
                transition={{ duration: 0.5, ease: "easeOut" }}
                className="relative pb-10 pl-16 last:pb-0 md:grid md:grid-cols-2 md:gap-x-12 md:pl-0"
              >
                <span
                  aria-hidden="true"
                  className="absolute left-6 top-7 z-10 -translate-x-1/2 md:left-1/2"
                >
                  <span className="relative block h-3.5 w-3.5">
                    {isOpen && (
                      <span
                        className={`tlc-ripple absolute left-1/2 top-1/2 h-3.5 w-3.5 rounded-full ${meta.dot}`}
                      />
                    )}
                    <span
                      className={`absolute inset-0 rounded-full ring-4 ring-white dark:ring-slate-950 ${meta.dot}`}
                    />
                  </span>
                </span>

                <div
                  className={
                    side === "left"
                      ? "md:col-start-1 md:pr-3"
                      : "md:col-start-2 md:row-start-1 md:pl-3"
                  }
                >
                  <article
                    className={[
                      "rounded-2xl border bg-white shadow-sm transition-colors motion-reduce:transition-none dark:bg-slate-900/60",
                      isOpen
                        ? "border-indigo-300 dark:border-indigo-500/50"
                        : "border-slate-200 hover:border-slate-300 dark:border-slate-800 dark:hover:border-slate-700",
                    ].join(" ")}
                  >
                    <button
                      type="button"
                      id={headerId}
                      ref={(el) => {
                        headerRefs.current[i] = el;
                      }}
                      onKeyDown={(e) => onHeaderKeyDown(e, i)}
                      onClick={() => setOpenId((prev) => (prev === m.id ? null : m.id))}
                      aria-expanded={isOpen}
                      aria-controls={panelId}
                      className={`flex w-full items-start gap-4 rounded-2xl p-5 text-left md:p-6 ${FOCUS_RING}`}
                    >
                      <span className="min-w-0 flex-1">
                        <span className="flex flex-wrap items-center gap-2">
                          <time className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
                            {m.date}
                          </time>
                          <span
                            className={`inline-flex items-center rounded-full px-2 py-0.5 text-[0.6875rem] font-semibold ${meta.chip}`}
                          >
                            {meta.label}
                          </span>
                        </span>
                        <span className="mt-2 block text-lg font-semibold tracking-tight 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>
                      <svg
                        aria-hidden="true"
                        viewBox="0 0 20 20"
                        fill="none"
                        className={`mt-0.5 h-5 w-5 shrink-0 text-slate-400 transition-transform duration-300 motion-reduce:transition-none ${
                          isOpen ? "rotate-180" : ""
                        }`}
                      >
                        <path
                          d="M5 7.5 10 12.5 15 7.5"
                          stroke="currentColor"
                          strokeWidth="1.75"
                          strokeLinecap="round"
                          strokeLinejoin="round"
                        />
                      </svg>
                    </button>

                    <AnimatePresence initial={false}>
                      {isOpen && (
                        <motion.div
                          key="panel"
                          id={panelId}
                          role="region"
                          aria-labelledby={headerId}
                          initial={reduce ? false : { height: 0, opacity: 0 }}
                          animate={reduce ? { opacity: 1 } : { height: "auto", opacity: 1 }}
                          exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
                          transition={{ duration: reduce ? 0.15 : 0.32, ease: "easeInOut" }}
                          className="overflow-hidden"
                        >
                          <div className="px-5 pb-5 md:px-6 md:pb-6">
                            <div className="flex items-baseline gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/50">
                              <span
                                className={`text-2xl font-semibold tabular-nums tracking-tight ${meta.text}`}
                              >
                                {m.metric.value}
                              </span>
                              <span className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
                                {m.metric.label}
                              </span>
                            </div>
                            <ul className="mt-4 space-y-2.5">
                              {m.details.map((d) => (
                                <li
                                  key={d}
                                  className="flex gap-2.5 text-sm leading-relaxed text-slate-600 dark:text-slate-300"
                                >
                                  <svg
                                    aria-hidden="true"
                                    viewBox="0 0 20 20"
                                    fill="none"
                                    className={`mt-0.5 h-4 w-4 shrink-0 ${meta.text}`}
                                  >
                                    <path
                                      d="m5 10.5 3.2 3.2L15 6.8"
                                      stroke="currentColor"
                                      strokeWidth="1.75"
                                      strokeLinecap="round"
                                      strokeLinejoin="round"
                                    />
                                  </svg>
                                  <span>{d}</span>
                                </li>
                              ))}
                            </ul>
                          </div>
                        </motion.div>
                      )}
                    </AnimatePresence>
                  </article>
                </div>
              </motion.li>
            );
          })}
        </ol>

        <p className="mt-12 text-center text-sm text-slate-500 dark:text-slate-500">
          Use <kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-sans text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">↑</kbd>{" "}
          <kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-sans text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">↓</kbd>{" "}
          to move between milestones, Enter to expand.
        </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 →