Web InnoventixFreeCode

Timeline Accordion

Original · free

A timeline-style vertical accordion with a connecting line.

byWeb InnoventixReact + Tailwind
accordiontimelineaccordions
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/accordion-timeline.json
accordion-timeline.tsx
"use client";

import { useId, useRef, useState, type KeyboardEvent } from "react";

type TimelineStatus = "done" | "active" | "upcoming";

type TimelineItem = {
  year: string;
  title: string;
  meta: string;
  status: TimelineStatus;
  body: string;
  points: string[];
};

const ITEMS: TimelineItem[] = [
  {
    year: "2019",
    title: "Founded in a two-room studio",
    meta: "Company · Seed",
    status: "done",
    body: "Three engineers left their agency jobs to build software that shipped instead of software that demoed. The first office was a converted apartment with more whiteboards than chairs.",
    points: [
      "First paying client signed in week six",
      "Open-sourced the internal component kit",
      "Hit break-even without outside capital",
    ],
  },
  {
    year: "2021",
    title: "Series A and the first 20 hires",
    meta: "Growth · $8M raised",
    status: "done",
    body: "A lead investor came in after seeing retention numbers that looked more like a utility than a startup. The team doubled twice in eighteen months and design finally got its own department.",
    points: [
      "Opened a second hub across two time zones",
      "Shipped the real-time collaboration engine",
      "Net revenue retention crossed 130%",
    ],
  },
  {
    year: "2023",
    title: "Platform rewrite and public API",
    meta: "Product · v3.0",
    status: "active",
    body: "The monolith that carried us this far was rebuilt into services without a single day of downtime. Opening the API turned early users into a partner ecosystem almost overnight.",
    points: [
      "P95 latency cut from 400ms to 90ms",
      "Launched a marketplace of 60+ integrations",
      "SOC 2 Type II certified",
    ],
  },
  {
    year: "2025",
    title: "Global rollout, one million teams",
    meta: "Scale · In progress",
    status: "upcoming",
    body: "Localization in fourteen languages and regional data residency unlocked enterprise deals that were previously off the table. The roadmap now runs on customer-voted priorities.",
    points: [
      "On-device model inference for privacy tiers",
      "Regional data residency in five geographies",
      "Community roadmap with public voting",
    ],
  },
];

const STATUS_STYLES: Record<
  TimelineStatus,
  { dot: string; ring: string; label: string; badge: string }
> = {
  done: {
    dot: "bg-emerald-500 dark:bg-emerald-400",
    ring: "ring-emerald-500/30 dark:ring-emerald-400/30",
    label: "Shipped",
    badge:
      "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20",
  },
  active: {
    dot: "bg-indigo-500 dark:bg-indigo-400",
    ring: "ring-indigo-500/30 dark:ring-indigo-400/30",
    label: "In flight",
    badge:
      "bg-indigo-50 text-indigo-700 ring-indigo-600/20 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20",
  },
  upcoming: {
    dot: "bg-slate-400 dark:bg-slate-500",
    ring: "ring-slate-400/30 dark:ring-slate-500/30",
    label: "Planned",
    badge:
      "bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-slate-500/10 dark:text-slate-300 dark:ring-slate-400/20",
  },
};

function Chevron({ open }: { open: boolean }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={`h-5 w-5 shrink-0 transition-transform duration-300 ease-out ${
        open ? "rotate-180" : "rotate-0"
      }`}
    >
      <path d="m6 9 6 6 6-6" />
    </svg>
  );
}

function CheckDot() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={3}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className="h-3 w-3"
    >
      <path d="M20 6 9 17l-5-5" />
    </svg>
  );
}

export default function AccordionTimeline() {
  const [open, setOpen] = useState<number>(2);
  const baseId = useId();
  const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const toggle = (index: number) => {
    setOpen((prev) => (prev === index ? -1 : index));
  };

  const onKeyDown = (e: KeyboardEvent, index: number) => {
    const last = ITEMS.length - 1;
    let next = index;
    if (e.key === "ArrowDown") next = index === last ? 0 : index + 1;
    else if (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();
    btnRefs.current[next]?.focus();
  };

  return (
    <section className="relative w-full bg-white px-4 py-20 sm:px-6 lg:py-28 dark:bg-slate-950">
      <style>{`
        @keyframes acctl-reveal {
          from { opacity: 0; transform: translateY(-6px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @keyframes acctl-pulse {
          0%, 100% { transform: scale(1); opacity: 0.9; }
          50% { transform: scale(1.9); opacity: 0; }
        }
        .acctl-panel[data-open="true"] .acctl-inner {
          animation: acctl-reveal 360ms cubic-bezier(0.16, 1, 0.3, 1) both;
        }
        @media (prefers-reduced-motion: reduce) {
          .acctl-panel[data-open="true"] .acctl-inner { animation: none; }
          .acctl-ping { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-2xl">
        <div className="mb-12 text-center">
          <span className="inline-flex items-center rounded-full bg-indigo-50 px-3 py-1 text-xs font-semibold uppercase tracking-widest text-indigo-700 ring-1 ring-inset ring-indigo-600/20 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
            Our story
          </span>
          <h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Milestones that shaped us
          </h2>
          <p className="mt-3 text-base text-slate-600 dark:text-slate-400">
            Expand any moment on the timeline to see what actually happened
            behind the headline.
          </p>
        </div>

        <ol className="relative">
          {/* connecting line */}
          <span
            aria-hidden="true"
            className="absolute left-[15px] top-2 bottom-2 w-px bg-gradient-to-b from-slate-200 via-slate-200 to-transparent dark:from-slate-700 dark:via-slate-800"
          />

          {ITEMS.map((item, index) => {
            const isOpen = open === index;
            const s = STATUS_STYLES[item.status];
            const panelId = `${baseId}-panel-${index}`;
            const btnId = `${baseId}-btn-${index}`;

            return (
              <li key={item.year} className="relative pl-12">
                {/* node on the line */}
                <span className="absolute left-0 top-4 flex h-8 w-8 items-center justify-center">
                  {item.status === "active" && (
                    <span
                      aria-hidden="true"
                      className={`acctl-ping absolute inline-flex h-3.5 w-3.5 rounded-full ${s.dot}`}
                      style={{
                        animation: "acctl-pulse 2s ease-out infinite",
                      }}
                    />
                  )}
                  <span
                    className={`relative inline-flex h-3.5 w-3.5 items-center justify-center rounded-full text-white ring-4 ring-white dark:ring-slate-950 ${s.dot}`}
                  >
                    <span
                      className={`absolute inline-flex h-6 w-6 rounded-full ring-1 ${s.ring}`}
                      aria-hidden="true"
                    />
                    {item.status === "done" && <CheckDot />}
                  </span>
                </span>

                <div
                  className={`mb-4 overflow-hidden rounded-2xl border transition-colors duration-300 ${
                    isOpen
                      ? "border-indigo-200 bg-indigo-50/40 dark:border-indigo-400/25 dark:bg-indigo-500/[0.06]"
                      : "border-slate-200 bg-slate-50/60 hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900/50 dark:hover:border-slate-700"
                  }`}
                >
                  <h3>
                    <button
                      ref={(el) => {
                        btnRefs.current[index] = el;
                      }}
                      id={btnId}
                      type="button"
                      aria-expanded={isOpen}
                      aria-controls={panelId}
                      onClick={() => toggle(index)}
                      onKeyDown={(e) => onKeyDown(e, index)}
                      className="flex w-full items-center gap-4 px-5 py-4 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:focus-visible:ring-indigo-400"
                    >
                      <span className="flex-1">
                        <span className="flex flex-wrap items-center gap-x-3 gap-y-1">
                          <span className="font-mono text-sm font-semibold text-indigo-600 dark:text-indigo-400">
                            {item.year}
                          </span>
                          <span
                            className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1 ring-inset ${s.badge}`}
                          >
                            {s.label}
                          </span>
                        </span>
                        <span className="mt-1 block text-base font-semibold text-slate-900 dark:text-white">
                          {item.title}
                        </span>
                      </span>
                      <span className="text-slate-400 dark:text-slate-500">
                        <Chevron open={isOpen} />
                      </span>
                    </button>
                  </h3>

                  <div
                    id={panelId}
                    role="region"
                    aria-labelledby={btnId}
                    hidden={!isOpen}
                    data-open={isOpen}
                    className="acctl-panel"
                  >
                    <div className="acctl-inner border-t border-slate-200/70 px-5 py-4 dark:border-slate-800">
                      <p className="text-[11px] font-medium uppercase tracking-wider text-slate-400 dark:text-slate-500">
                        {item.meta}
                      </p>
                      <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
                        {item.body}
                      </p>
                      <ul className="mt-4 space-y-2">
                        {item.points.map((point) => (
                          <li
                            key={point}
                            className="flex items-start gap-2.5 text-sm text-slate-700 dark:text-slate-300"
                          >
                            <span
                              aria-hidden="true"
                              className="mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300"
                            >
                              <CheckDot />
                            </span>
                            <span>{point}</span>
                          </li>
                        ))}
                      </ul>
                    </div>
                  </div>
                </div>
              </li>
            );
          })}
        </ol>
      </div>
    </section>
  );
}

Dependencies

None (React + Tailwind only).

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 →