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
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 with3 * timestamps along an SVG route, a marker that travels the route to the live4 * position when it scrolls into view, and stage cards that expand into an5 * 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 a16 * button with aria-expanded; the route SVG is decorative, its information is17 * in the list. The live stage carries aria-current="step".18 * Reduced motion: the marker is placed at the live position immediately and19 * 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 position91 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-deps124 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.
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.


