Skip to content

Shipment Timeline

Shipment tracking timeline for React: stages on an SVG route, a marker that sails to the live position, expandable logs.

  • Feature Sections
  • Logistics
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers (SVG getPointAtLength, ResizeObserver). Under 860px the route becomes a vertical rail.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/process-timeline.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/features/process-timeline.tsx
1/**
2 * ProcessTimeline, a shipment's journey as a timeline: stages with
3 * timestamps along an SVG route, a marker that travels the route to the live
4 * position when it scrolls into view, and stage cards that expand into an
5 * event log. Horizontal on desktop, a vertical rail on narrow screens.
6 *
7 * Props: reference, origin, destination, eta, stages ({ id, title, place,
8 * time, status: "done" | "live" | "next", leg?, events[] }), progress (0..1,
9 * how far the live leg is done).
10 * CSS vars: --fd-ptl-bg, --fd-ptl-ink, --fd-ptl-signal.
11 *
12 * Browser support: all evergreen browsers (SVGPathElement.getPointAtLength).
13 * Motion: one rAF tween moves the marker along the path with transform only,
14 * started by an IntersectionObserver; it rests when it arrives.
15 * Accessibility: stages are an ordered list; each card's details toggle is a
16 * button with aria-expanded; the route SVG is decorative, its information is
17 * in the list. The live stage carries aria-current="step".
18 * Reduced motion: the marker is placed at the live position immediately and
19 * does not bob.
20 */
21import { useEffect, useId, useRef, useState } from "react";
22import type { ReactNode } from "react";
23
24export interface PtlStage {
25 id: string;
26 title: string;
27 place: string;
28 time: string;
29 status: "done" | "live" | "next";
30 leg?: string;
31 events: { t: string; text: string }[];
32}
33
34export interface ProcessTimelineProps {
35 reference?: string;
36 origin?: string;
37 destination?: string;
38 eta?: ReactNode;
39 stages?: PtlStage[];
40 progress?: number;
41 className?: string;
42}
43
44const DEFAULT_STAGES: PtlStage[] = [
45 { id: "pickup", title: "Collected", place: "Castellón de la Plana, ES", time: "Mon 3 Nov, 07:40", status: "done", leg: "Truck · 74 km", events: [{ t: "07:40", text: "22 pallets loaded, 14,380 kg" }, { t: "07:52", text: "Seal 448120 applied, photo on file" }, { t: "09:05", text: "Gate in, Valencia Principe Felipe" }] },
46 { id: "valencia", title: "Port of Valencia", place: "Export cleared, loaded", time: "Mon 3 Nov, 16:15", status: "done", leg: "MV Nordkapp · 1,940 km sailed", events: [{ t: "11:30", text: "Export declaration MRN 25ES00…7A released" }, { t: "16:15", text: "Container HLDU 204931 loaded, bay 22" }, { t: "22:00", text: "Vessel departed, 2 h late on berth" }] },
47 { id: "sea", title: "At sea", place: "Bay of Biscay, 11.4 kn", time: "Wed 5 Nov, 09:20", status: "live", leg: "1,260 km to go", events: [{ t: "09:20", text: "Position 45.12 N, 6.80 W" }, { t: "06:00", text: "Swell 2.1 m, no delay expected" }, { t: "Tue", text: "Passed Finisterre, back on schedule" }] },
48 { id: "rotterdam", title: "Rotterdam", place: "Maasvlakte, onto rail", time: "Thu 6 Nov, 06:00", status: "next", leg: "Rail + ferry · 1,180 km", events: [{ t: "ETA", text: "Berth at ECT Delta, 06:00" }, { t: "ETA", text: "Rail slot booked, 11:40 to Göteborg" }] },
49 { id: "gothenburg", title: "Delivered", place: "Nordisk Kakel AB, Göteborg", time: "Fri 7 Nov, 14:00–16:00", status: "next", events: [{ t: "ETA", text: "Tail-lift truck, dock 4" }, { t: "Note", text: "Call Linnea 30 min before arrival" }] },
50];
51
52const YS = [86, 52, 100, 60, 90];
53
54function routePath(n: [number, number][]) {
55 let d = `M${n[0][0]} ${n[0][1]}`;
56 for (let i = 1; i < n.length; i++) {
57 const [x0, y0] = n[i - 1], [x1, y1] = n[i];
58 const dx = (x1 - x0) * 0.45;
59 d += ` C${x0 + dx} ${y0}, ${x1 - dx} ${y1}, ${x1} ${y1}`;
60 }
61 return d;
62}
63
64export function ProcessTimeline({
65 reference = "HLD-20931",
66 origin = "Castellón",
67 destination = "Göteborg",
68 eta = <>Fri 7 Nov<br />14:00–16:00</>,
69 stages = DEFAULT_STAGES,
70 progress = 0.18,
71 className,
72}: ProcessTimelineProps) {
73 const [open, setOpen] = useState<string | null>(null);
74 const [arrived, setArrived] = useState(false);
75 const pathRef = useRef<SVGPathElement>(null);
76 const markRef = useRef<HTMLSpanElement>(null);
77 const doneRef = useRef<SVGPathElement>(null);
78 const root = useRef<HTMLElement>(null);
79 const uid = useId();
80 const nodes: [number, number][] = stages.map((_, i) => [Math.round(((i + 0.5) / stages.length) * 1010), YS[i % YS.length]]);
81 const d = routePath(nodes);
82 const live = Math.max(0, stages.findIndex((s) => s.status === "live"));
83 const doneCount = stages.filter((s) => s.status === "done").length;
84 const pct = Math.round(((doneCount + progress) / Math.max(1, stages.length - 1)) * 100);
85
86 useEffect(() => {
87 const p = pathRef.current, m = markRef.current, dn = doneRef.current, el = root.current;
88 if (!p || !m || !dn || !el) return;
89 const total = p.getTotalLength();
90 // find the length on the path whose x matches the live position
91 const tx = nodes[live][0] + ((nodes[Math.min(live + 1, nodes.length - 1)][0] - nodes[live][0]) * progress);
92 let lo = 0, hi = total;
93 for (let i = 0; i < 24; i++) { const mid = (lo + hi) / 2; if (p.getPointAtLength(mid).x < tx) lo = mid; else hi = mid; }
94 const target = lo;
95 const box = m.parentElement!;
96 let last = 0;
97 const place = (len: number) => {
98 last = len;
99 const pt = p.getPointAtLength(len);
100 const W = box.clientWidth, H = box.clientHeight;
101 m.style.transform = `translate(${(pt.x / 1010) * W}px, ${(pt.y / 150) * H}px)`;
102 dn.style.strokeDasharray = `${len} ${total}`;
103 };
104 const ro = new ResizeObserver(() => place(last));
105 ro.observe(box);
106 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { place(target); setArrived(true); return () => ro.disconnect(); }
107 place(0);
108 let raf = 0;
109 const io = new IntersectionObserver(([e]) => {
110 if (!e.isIntersecting) return;
111 io.disconnect();
112 const t0 = performance.now(), dur = 2400;
113 const step = (t: number) => {
114 const k = Math.min(1, (t - t0) / dur);
115 const ease = 1 - Math.pow(1 - k, 4);
116 place(target * ease);
117 if (k < 1) raf = requestAnimationFrame(step); else setArrived(true);
118 };
119 raf = requestAnimationFrame(step);
120 }, { threshold: 0.4 });
121 io.observe(el);
122 return () => { io.disconnect(); ro.disconnect(); cancelAnimationFrame(raf); };
123 }, [d, live, progress]); // eslint-disable-line react-hooks/exhaustive-deps
124
125 return (
126 <section ref={root} className={`fd-ptl${className ? " " + className : ""}`} aria-labelledby={`${uid}-h`} style={{ ["--fd-ptl-cols" as string]: stages.length }}>
127 <style>{css}</style>
128 <header className="fd-ptl-head">
129 <div>
130 <p className="fd-ptl-ref"><span className="fd-ptl-live" aria-hidden="true" />Shipment {reference} · FCL 40′ · Tiles, porcelain</p>
131 <h2 id={`${uid}-h`} className="fd-ptl-title">{origin}<span aria-label=" to "> → </span>{destination}</h2>
132 </div>
133 <dl className="fd-ptl-stats">
134 <div><dt>Status</dt><dd>On time</dd></div>
135 <div><dt>Journey</dt><dd className="fd-ptl-mono">{pct}%</dd></div>
136 <div><dt>ETA</dt><dd>{eta}</dd></div>
137 </dl>
138 </header>
139
140 <div className="fd-ptl-map" aria-hidden="true">
141 <svg viewBox="0 0 1010 150" preserveAspectRatio="none">
142 <path d={d} className="fd-ptl-road" vectorEffect="non-scaling-stroke" />
143 <path ref={doneRef} d={d} className="fd-ptl-done" />
144 <path ref={pathRef} d={d} fill="none" stroke="none" />
145 </svg>
146 {nodes.map(([x, y], i) => i < nodes.length - 1 && stages[i].leg ? (
147 <span key={i} className="fd-ptl-leg" style={{ left: `${((x + nodes[i + 1][0]) / 2 / 1010) * 100}%`, top: `${((Math.max(y, nodes[i + 1][1]) + 26) / 150) * 100}%` }}>{stages[i].leg}</span>
148 ) : null)}
149 {nodes.map(([x, y], i) => (
150 <span key={i} className="fd-ptl-node" data-s={stages[i].status} style={{ left: `${(x / 1010) * 100}%`, top: `${(y / 150) * 100}%` }} />
151 ))}
152 <span ref={markRef} className="fd-ptl-marker" data-arrived={arrived ? "" : undefined}>
153 <span className="fd-ptl-bob">
154 <svg viewBox="-24 -24 48 48" width="48" height="48">
155 <circle r="22" className="fd-ptl-halo" />
156 <circle r="15" className="fd-ptl-dot" />
157 <path d="M-8 1 L8 1 L5.5 6 L-5.5 6 Z M-3 1 V-6 H3 V1" className="fd-ptl-ship" />
158 </svg>
159 </span>
160 </span>
161 </div>
162
163 <ol className="fd-ptl-stages">
164 {stages.map((s, i) => {
165 const isOpen = open === s.id;
166 return (
167 <li key={s.id} className="fd-ptl-stage" data-s={s.status} data-open={isOpen ? "" : undefined} aria-current={s.status === "live" ? "step" : undefined}>
168 <span className="fd-ptl-rail" aria-hidden="true"><i /></span>
169 <p className="fd-ptl-n"><span>{String(i + 1).padStart(2, "0")}</span>{s.status === "done" ? "Done" : s.status === "live" ? "Now" : "Planned"}</p>
170 <h3 className="fd-ptl-st">{s.title}</h3>
171 <p className="fd-ptl-place">{s.place}</p>
172 <p className="fd-ptl-time">{s.time}</p>
173 {s.leg && <p className="fd-ptl-mleg">{s.leg}</p>}
174 <button type="button" className="fd-ptl-more" aria-expanded={isOpen} aria-controls={`${uid}-e-${i}`} onClick={() => setOpen(isOpen ? null : s.id)}>
175 {isOpen ? "Hide log" : `Log, ${s.events.length} events`}
176 <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.5 5 6.5 8 3.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
177 </button>
178 <div className="fd-ptl-log" id={`${uid}-e-${i}`}>
179 <ul>
180 {s.events.map((e, k) => <li key={k}><span>{e.t}</span>{e.text}</li>)}
181 </ul>
182 </div>
183 </li>
184 );
185 })}
186 </ol>
187 </section>
188 );
189}
190
191const css = `
192:where(.fd-ptl,.fd-ptl *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none;border:0;background:none}
193:where(.fd-ptl,.fd-ptl *)::before,:where(.fd-ptl,.fd-ptl *)::after{box-sizing:border-box}
194:where(.fd-ptl svg){display:block}
195:where(.fd-ptl button){appearance:none;-webkit-appearance:none;outline:0;border-radius:0;cursor:pointer;text-align:left}
196.fd-ptl{
197 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-ease-in-out:cubic-bezier(.65,0,.35,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);
198 --fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;
199 --fd-ptl-bg:#ECEEEA;--fd-ptl-ink:#0F2E33;--fd-ptl-mut:#5F7276;--fd-ptl-line:rgba(15,46,51,.16);--fd-ptl-signal:#F4C21B;--fd-ptl-card:#F6F7F4;
200 --fd-ptl-sans:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;
201 --fd-ptl-mono-f:ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;
202 display:block;width:100%;background:var(--fd-ptl-bg);color:var(--fd-ptl-ink);padding:clamp(48px,7vw,104px) clamp(16px,4vw,56px);
203 font:400 14px/1.5 var(--fd-ptl-sans);-webkit-font-smoothing:antialiased}
204.fd-ptl-mono{font-family:var(--fd-ptl-mono-f);font-variant-numeric:tabular-nums}
205.fd-ptl-head{max-width:1280px;margin:0 auto 40px;display:flex;justify-content:space-between;align-items:flex-end;gap:32px;flex-wrap:wrap;background:var(--fd-ptl-bg);padding:8px 0}
206.fd-ptl-ref{display:flex;align-items:center;gap:10px;font:500 12px/16px var(--fd-ptl-mono-f);letter-spacing:.02em;color:var(--fd-ptl-mut)}
207.fd-ptl-live{position:relative;width:8px;height:8px;border-radius:50%;background:#2E9E5B}
208.fd-ptl-live::after{content:"";position:absolute;inset:-4px;border-radius:50%;border:1.5px solid #2E9E5B;animation:fd-ptl-ping 2s var(--fd-ease-out) infinite}
209@keyframes fd-ptl-ping{from{opacity:.8;transform:scale(.6)}to{opacity:0;transform:scale(1.6)}}
210.fd-ptl-title{margin-top:12px;font:800 clamp(2.5rem,6.4vw,5.75rem)/.92 var(--fd-ptl-sans);letter-spacing:-.05em;text-transform:uppercase}
211.fd-ptl-title span{font-weight:300;color:var(--fd-ptl-mut)}
212.fd-ptl-stats{margin-left:auto;display:flex;gap:0;border:1px solid var(--fd-ptl-ink);background:var(--fd-ptl-card)}
213.fd-ptl-stats div{padding:12px 18px;border-left:1px solid var(--fd-ptl-line);min-width:110px}
214.fd-ptl-stats div:first-child{border-left:0}
215.fd-ptl-stats dt{font:500 10px/14px var(--fd-ptl-mono-f);letter-spacing:.12em;text-transform:uppercase;color:var(--fd-ptl-mut)}
216.fd-ptl-stats dd{margin-top:4px;font:700 16px/1.2 var(--fd-ptl-sans);letter-spacing:-.01em}
217.fd-ptl-stats div:first-child dd{color:#1E7A45}
218.fd-ptl-map{position:relative;max-width:1280px;margin:0 auto;height:150px}
219.fd-ptl-map>svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
220.fd-ptl-road{fill:none;stroke:var(--fd-ptl-ink);stroke-width:2;stroke-dasharray:2 7;stroke-linecap:round;opacity:.5}
221.fd-ptl-done{fill:none;stroke:var(--fd-ptl-ink);stroke-width:3.5;stroke-linecap:round;stroke-dasharray:0 99999}
222.fd-ptl-leg{position:absolute;transform:translateX(-50%);white-space:nowrap;font:500 11px/14px var(--fd-ptl-mono-f);color:var(--fd-ptl-mut);letter-spacing:.02em;background:var(--fd-ptl-bg);padding:0 4px}
223.fd-ptl-node{position:absolute;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;background:var(--fd-ptl-bg);box-shadow:inset 0 0 0 2px var(--fd-ptl-ink)}
224.fd-ptl-node[data-s="done"]{background:var(--fd-ptl-ink)}
225.fd-ptl-node[data-s="live"]{box-shadow:inset 0 0 0 2px var(--fd-ptl-ink),0 0 0 4px var(--fd-ptl-bg)}
226.fd-ptl-marker{position:absolute;left:-24px;top:-24px;width:48px;height:48px;pointer-events:none;will-change:transform}
227.fd-ptl-bob{display:block;width:48px;height:48px}
228.fd-ptl-marker svg{position:static!important;width:48px!important;height:48px!important;overflow:visible}
229.fd-ptl-halo{transform-box:fill-box;transform-origin:center}
230.fd-ptl-halo{fill:var(--fd-ptl-signal);opacity:.25}
231.fd-ptl-dot{fill:var(--fd-ptl-signal);stroke:var(--fd-ptl-ink);stroke-width:1.5}
232.fd-ptl-ship{fill:var(--fd-ptl-ink)}
233.fd-ptl-marker[data-arrived] .fd-ptl-bob{animation:fd-ptl-bob 2.6s var(--fd-ease-in-out) infinite}
234.fd-ptl-marker[data-arrived] .fd-ptl-halo{animation:fd-ptl-halo 2.6s var(--fd-ease-out) infinite}
235@keyframes fd-ptl-bob{0%,100%{transform:translateY(0) rotate(-3deg)}50%{transform:translateY(-3px) rotate(3deg)}}
236@keyframes fd-ptl-halo{0%{opacity:.4;transform:scale(.7)}100%{opacity:0;transform:scale(1.5)}}
237.fd-ptl-stages{max-width:1280px;margin:28px auto 0;display:grid;grid-template-columns:repeat(var(--fd-ptl-cols),minmax(0,1fr));border-top:1px solid var(--fd-ptl-ink)}
238.fd-ptl-stage{position:relative;padding:18px 20px 20px;border-left:1px solid var(--fd-ptl-line);background:var(--fd-ptl-card);transition:background-color var(--fd-dur-2)}
239.fd-ptl-stage:first-child{border-left:0}
240.fd-ptl-stage[data-s="live"]{background:#fff;box-shadow:inset 0 3px 0 var(--fd-ptl-signal)}
241.fd-ptl-stage[data-s="next"] .fd-ptl-st,.fd-ptl-stage[data-s="next"] .fd-ptl-time{color:var(--fd-ptl-mut)}
242.fd-ptl-rail{display:none}
243.fd-ptl-n{display:flex;align-items:center;gap:8px;font:500 11px/14px var(--fd-ptl-mono-f);letter-spacing:.1em;text-transform:uppercase;color:var(--fd-ptl-mut)}
244.fd-ptl-n span{padding:2px 5px;border-radius:3px;background:var(--fd-ptl-ink);color:var(--fd-ptl-bg);letter-spacing:0}
245.fd-ptl-stage[data-s="next"] .fd-ptl-n span{background:none;color:var(--fd-ptl-mut);box-shadow:inset 0 0 0 1px var(--fd-ptl-line)}
246.fd-ptl-stage[data-s="live"] .fd-ptl-n{color:var(--fd-ptl-ink)}
247.fd-ptl-stage[data-s="live"] .fd-ptl-n span{background:var(--fd-ptl-signal);color:var(--fd-ptl-ink)}
248.fd-ptl-st{margin-top:14px;font:700 19px/1.2 var(--fd-ptl-sans);letter-spacing:-.02em}
249.fd-ptl-place{margin-top:2px;font:400 13px/1.4 var(--fd-ptl-sans);color:var(--fd-ptl-mut)}
250.fd-ptl-time{margin-top:14px;font:500 13px/1.3 var(--fd-ptl-mono-f);font-variant-numeric:tabular-nums;letter-spacing:-.01em}
251.fd-ptl-mleg{display:none}
252.fd-ptl-more{display:inline-flex;align-items:center;gap:6px;margin-top:14px;font:600 12px/16px var(--fd-ptl-sans);color:var(--fd-ptl-ink);padding:4px 0;border-bottom:1px solid var(--fd-ptl-line);transition:border-color var(--fd-dur-1)}
253.fd-ptl-more:hover{border-color:var(--fd-ptl-ink)}
254.fd-ptl-more:focus-visible{outline:2px solid rgba(15,46,51,.7);outline-offset:2px;border-radius:2px}
255.fd-ptl-more svg{transition:transform var(--fd-dur-3) var(--fd-ease-in-out)}
256.fd-ptl-stage[data-open] .fd-ptl-more svg{transform:rotate(180deg)}
257.fd-ptl-log{display:grid;grid-template-rows:0fr;transition:grid-template-rows var(--fd-dur-3) var(--fd-ease-in-out)}
258.fd-ptl-stage[data-open] .fd-ptl-log{grid-template-rows:1fr}
259.fd-ptl-log ul{min-height:0;overflow:hidden}
260.fd-ptl-log li{display:grid;grid-template-columns:44px minmax(0,1fr);gap:8px;padding:8px 0 0;font:400 12.5px/1.4 var(--fd-ptl-sans);color:var(--fd-ptl-ink)}
261.fd-ptl-log li:first-child{margin-top:10px}
262.fd-ptl-log li span{font:500 11px/1.6 var(--fd-ptl-mono-f);color:var(--fd-ptl-mut)}
263@media (max-width:860px){
264 .fd-ptl-map{display:none}
265 .fd-ptl-head{margin-bottom:28px}
266 .fd-ptl-stats{width:100%}
267 .fd-ptl-stats div{flex:1;min-width:0;padding:10px 12px}
268 .fd-ptl-stats dd{font-size:14px}
269 .fd-ptl-stages{grid-template-columns:minmax(0,1fr);border-top:0;margin-top:0;background:none}
270 .fd-ptl-stage{border-left:0;padding:0 0 28px 52px;background:none;box-shadow:none!important}
271 .fd-ptl-stage[data-s="live"]{background:none}
272 .fd-ptl-rail{display:block;position:absolute;left:16px;top:0;bottom:0;width:4px}
273 .fd-ptl-rail::before{content:"";position:absolute;left:1px;top:20px;bottom:-2px;width:2px;background:repeating-linear-gradient(var(--fd-ptl-ink) 0 2px,transparent 2px 9px);opacity:.5}
274 .fd-ptl-stage[data-s="done"] .fd-ptl-rail::before{background:var(--fd-ptl-ink);opacity:1;width:4px;left:0}
275 .fd-ptl-stage:last-child .fd-ptl-rail::before{display:none}
276 .fd-ptl-rail i{position:absolute;left:-5px;top:2px;width:14px;height:14px;border-radius:50%;background:var(--fd-ptl-bg);box-shadow:inset 0 0 0 2px var(--fd-ptl-ink)}
277 .fd-ptl-stage[data-s="done"] .fd-ptl-rail i{background:var(--fd-ptl-ink)}
278 .fd-ptl-stage[data-s="live"] .fd-ptl-rail::after{content:"";position:absolute;left:-11px;top:46%;width:26px;height:26px;border-radius:50%;background:var(--fd-ptl-signal);box-shadow:0 0 0 1.5px var(--fd-ptl-ink),0 0 0 8px rgba(244,194,27,.25)}
279 .fd-ptl-stage[data-s="live"] .fd-ptl-rail{background:linear-gradient(var(--fd-ptl-ink) 0 46%,transparent 46%)}
280 .fd-ptl-st{margin-top:8px}
281 .fd-ptl-time{margin-top:8px}
282 .fd-ptl-mleg{display:block;margin-top:14px;font:500 11px/14px var(--fd-ptl-mono-f);color:var(--fd-ptl-mut)}
283 .fd-ptl-mleg::before{content:"↓ "}
284}
285@media (prefers-reduced-motion:reduce){
286 .fd-ptl *,.fd-ptl *::after{transition-duration:1ms!important;animation:none!important}
287}
288`;
289
290export function Demo() {
291 return (
292 <div className="fd-ptld">
293 <style>{`.fd-ptld{background:#ECEEEA;min-height:100vh}.fd-ptld-nav{box-sizing:border-box;max-width:1392px;margin:0 auto;height:60px;padding:0 clamp(16px,4vw,56px);display:flex;justify-content:space-between;align-items:center;font:500 13px/18px ui-sans-serif,-apple-system,BlinkMacSystemFont,"Inter",sans-serif;color:#5F7276;border-bottom:1px solid #0F2E33}.fd-ptld-nav b{display:flex;align-items:center;gap:8px;font:900 17px/1 ui-sans-serif,-apple-system,BlinkMacSystemFont,"Inter",sans-serif;letter-spacing:.02em;color:#0F2E33}.fd-ptld-nav b i{display:block;width:14px;height:14px;background:repeating-linear-gradient(-45deg,#0F2E33 0 3px,#F4C21B 3px 6px)}`}</style>
294 <div className="fd-ptld-nav"><b><i />HALDEN FREIGHT</b><span>Track · Quote · Account</span></div>
295 <ProcessTimeline />
296 </div>
297 );
298}

More Feature Sections. Same file contract.

Live UI Bento, features preview: Bento grid features section for React where every tile works: freeze a card, convert FX, clear approvals,…

Live UI Bento

Bento grid features section for React where every tile works: freeze a card, convert FX, clear approvals, scrub a chart.

Sticky Feature Tour, features preview: Scroll-driven feature tour with a pinned window that crossfades terminal, diff and query UIs, steps on a…

Sticky Feature Tour

Scroll-driven feature tour with a pinned window that crossfades terminal, diff and query UIs, steps on a progress rail.

Autoplay Feature Tabs, features preview: Autoplay feature tabs for React: tabs advance on a filling progress bar, pause on hover, and show a phone…

Autoplay Feature Tabs

Autoplay feature tabs for React: tabs advance on a filling progress bar, pause on hover, and show a phone screen built in code.

Questions about Shipment Timeline

Run npx shadcn@latest add https://freedesignmd.com/r/process-timeline.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.

Shipment tracking timeline for React: stages on an SVG route, a marker that sails to the live position, expandable logs. It is a free, MIT-licensed React feature sections component from the freedesignmd library.

Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.

All evergreen browsers (SVG getPointAtLength, ResizeObserver). Under 860px the route becomes a vertical rail.

Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.