Web InnoventixFreeCode

Icons Timeline

Original · free

timeline with icon nodes

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

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

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

type Milestone = {
  id: string;
  period: string;
  title: string;
  status: Status;
  summary: string;
  detail: string;
  team: string;
  metric: string;
  icon: ReactNode;
};

function IconRocket() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-5 w-5"
      aria-hidden="true"
    >
      <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" />
      <path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.9 12.9 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.3 22.3 0 0 1-4 2z" />
      <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
      <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
    </svg>
  );
}

function IconBolt() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-5 w-5"
      aria-hidden="true"
    >
      <path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z" />
    </svg>
  );
}

function IconShield() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-5 w-5"
      aria-hidden="true"
    >
      <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
      <path d="m9 12 2 2 4-4" />
    </svg>
  );
}

function IconReceipt() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-5 w-5"
      aria-hidden="true"
    >
      <path d="M5 2v20l2-1.2L9 22l2-1.2L13 22l2-1.2L17 22l2-1.2V2l-2 1.2L15 2l-2 1.2L11 2 9 3.2 7 2 5 3.2z" />
      <path d="M8.5 8h7M8.5 12h7M8.5 16h4" />
    </svg>
  );
}

function IconGlobe() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-5 w-5"
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="9" />
      <path d="M3 12h18" />
      <path d="M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z" />
    </svg>
  );
}

function IconCode() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-5 w-5"
      aria-hidden="true"
    >
      <path d="m16 18 6-6-6-6M8 6l-6 6 6 6" />
    </svg>
  );
}

function IconChevron() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4"
      aria-hidden="true"
    >
      <path d="m6 9 6 6 6-6" />
    </svg>
  );
}

const MILESTONES: Milestone[] = [
  {
    id: "onboarding",
    period: "Q1 2025",
    title: "Self-serve onboarding",
    status: "shipped",
    summary:
      "New teams reach their first live dashboard in under ten minutes — no sales call required.",
    detail:
      "We replaced the manual provisioning flow with a guided setup that seeds a sample project, connects a first data source, and drops users straight into an editable dashboard. Activation within 24 hours climbed from 31% to 58%.",
    team: "Growth Engineering",
    metric: "58% day-one activation",
    icon: <IconRocket />,
  },
  {
    id: "pipeline",
    period: "Q2 2025",
    title: "Real-time event pipeline",
    status: "shipped",
    summary:
      "Events surface in dashboards a second or two after they happen, not minutes later.",
    detail:
      "The nightly batch job gave way to a streaming pipeline built on a partitioned log and incremental rollups. Median end-to-end latency dropped from four minutes to 1.8 seconds while sustaining 40,000 events per second.",
    team: "Platform",
    metric: "1.8s median latency",
    icon: <IconBolt />,
  },
  {
    id: "rbac",
    period: "Q3 2025",
    title: "Role-based access control",
    status: "shipped",
    summary:
      "Admins scope exactly who can see which projects, dashboards, and raw exports.",
    detail:
      "We shipped granular roles — viewer, editor, admin, billing — with per-project overrides and a complete audit log. SAML single sign-on and SCIM provisioning landed the same quarter, unblocking three enterprise deals.",
    team: "Security",
    metric: "SAML + SCIM",
    icon: <IconShield />,
  },
  {
    id: "billing",
    period: "Q4 2025",
    title: "Usage-based billing",
    status: "progress",
    summary:
      "Pay for the events you actually track, with spend caps and live usage meters.",
    detail:
      "Metering is running in staging and reconciles against the ingestion ledger down to the individual event. We are finishing invoice previews, proration on plan changes, and hard spend limits before the general rollout.",
    team: "Billing",
    metric: "In staging",
    icon: <IconReceipt />,
  },
  {
    id: "residency",
    period: "Q1 2026",
    title: "EU data residency",
    status: "planned",
    summary:
      "Store and process customer data entirely within an isolated EU region.",
    detail:
      "A Frankfurt region with dedicated storage, queues, and compute is scoped. Work includes region-aware routing at the edge, encrypted cross-region metadata only, and a residency toggle per workspace to honour GDPR commitments.",
    team: "Platform",
    metric: "Frankfurt region",
    icon: <IconGlobe />,
  },
  {
    id: "graphql",
    period: "Q2 2026",
    title: "Public GraphQL API",
    status: "planned",
    summary:
      "Query your metrics programmatically through a typed, versioned schema.",
    detail:
      "The API exposes dashboards, metrics, and cohorts behind scoped keys with per-key rate limits. A generated TypeScript client and webhook subscriptions for threshold alerts ship alongside the first stable schema.",
    team: "Developer Platform",
    metric: "Typed schema",
    icon: <IconCode />,
  },
];

const STATUS_META: Record<
  Status,
  { label: string; badge: string; node: string; dot: string }
> = {
  shipped: {
    label: "Shipped",
    badge:
      "bg-emerald-100 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/25",
    node: "border-emerald-400 text-emerald-600 dark:border-emerald-500/60 dark:text-emerald-300",
    dot: "bg-emerald-500",
  },
  progress: {
    label: "In progress",
    badge:
      "bg-amber-100 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/25",
    node: "border-amber-400 text-amber-600 dark:border-amber-500/60 dark:text-amber-300",
    dot: "bg-amber-500",
  },
  planned: {
    label: "Planned",
    badge:
      "bg-indigo-100 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/25",
    node: "border-indigo-300 text-indigo-500 dark:border-indigo-500/45 dark:text-indigo-300",
    dot: "bg-indigo-400",
  },
};

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

export default function TimelineIcons() {
  const reduce = useReducedMotion();
  const baseId = useId();
  const [filter, setFilter] = useState<Filter>("all");
  const [open, setOpen] = useState<Set<string>>(() => new Set(["billing"]));
  const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const shippedCount = useMemo(
    () => MILESTONES.filter((m) => m.status === "shipped").length,
    [],
  );
  const pct = Math.round((shippedCount / MILESTONES.length) * 100);

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

  const countFor = (value: Filter) =>
    value === "all"
      ? MILESTONES.length
      : MILESTONES.filter((m) => m.status === value).length;

  const allOpen =
    filtered.length > 0 && filtered.every((m) => open.has(m.id));

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

  function toggleAll() {
    setOpen(allOpen ? new Set() : new Set(filtered.map((m) => m.id)));
  }

  function onRadioKey(e: KeyboardEvent<HTMLButtonElement>, index: number) {
    let next = index;
    const last = FILTERS.length - 1;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = index === last ? 0 : index + 1;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = index === 0 ? last : index - 1;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = last;
    else return;
    e.preventDefault();
    setFilter(FILTERS[next].value);
    optionRefs.current[next]?.focus();
  }

  const titleId = `${baseId}-title`;

  const itemInitial = reduce ? { opacity: 0 } : { opacity: 0, y: 14 };
  const itemExit = reduce ? { opacity: 0 } : { opacity: 0, y: -8 };

  return (
    <section
      aria-labelledby={titleId}
      className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-24 dark:bg-slate-950 dark:text-slate-100"
    >
      <style>{`
        @keyframes tlic-ping {
          0% { transform: scale(1); opacity: 0.5; }
          100% { transform: scale(2.3); opacity: 0; }
        }
        @keyframes tlic-rise {
          from { transform: translateY(10px); opacity: 0; }
          to { transform: translateY(0); opacity: 1; }
        }
        .tlic-pulse { animation: tlic-ping 2.2s cubic-bezier(0.4, 0, 0.2, 1) infinite; }
        .tlic-eyebrow { animation: tlic-rise 0.6s ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .tlic-pulse, .tlic-eyebrow { animation: none !important; }
        }
      `}</style>

      {/* Decorative atmosphere */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 -z-10"
      >
        <div className="absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-600/15" />
        <div className="absolute bottom-0 right-4 h-64 w-64 rounded-full bg-violet-300/20 blur-3xl dark:bg-violet-700/15" />
      </div>

      <div className="mx-auto max-w-3xl">
        <header className="mb-10">
          <p className="tlic-eyebrow inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-white/70 px-3 py-1 text-xs font-semibold uppercase tracking-widest text-indigo-600 backdrop-blur dark:border-indigo-500/30 dark:bg-slate-900/60 dark:text-indigo-300">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Platform roadmap
          </p>
          <h2
            id={titleId}
            className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white"
          >
            What we shipped, and what&rsquo;s next
          </h2>
          <p className="mt-3 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-400">
            A living timeline of the analytics platform — every milestone with
            the team behind it and the number that proves it moved.
          </p>

          {/* Progress */}
          <div className="mt-6 max-w-sm">
            <div className="flex items-baseline justify-between text-sm">
              <span className="font-medium text-slate-700 dark:text-slate-300">
                {shippedCount} of {MILESTONES.length} milestones shipped
              </span>
              <span className="tabular-nums text-slate-500 dark:text-slate-400">
                {pct}%
              </span>
            </div>
            <div
              className="mt-2 h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
              role="progressbar"
              aria-valuemin={0}
              aria-valuemax={MILESTONES.length}
              aria-valuenow={shippedCount}
              aria-valuetext={`${shippedCount} of ${MILESTONES.length} milestones shipped`}
            >
              <div
                className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-emerald-400 transition-[width] duration-500 ease-out motion-reduce:transition-none"
                style={{ width: `${pct}%` }}
              />
            </div>
          </div>
        </header>

        {/* Controls */}
        <div className="mb-8 flex flex-wrap items-center justify-between gap-4">
          <div
            role="radiogroup"
            aria-label="Filter milestones by status"
            className="inline-flex flex-wrap gap-1.5 rounded-xl border border-slate-200 bg-white/70 p-1.5 backdrop-blur dark:border-slate-800 dark:bg-slate-900/60"
          >
            {FILTERS.map((f, i) => {
              const active = filter === f.value;
              return (
                <button
                  key={f.value}
                  ref={(el) => {
                    optionRefs.current[i] = el;
                  }}
                  type="button"
                  role="radio"
                  aria-checked={active}
                  tabIndex={active ? 0 : -1}
                  onClick={() => setFilter(f.value)}
                  onKeyDown={(e) => onRadioKey(e, i)}
                  className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white motion-reduce:transition-none dark:focus-visible:ring-offset-slate-900 ${
                    active
                      ? "bg-slate-900 text-white shadow-sm dark:bg-white dark:text-slate-900"
                      : "text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
                  }`}
                >
                  {f.label}
                  <span
                    className={`rounded-md px-1.5 py-0.5 text-xs tabular-nums ${
                      active
                        ? "bg-white/20 text-white dark:bg-slate-900/15 dark:text-slate-900"
                        : "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400"
                    }`}
                  >
                    {countFor(f.value)}
                  </span>
                </button>
              );
            })}
          </div>

          <button
            type="button"
            onClick={toggleAll}
            className="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 outline-none transition-colors hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 motion-reduce:transition-none dark:text-slate-400 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-950"
          >
            {allOpen ? "Collapse all" : "Expand all"}
          </button>
        </div>

        {/* Timeline */}
        <div className="relative">
          <span
            aria-hidden="true"
            className="pointer-events-none absolute left-6 top-3 bottom-3 w-px bg-gradient-to-b from-indigo-300 via-violet-300 to-slate-200 dark:from-indigo-700 dark:via-violet-800 dark:to-slate-800"
          />

          <ol className="space-y-4">
          <AnimatePresence initial={false}>
            {filtered.map((m, i) => {
              const meta = STATUS_META[m.status];
              const isOpen = open.has(m.id);
              const panelId = `${baseId}-panel-${m.id}`;
              const btnId = `${baseId}-btn-${m.id}`;
              return (
                <motion.li
                  key={m.id}
                  layout={!reduce}
                  initial={itemInitial}
                  animate={{ opacity: 1, y: 0 }}
                  exit={itemExit}
                  transition={{
                    duration: reduce ? 0 : 0.32,
                    delay: reduce ? 0 : i * 0.05,
                    ease: [0.22, 1, 0.36, 1],
                  }}
                  className="relative pl-16"
                >
                  {/* Icon node */}
                  <span
                    className={`absolute left-6 top-3 grid h-11 w-11 -translate-x-1/2 place-items-center rounded-full border-2 bg-white shadow-sm dark:bg-slate-900 ${meta.node}`}
                  >
                    {m.status === "progress" && (
                      <span
                        aria-hidden="true"
                        className="tlic-pulse absolute inset-0 rounded-full border-2 border-amber-400/70"
                      />
                    )}
                    {m.icon}
                  </span>

                  {/* Card */}
                  <div className="rounded-2xl border border-slate-200 bg-white/70 shadow-sm backdrop-blur transition-colors hover:border-slate-300 motion-reduce:transition-none dark:border-slate-800 dark:bg-slate-900/60 dark:hover:border-slate-700">
                    <h3>
                      <button
                        id={btnId}
                        type="button"
                        aria-expanded={isOpen}
                        aria-controls={panelId}
                        onClick={() => toggle(m.id)}
                        className="flex w-full items-start justify-between gap-4 rounded-2xl p-4 text-left outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 sm:p-5 dark:focus-visible:ring-offset-slate-950"
                      >
                        <span className="min-w-0">
                          <span className="flex flex-wrap items-center gap-2">
                            <span className="font-mono text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400">
                              {m.period}
                            </span>
                            <span
                              className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${meta.badge}`}
                            >
                              <span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} />
                              {meta.label}
                            </span>
                          </span>
                          <span className="mt-1.5 block text-lg font-semibold text-slate-900 dark:text-white">
                            {m.title}
                          </span>
                          <span className="mt-1 block text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                            {m.summary}
                          </span>
                        </span>
                        <span
                          className={`mt-0.5 shrink-0 rounded-full border border-slate-200 p-1.5 text-slate-500 transition-transform duration-300 motion-reduce:transition-none dark:border-slate-700 dark:text-slate-400 ${
                            isOpen ? "rotate-180" : ""
                          }`}
                        >
                          <IconChevron />
                        </span>
                      </button>
                    </h3>

                    <AnimatePresence initial={false}>
                      {isOpen && (
                        <motion.div
                          key="panel"
                          id={panelId}
                          role="region"
                          aria-labelledby={btnId}
                          initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
                          animate={reduce ? { opacity: 1 } : { height: "auto", opacity: 1 }}
                          exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
                          transition={{ duration: reduce ? 0 : 0.28, ease: [0.22, 1, 0.36, 1] }}
                          className="overflow-hidden"
                        >
                          <div className="border-t border-slate-200/80 px-4 pb-5 pt-4 sm:px-5 dark:border-slate-800/80">
                            <p className="text-sm leading-relaxed text-slate-700 dark:text-slate-300">
                              {m.detail}
                            </p>
                            <dl className="mt-4 flex flex-wrap gap-x-8 gap-y-3">
                              <div>
                                <dt className="text-xs font-medium uppercase tracking-wider text-slate-400 dark:text-slate-500">
                                  Owned by
                                </dt>
                                <dd className="mt-0.5 text-sm font-medium text-slate-800 dark:text-slate-200">
                                  {m.team}
                                </dd>
                              </div>
                              <div>
                                <dt className="text-xs font-medium uppercase tracking-wider text-slate-400 dark:text-slate-500">
                                  Result
                                </dt>
                                <dd className="mt-0.5 text-sm font-semibold text-indigo-600 dark:text-indigo-300">
                                  {m.metric}
                                </dd>
                              </div>
                            </dl>
                          </div>
                        </motion.div>
                      )}
                    </AnimatePresence>
                  </div>
                </motion.li>
              );
            })}
          </AnimatePresence>
          </ol>
        </div>

        {filtered.length === 0 && (
          <p className="rounded-2xl border border-dashed border-slate-300 py-12 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
            No milestones match this filter yet.
          </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 →