1import React from "react";
2
3type TimelineVariant = "default" | "success" | "warning" | "accent";
4
5interface TimelineItem {
6 key: string;
7 title: string;
8 description?: string;
9 date?: string;
10 icon?: React.ReactNode;
11 variant?: TimelineVariant;
12}
13
14interface TimelineProps {
15 items: TimelineItem[];
16 className?: string;
17}
18
19/* Each event is a small raised node with a lit dot, like an indicator
20 lamp; the variant only changes the lamp's colour. Nodes are joined by
21 one continuous groove, and titles sit on the node's centre line. */
22const lamp: Record<TimelineVariant, string> = {
23 default: "#262626",
24 success: "#10b981",
25 warning: "#f59e0b",
26 accent: "#8b5cf6",
27};
28
29const iconText: Record<TimelineVariant, string> = {
30 default: "text-neutral-600",
31 success: "text-emerald-600",
32 warning: "text-amber-600",
33 accent: "text-violet-600",
34};
35
36export function Timeline({ items, className = "" }: TimelineProps) {
37 return (
38 <div
39 className={["rounded-[18px] p-1.5", className].filter(Boolean).join(" ")}
40 style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
41 >
42 <ol
43 className="bg-white rounded-[13px] px-5 py-5 flex flex-col"
44 style={{ boxShadow: "var(--panel-face-shadow)" }}
45 >
46 {items.map((item, index) => {
47 const variant = item.variant ?? "default";
48 const isLast = index === items.length - 1;
49
50 return (
51 <li key={item.key} className={["relative flex gap-4", isLast ? "" : "pb-6"].join(" ")}>
52 {!isLast && (
53 <span
54 aria-hidden
55 className="absolute left-[11px] top-[30px] bottom-[6px] w-[2px] rounded-full"
56 style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
57 />
58 )}
59
60 <span
61 aria-hidden
62 className={[
63 "relative w-6 h-6 rounded-full flex items-center justify-center shrink-0",
64 iconText[variant],
65 ].join(" ")}
66 style={{ background: "var(--face-light)", boxShadow: "var(--tile-light-shadow)" }}
67 >
68 {item.icon ?? (
69 <span
70 className="w-2 h-2 rounded-full"
71 style={{
72 background: lamp[variant],
73 boxShadow: `0 0 6px ${lamp[variant]}66, inset 0 1px 1px rgba(255,255,255,0.35)`,
74 }}
75 />
76 )}
77 </span>
78
79 <div className="flex-1 min-w-0">
80 <div className="flex items-baseline justify-between gap-3">
81 <p className="text-[13.5px] font-medium text-neutral-900 leading-6 tracking-[-0.005em]">
82 {item.title}
83 </p>
84 {item.date && (
85 <time className="text-[12px] text-neutral-400 tabular-nums shrink-0">
86 {item.date}
87 </time>
88 )}
89 </div>
90 {item.description && (
91 <p className="text-[13px] text-neutral-500 leading-relaxed mt-0.5">
92 {item.description}
93 </p>
94 )}
95 </div>
96 </li>
97 );
98 })}
99 </ol>
100 </div>
101 );
102}