Website Sections
ProcessTrain
A how-it-works section told as a railway line at dusk. Each step is a station with a yellow name board. The train runs from one to the next as the land slides past on three layers, a coach lights up at each stop, and pressing any stop sends it there, backwards if needed.
Download
- React
- Tailwind CSS v4
- motion
components/ui/process-train.tsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useInView, useReducedMotion } from "motion/react";
type TrainStep = { title: string; text: string; time?: string };
type ProcessTrainProps = {
/** One station each, in order. */
steps?: TrainStep[];
/** Ms the train waits at a station before it runs on by itself. */
hold?: number;
/** Called when the train stops at a station. */
onArrive?: (index: number) => void;
/** Set the width here. Everything scales with it. */
className?: string;
};
const STEPS: TrainStep[] = [
{ title: "Enquiry", text: "Tell us what you need. We reply the same day with a plain price.", time: "Day 1" },
{ title: "Plan", text: "We map the work on one page, so you know each stop before we leave.", time: "Day 2" },
{ title: "Build", text: "Your first version comes in a week. You check it, we fix it.", time: "Week 1" },
{ title: "Launch", text: "We go live together and stay on the line for the first month.", time: "Week 2" },
];
const W = 640;
const H = 232;
const RAIL = 192;
const GAP = 560; // track between two stations
const ENGINE = 372; // back of the engine on screen
const START = -470; // the train rolls in from here
const COACH = 104;
const PUFFS = 12;
const BRAKE = 95;
const ROOF = (i: number) => (i * 37 + 11) % 90;
function hills(from: number, to: number, base: number, lift: number, seed: number) {
let d = `M${from} ${H}`;
for (let x = from; x <= to; x += 24) {
const y = base - (Math.sin(x * 0.009 + seed) * lift + Math.sin(x * 0.023 + seed * 2) * lift * 0.45);
d += ` L${x} ${y.toFixed(1)}`;
}
return `${d} L${to} ${H} Z`;
}
function Wheel({ x, r, spokes = 6 }: { x: number; r: number; spokes?: number }) {
return (
<g transform={`translate(${x} ${-r})`}>
<g data-wheel="">
<circle r={r} fill="#17181d" />
<circle r={r - 1.8} fill="#a12a24" />
{Array.from({ length: spokes }, (_, i) => (
<rect key={i} x={-0.8} y={-(r - 2)} width={1.6} height={(r - 2) * 2} fill="#17181d" transform={`rotate(${(i * 180) / spokes})`} />
))}
<circle r={2.4} fill="#e8c56c" />
</g>
</g>
);
}
function Coach({ x, lit, label }: { x: number; lit: boolean; label: number }) {
const glow = lit ? "#ffd98a" : "#26395f";
return (
<g transform={`translate(${x} 0)`}>
<rect x="2" y="-70" width="92" height="10" rx="5" fill="#1a2a4c" />
<rect x="0" y="-62" width="96" height="42" rx="3" fill="#2a4a8c" />
<rect x="0" y="-45" width="96" height="4" fill="#ecdfc0" />
{[8, 24, 56, 72].map((wx) => (
<rect key={wx} x={wx} y="-57" width="12" height="11" rx="1.5" fill={glow} style={{ transition: "fill 500ms" }} />
))}
<rect x="41" y="-59" width="14" height="37" rx="1.5" fill="#1a2a4c" />
<text x="48" y="-29" textAnchor="middle" fontSize="11" fontWeight="700" fill={lit ? "#ffd98a" : "#7f8fb5"} style={{ transition: "fill 500ms" }}>{label}</text>
<rect x="0" y="-20" width="96" height="3" fill="#17181d" />
<rect x="96" y="-27" width="9" height="3" fill="#17181d" />
<Wheel x={18} r={9} spokes={4} />
<Wheel x={78} r={9} spokes={4} />
</g>
);
}
function Station({ index, title, passed }: { index: number; title: string; passed: boolean }) {
const wide = Math.max(92, title.length * 10.5 + 40);
return (
<g transform={`translate(${index * GAP} 0)`}>
{/* Platform. */}
<rect x="170" y={RAIL - 26} width="330" height="26" fill="#c9bfae" />
<rect x="170" y={RAIL - 26} width="330" height="4" fill="#f4efe2" />
{Array.from({ length: 11 }, (_, i) => (
<rect key={i} x={170 + i * 30} y={RAIL - 22} width="15" height="22" fill="#b7ac99" opacity="0.5" />
))}
{/* Shelter. */}
<path d="M188 70 L392 70 L378 54 L202 54 Z" fill="#7b2f28" />
<rect x="188" y="70" width="204" height="4" fill="#efe3c6" />
<rect x="208" y="74" width="5" height="92" fill="#3d4b57" />
<rect x="367" y="74" width="5" height="92" fill="#3d4b57" />
{/* Board. */}
<g transform={`translate(${290 - wide / 2} 84)`}>
<rect x="14" y="-10" width="2.4" height="10" fill="#3d4b57" />
<rect x={wide - 16} y="-10" width="2.4" height="10" fill="#3d4b57" />
<rect width={wide} height="27" rx="1.5" fill="#f7cb1a" stroke="#17181d" strokeWidth="2" />
<text x={wide / 2} y="19" textAnchor="middle" fontSize="16" fontWeight="800" letterSpacing="0.8" fill="#17181d">{title.toUpperCase()}</text>
</g>
{/* Signal past the end of the platform. */}
<rect x="514" y="118" width="4" height="74" fill="#3d4b57" />
<rect x="507" y="104" width="18" height="30" rx="4" fill="#17181d" />
<circle cx="516" cy="112" r="4.2" fill={passed ? "#4a1612" : "#ff5040"} style={{ transition: "fill 300ms" }} />
<circle cx="516" cy="125" r="4.2" fill={passed ? "#49f08e" : "#123322"} style={{ transition: "fill 300ms" }} />
</g>
);
}
/* ProcessTrain: a how-it-works section told as a railway line at dusk. Each step is a station with a yellow name board. The train runs from one to the next as the land slides past on three layers, a coach lights up at each stop, and pressing any stop on the route sends it there, backwards if needed. */
export function ProcessTrain({ steps = STEPS, hold = 3800, onArrive, className = "w-full max-w-3xl" }: ProcessTrainProps) {
const reduce = useReducedMotion();
const id = useId().replace(/[^a-zA-Z0-9]/g, "");
const box = useRef<HTMLDivElement>(null);
const scene = useRef<SVGSVGElement>(null);
const seen = useInView(box, { once: true, amount: 0.35 });
const n = steps.length;
const end = n * GAP + 700;
const [at, setAt] = useState(0); // where the train is heading
const [here, setHere] = useState(-1); // where it stands, -1 while moving
const [best, setBest] = useState(-1); // the furthest stop so far
const [auto, setAuto] = useState(true);
const [paused, setPaused] = useState(false);
const cam = useRef(START);
const vel = useRef(0);
const puffs = useRef(Array.from({ length: PUFFS }, () => ({ x: 0, y: 0, age: 9, life: 1 })));
const told = useRef(onArrive);
useEffect(() => {
told.current = onArrive;
}, [onArrive]);
useEffect(() => {
if (!seen) return;
const svg = scene.current;
if (!svg) return;
const world = svg.querySelector("[data-layer='world']");
const mid = svg.querySelector("[data-layer='mid']");
const far = svg.querySelector("[data-layer='far']");
const rod = svg.querySelector("[data-rod]");
const wheels = Array.from(svg.querySelectorAll("[data-wheel]"));
const clouds = Array.from(svg.querySelectorAll("[data-puff]"));
const target = at * GAP;
let raf = 0;
let last = performance.now();
let chuff = 0;
let stopped = false;
// A far stop takes about as long as a near one: it just brakes harder.
const brake = BRAKE * Math.max(1, Math.abs(target - cam.current) / GAP);
const tick = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
if (reduce) {
cam.current = target;
vel.current = 0;
} else {
const d = target - cam.current;
// Brakes at a steady rate, so it stops in a clean line, not by creeping in.
const top = Math.max(230, Math.abs(d) / 1.2);
const want = Math.sign(d) * Math.min(top, Math.sqrt(2 * brake * Math.abs(d)));
const push = brake * 2.4 * dt;
vel.current += Math.max(-push, Math.min(push, want - vel.current));
cam.current += vel.current * dt;
if (Math.abs(d) < 1.2) {
cam.current = target;
vel.current = 0;
}
}
const x = cam.current;
world?.setAttribute("transform", `translate(${-x} 0)`);
mid?.setAttribute("transform", `translate(${-x * 0.45} 0)`);
far?.setAttribute("transform", `translate(${-x * 0.14} 0)`);
const turn = x / 11;
for (const w of wheels) w.setAttribute("transform", `rotate(${((turn * 180) / Math.PI).toFixed(1)})`);
rod?.setAttribute("transform", `translate(${(Math.cos(turn) * 5).toFixed(2)} ${(Math.sin(turn) * 5).toFixed(2)})`);
// Smoke leaves the chimney and is left behind by the speed of the train.
const speed = Math.abs(vel.current);
chuff -= dt * (1 + speed / 70);
if (chuff <= 0 && speed > 5 && !reduce) {
chuff = 0.3;
const free = puffs.current.find((p) => p.age >= p.life);
if (free) {
free.x = ENGINE + 94;
free.y = RAIL - 72;
free.age = 0;
free.life = 1.1 + Math.random() * 0.7;
}
}
let alive = false;
puffs.current.forEach((p, i) => {
const c = clouds[i];
if (!c) return;
if (p.age >= p.life) {
c.setAttribute("opacity", "0");
return;
}
alive = true;
p.age += dt;
p.x -= (vel.current * 0.75 + 9) * dt;
p.y -= (26 - p.age * 10) * dt;
const t = p.age / p.life;
c.setAttribute("cx", p.x.toFixed(1));
c.setAttribute("cy", p.y.toFixed(1));
c.setAttribute("r", (4 + t * 15).toFixed(1));
c.setAttribute("opacity", ((1 - t) * 0.62).toFixed(2));
});
const still = x === target;
if (still && !stopped) {
stopped = true;
setHere(at);
setBest((b) => Math.max(b, at));
told.current?.(at);
}
if (!still || alive) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [seen, at, reduce]);
useEffect(() => {
if (!auto || reduce || paused || here !== at || at >= n - 1) return;
const t = setTimeout(() => {
setHere(-1);
setAt((a) => a + 1);
}, hold);
return () => clearTimeout(t);
}, [auto, reduce, paused, here, at, n, hold]);
const go = (i: number) => {
setAuto(false);
if (i === at) return;
setHere(-1);
setAt(i);
};
const step = steps[at];
return (
<div
ref={box}
className={`@container ${className}`}
onPointerEnter={() => setPaused(true)}
onPointerLeave={() => setPaused(false)}
onFocusCapture={() => setPaused(true)}
onBlurCapture={() => setPaused(false)}
>
<div className="overflow-hidden rounded-[2cqw]" style={{ fontSize: "clamp(11.5px, 3.3cqw, 18px)", boxShadow: "0 1.6cqw 4cqw #0008" }}>
<svg ref={scene} viewBox={`0 0 ${W} ${H}`} className="block w-full" style={{ fontFamily: "system-ui, sans-serif" }} aria-hidden>
<defs>
<linearGradient id={`${id}sky`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#3b2a6b" />
<stop offset="0.45" stopColor="#c8506b" />
<stop offset="0.8" stopColor="#f79f5a" />
</linearGradient>
<pattern id={`${id}ties`} x="0" y={RAIL} width="18" height="10" patternUnits="userSpaceOnUse">
<rect x="3" y="1" width="9" height="7" fill="#4b3324" />
</pattern>
</defs>
<rect width={W} height={H} fill={`url(#${id}sky)`} />
<circle cx="500" cy="118" r="44" fill="#ffd27a" />
<circle cx="500" cy="118" r="62" fill="#ffd27a" opacity="0.22" />
<g data-layer="far" transform={`translate(${-START * 0.14} 0)`}>
<path d={hills(-900, end, 136, 22, 1.2)} fill="#7b3a70" />
</g>
<g data-layer="mid" transform={`translate(${-START * 0.45} 0)`}>
<path d={hills(-900, end, 166, 11, 3.1)} fill="#44295c" />
{Array.from({ length: Math.ceil((end + 900) / 170) }, (_, i) => {
const x = -900 + i * 170 + ROOF(i);
const tall = 34 + (i % 3) * 9;
return (
<g key={i} transform={`translate(${x} ${172})`} fill="#2a1a3f" stroke="#2a1a3f">
<path d={`M0 0 Q 3 ${-tall / 2} 7 ${-tall}`} fill="none" strokeWidth="2.6" />
<g transform={`translate(7 ${-tall})`} strokeWidth="0">
<path d="M0 0 Q -14 -9 -24 3 Q -12 -3 0 0" />
<path d="M0 0 Q 13 -10 24 2 Q 12 -3 0 0" />
<path d="M0 0 Q -7 -15 -18 -14 Q -7 -8 0 0" />
<path d="M0 0 Q 7 -15 18 -15 Q 7 -8 0 0" />
</g>
</g>
);
})}
</g>
<g data-layer="world" transform={`translate(${-START} 0)`}>
<rect x="-900" y={RAIL - 12} width={end + 1800} height="60" fill="#2d2130" />
{steps.map((s, i) => (
<Station key={s.title} index={i} title={s.title} passed={i <= best && i < at} />
))}
<rect x="-900" y={RAIL + 1} width={end + 1800} height="10" fill={`url(#${id}ties)`} />
<rect x="-900" y={RAIL} width={end + 1800} height="2.6" fill="#c3c6cc" />
</g>
<g transform={`translate(${ENGINE} ${RAIL})`}>
{/* Engine. Its nose points right. */}
<rect x="-3" y="-20" width="116" height="4" fill="#a12a24" />
<rect x="0" y="-64" width="36" height="44" rx="2" fill="#1f3a70" />
<rect x="-4" y="-69" width="44" height="6" rx="2.5" fill="#17181d" />
<rect x="9" y="-56" width="18" height="16" rx="1.5" fill="#ffd98a" />
<rect x="36" y="-53" width="62" height="33" rx="9" fill="#2a4a8c" />
{[46, 62, 78].map((bx) => (
<rect key={bx} x={bx} y="-53" width="2.6" height="33" fill="#ecdfc0" opacity="0.75" />
))}
<rect x="96" y="-55" width="12" height="37" rx="3" fill="#17181d" />
<rect x="88" y="-71" width="11" height="18" fill="#17181d" />
<rect x="85.5" y="-74" width="16" height="4.5" rx="1.5" fill="#17181d" />
<circle cx="63" cy="-55" r="7" fill="#e8c56c" />
<circle cx="104" cy="-38" r="4" fill="#ffefb0" />
<circle cx="104" cy="-38" r="9" fill="#ffefb0" opacity="0.3" />
<path d="M108 -16 L128 0 L106 0 Z" fill="#17181d" />
<rect x="107" y="-26" width="7" height="10" fill="#a12a24" />
<Wheel x={22} r={11} />
<Wheel x={50} r={11} />
<Wheel x={78} r={11} />
<g data-rod="">
<rect x="20" y="-13.5" width="60" height="3.2" rx="1.6" fill="#e8c56c" />
</g>
{steps.map((_, i) => (
<Coach key={i} x={-(i + 1) * COACH} lit={i <= best} label={i + 1} />
))}
</g>
{Array.from({ length: PUFFS }, (_, i) => (
<circle key={i} data-puff="" r="0" fill="#f3e6dc" opacity="0" />
))}
</svg>
<div className="flex items-start gap-[0.8em] px-[1em] py-[0.6em]" style={{ background: "#f7cb1a", color: "#17181d", borderTop: "0.16em solid #17181d" }}>
<span className="grid size-[2.1em] shrink-0 place-items-center rounded-full text-[1.05em] font-extrabold" style={{ background: "#17181d", color: "#f7cb1a" }} aria-hidden>
{at + 1}
</span>
<div className="min-w-0 flex-1" aria-live={auto ? "off" : "polite"}>
<p className="m-0 flex items-baseline justify-between gap-[0.6em] leading-[1.2]">
<span className="text-[1.15em] font-extrabold tracking-[0.02em]">{step?.title}</span>
{step?.time && <span className="font-mono text-[0.85em] font-semibold whitespace-nowrap">{here === at ? step.time : "Arriving"}</span>}
</p>
<p className="m-0 mt-[0.15em] leading-[1.28]">{step?.text}</p>
</div>
</div>
<ol className="relative m-0 flex list-none justify-between p-0 px-[1.2em] py-[0.42em]" style={{ background: "#17181d" }} aria-label="Stops on the line">
<span className="absolute inset-x-[2.2em] top-1/2 h-[0.14em] -translate-y-1/2" style={{ background: "#555a66" }} aria-hidden />
{steps.map((s, i) => {
const now = i === at;
return (
<li key={s.title} className="relative">
<button
type="button"
onClick={() => go(i)}
aria-current={now ? "step" : undefined}
aria-label={`Stop ${i + 1}, ${s.title}`}
className="grid size-[1.9em] min-h-6 min-w-6 cursor-pointer place-items-center rounded-full text-[0.86em] font-bold transition-colors"
style={{ background: now ? "#f7cb1a" : i <= best ? "#2a4a8c" : "#2c2e36", color: now ? "#17181d" : "#f1efe8" }}
>
{i + 1}
</button>
</li>
);
})}
</ol>
</div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/process-train.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/process-train-demo.tsx
import { ProcessTrain } from "@/components/ui/process-train"; export default function ProcessTrainExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[#17131f] p-2"> <ProcessTrain className="w-[min(130cqh,96cqw,820px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| steps | { title: string; text: string; time?: string }[] | 4 steps | One station each, in order. Short titles fit the name board best. |
| hold | number | 3800 | Ms at a station before it runs on by itself. Stops once a visitor picks a stop. |
| onArrive | (index: number) => void | - | Called when the train stops at a station. |
| className | string | "w-full max-w-3xl" | Set the width here. Everything scales with it. |