Web InnoventixFreeCode

Horizontal Timeline

Original · free

horizontal timeline

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

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

type Stat = { label: string; value: string };

type Milestone = {
  id: string;
  year: string;
  period: string;
  title: string;
  tag: string;
  blurb: string;
  stats: [Stat, Stat];
};

const MILESTONES: Milestone[] = [
  {
    id: "loft",
    year: "2019",
    period: "Q2 · Rotterdam",
    title: "The loft prototype",
    tag: "Origin",
    blurb:
      "Two ex-platform engineers rented a corner of a shipping-district co-working loft and shipped the first Halyard runner — a single Go binary that turned a YAML file into a reproducible build in under a minute.",
    stats: [
      { label: "Team", value: "2" },
      { label: "Funding", value: "Bootstrapped" },
    ],
  },
  {
    id: "beta",
    year: "2020",
    period: "Q1 · Public beta",
    title: "The beta opens",
    tag: "Launch",
    blurb:
      "Halyard 0.4 went public. 1,900 developers signed up in the first week, drawn by config that fit on a postcard and logs that streamed live instead of buffering to the very end of the run.",
    stats: [
      { label: "Beta signups", value: "1,900" },
      { label: "Avg. build", value: "48s" },
    ],
  },
  {
    id: "series-a",
    year: "2021",
    period: "Q3 · Series A",
    title: "Fourteen million raised",
    tag: "Funded",
    blurb:
      "Led by Northaven Ventures, the round turned a side project into a company. Headcount tripled and the team rewrote the scheduler to pack four independent builds onto every runner node.",
    stats: [
      { label: "Raised", value: "$14M" },
      { label: "Team", value: "23" },
    ],
  },
  {
    id: "self-hosted",
    year: "2022",
    period: "Q4 · GA",
    title: "Self-hosted runners",
    tag: "Release",
    blurb:
      "Enterprises wanted builds inside their own VPC. Self-hosted runners shipped with signed attestations, so any pipeline could prove exactly which commit and which machine produced every artifact.",
    stats: [
      { label: "Enterprise", value: "40+" },
      { label: "Regions", value: "6" },
    ],
  },
  {
    id: "million",
    year: "2023",
    period: "Q2 · Scale",
    title: "A million pipelines a day",
    tag: "Scale",
    blurb:
      "A quiet Tuesday crossed the seven-figure mark. The control plane held p99 queue latency under 900ms by sharding tenants across independent scheduler cells that fail without taking neighbours down.",
    stats: [
      { label: "Pipelines/day", value: "1.2M" },
      { label: "p99 queue", value: "0.9s" },
    ],
  },
  {
    id: "soc2",
    year: "2024",
    period: "Q1 · Trust",
    title: "SOC 2 + EU residency",
    tag: "Trust",
    blurb:
      "A clean Type II report and a Frankfurt data region unlocked regulated customers. Audit logs became immutable and stream in real time to the customer's own SIEM, no nightly export job required.",
    stats: [
      { label: "Uptime", value: "99.98%" },
      { label: "Data regions", value: "3" },
    ],
  },
  {
    id: "repair",
    year: "2025",
    period: "Q3 · Today",
    title: "AI pipeline repair",
    tag: "Today",
    blurb:
      "Halyard now reads a failed build, proposes the one-line fix, and opens a pull request against the config. Nine of ten flaky-cache failures are resolved before an engineer even opens the tab.",
    stats: [
      { label: "Auto-fixed", value: "71%" },
      { label: "Median repair", value: "40s" },
    ],
  },
];

function ChevronIcon({ dir }: { dir: "left" | "right" }) {
  return (
    <svg
      viewBox="0 0 24 24"
      aria-hidden="true"
      className="h-5 w-5"
      fill="none"
      stroke="currentColor"
      strokeWidth={2.2}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      {dir === "left" ? <path d="M15 6l-6 6 6 6" /> : <path d="M9 6l6 6-6 6" />}
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      aria-hidden="true"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth={3}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M5 12.5l4.5 4.5L19 7" />
    </svg>
  );
}

export default function TlHorizontal() {
  const reduce = useReducedMotion();
  const uid = useId();
  const [selected, setSelected] = useState(2);
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const count = MILESTONES.length;
  const active = MILESTONES[selected];
  const inset = 50 / count;
  const fillWidth = (selected / count) * 100;

  function focusTab(next: number) {
    const clamped = Math.max(0, Math.min(count - 1, next));
    setSelected(clamped);
    tabRefs.current[clamped]?.focus();
  }

  function onTabKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
    switch (event.key) {
      case "ArrowRight":
      case "ArrowDown":
        event.preventDefault();
        focusTab(index + 1);
        break;
      case "ArrowLeft":
      case "ArrowUp":
        event.preventDefault();
        focusTab(index - 1);
        break;
      case "Home":
        event.preventDefault();
        focusTab(0);
        break;
      case "End":
        event.preventDefault();
        focusTab(count - 1);
        break;
      default:
        break;
    }
  }

  const panelMotion = reduce
    ? {
        initial: { opacity: 0 },
        animate: { opacity: 1 },
        exit: { opacity: 0 },
        transition: { duration: 0.12 },
      }
    : {
        initial: { opacity: 0, y: 14 },
        animate: { opacity: 1, y: 0 },
        exit: { opacity: 0, y: -10 },
        transition: { duration: 0.34, ease: "easeOut" as const },
      };

  const ringBase =
    "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";

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-6 py-20 text-slate-900 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes tlhz-pulse {
          0%   { transform: scale(1);   opacity: 0.55; }
          70%  { transform: scale(2.1); opacity: 0; }
          100% { transform: scale(2.1); opacity: 0; }
        }
        @keyframes tlhz-sheen {
          0%   { background-position: 200% 0; }
          100% { background-position: -200% 0; }
        }
        .tlhz-pulse-ring { animation: tlhz-pulse 2.4s ease-out infinite; }
        .tlhz-sheen {
          background-size: 200% 100%;
          animation: tlhz-sheen 3.2s linear infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .tlhz-pulse-ring, .tlhz-sheen { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-indigo-400/60 to-transparent"
      />

      <div className="relative mx-auto max-w-6xl">
        {/* Header */}
        <div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
          <div className="max-w-2xl">
            <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
              <span className="tlhz-sheen bg-gradient-to-r from-indigo-500 via-violet-400 to-indigo-500 bg-clip-text text-transparent">
                Halyard
              </span>
              Product timeline
            </span>
            <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
              From a loft prototype to a million pipelines a day
            </h2>
            <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
              Six years of shipping a continuous-delivery platform. Step through
              the milestones with the arrows or your keyboard.
            </p>
          </div>

          {/* Prev / Next controls */}
          <div className="flex shrink-0 items-center gap-3">
            <span
              className="text-sm font-medium tabular-nums text-slate-500 dark:text-slate-400"
              aria-hidden="true"
            >
              {String(selected + 1).padStart(2, "0")}
              <span className="mx-1 text-slate-300 dark:text-slate-600">/</span>
              {String(count).padStart(2, "0")}
            </span>
            <div className="flex items-center gap-2">
              <button
                type="button"
                onClick={() => focusTab(selected - 1)}
                disabled={selected === 0}
                aria-label="Previous milestone"
                className={`inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-300 bg-white text-slate-700 transition-colors hover:border-indigo-400 hover:text-indigo-600 disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-indigo-500 dark:hover:text-indigo-300 ${ringBase}`}
              >
                <ChevronIcon dir="left" />
              </button>
              <button
                type="button"
                onClick={() => focusTab(selected + 1)}
                disabled={selected === count - 1}
                aria-label="Next milestone"
                className={`inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-300 bg-white text-slate-700 transition-colors hover:border-indigo-400 hover:text-indigo-600 disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-indigo-500 dark:hover:text-indigo-300 ${ringBase}`}
              >
                <ChevronIcon dir="right" />
              </button>
            </div>
          </div>
        </div>

        {/* Timeline track */}
        <div className="mt-14 overflow-x-auto pb-4 [scrollbar-width:thin]">
          <div
            role="tablist"
            aria-label="Halyard milestones"
            aria-orientation="horizontal"
            className="relative mx-auto min-w-[720px]"
          >
            {/* base rail */}
            <div
              aria-hidden="true"
              className="absolute top-[22px] h-0.5 -translate-y-1/2 rounded-full bg-slate-200 dark:bg-slate-800"
              style={{ left: `${inset}%`, right: `${inset}%` }}
            />
            {/* progress fill */}
            <motion.div
              aria-hidden="true"
              className="absolute top-[22px] h-0.5 -translate-y-1/2 rounded-full bg-gradient-to-r from-indigo-500 via-violet-500 to-indigo-500"
              style={{ left: `${inset}%` }}
              initial={false}
              animate={{ width: `${fillWidth}%` }}
              transition={
                reduce ? { duration: 0 } : { duration: 0.45, ease: "easeOut" }
              }
            />

            <div className="relative flex items-start">
              {MILESTONES.map((m, i) => {
                const isActive = i === selected;
                const isPast = i < selected;
                const tabId = `${uid}-tab-${i}`;
                return (
                  <div
                    key={m.id}
                    className="flex flex-1 flex-col items-center px-1"
                  >
                    <button
                      type="button"
                      role="tab"
                      id={tabId}
                      ref={(el) => {
                        tabRefs.current[i] = el;
                      }}
                      aria-selected={isActive}
                      aria-controls={`${uid}-panel`}
                      tabIndex={isActive ? 0 : -1}
                      onClick={() => focusTab(i)}
                      onKeyDown={(e) => onTabKeyDown(e, i)}
                      className={`group relative z-10 flex h-11 w-11 items-center justify-center rounded-full border-2 bg-white transition-all dark:bg-slate-950 ${ringBase} ${
                        isActive
                          ? "border-indigo-500 scale-110 shadow-[0_0_0_4px_rgba(99,102,241,0.14)]"
                          : isPast
                            ? "border-indigo-500 bg-indigo-500 text-white dark:bg-indigo-500"
                            : "border-slate-300 text-slate-400 hover:border-indigo-400 dark:border-slate-700 dark:text-slate-500 dark:hover:border-indigo-500"
                      }`}
                    >
                      {isActive && (
                        <span
                          aria-hidden="true"
                          className="tlhz-pulse-ring absolute inset-0 rounded-full bg-indigo-500/25"
                        />
                      )}
                      {isPast ? (
                        <CheckIcon />
                      ) : (
                        <span
                          aria-hidden="true"
                          className={`block rounded-full transition-all ${
                            isActive
                              ? "h-3 w-3 bg-indigo-500"
                              : "h-2.5 w-2.5 bg-slate-300 group-hover:bg-indigo-400 dark:bg-slate-600"
                          }`}
                        />
                      )}
                    </button>

                    <span
                      className={`mt-4 text-base font-semibold tabular-nums transition-colors ${
                        isActive
                          ? "text-indigo-600 dark:text-indigo-300"
                          : "text-slate-800 dark:text-slate-200"
                      }`}
                    >
                      {m.year}
                    </span>
                    <span className="mt-1 max-w-[8.5rem] text-center text-xs leading-snug text-slate-500 dark:text-slate-400">
                      {m.title}
                    </span>
                  </div>
                );
              })}
            </div>
          </div>
        </div>

        {/* Detail panel */}
        <div
          id={`${uid}-panel`}
          role="tabpanel"
          aria-labelledby={`${uid}-tab-${selected}`}
          tabIndex={0}
          className={`mt-10 rounded-2xl border border-slate-200 bg-white p-6 sm:p-8 dark:border-slate-800 dark:bg-slate-900/60 ${ringBase}`}
        >
          <AnimatePresence mode="wait" initial={false}>
            <motion.div
              key={active.id}
              initial={panelMotion.initial}
              animate={panelMotion.animate}
              exit={panelMotion.exit}
              transition={panelMotion.transition}
              className="grid gap-8 md:grid-cols-[1.6fr_1fr] md:items-center"
            >
              <div>
                <div className="flex flex-wrap items-center gap-3">
                  <span className="inline-flex items-center rounded-full bg-indigo-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-indigo-700 dark:text-indigo-300">
                    {active.tag}
                  </span>
                  <span className="text-sm font-medium text-slate-500 dark:text-slate-400">
                    {active.period}
                  </span>
                </div>
                <h3 className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
                  {active.title}
                </h3>
                <p className="mt-3 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-300">
                  {active.blurb}
                </p>
              </div>

              <dl className="grid grid-cols-2 gap-4">
                {active.stats.map((s) => (
                  <div
                    key={s.label}
                    className="rounded-xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950/40"
                  >
                    <dt className="text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400">
                      {s.label}
                    </dt>
                    <dd className="mt-1 text-2xl font-semibold tabular-nums text-slate-900 dark:text-white">
                      {s.value}
                    </dd>
                  </div>
                ))}
              </dl>
            </motion.div>
          </AnimatePresence>
        </div>
      </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 →