Road Journey
A winding road that draws itself as you scroll, with a little car driving between your milestones. Works with the page or any scroll container.
Scroll inside this window ↓
- Stop 01
Hello, world
Wrote my first line of code and never looked back.
- Stop 02
First client
Someone actually paid me to build a thing.
- Stop 03
Big project
Shipped something thousands of people use.
- Up next
Next exit
Working on it right now.
1"use client"; 2 3import { RefObject, useEffect, useRef, useState } from "react"; 4import "./RoadJourney.css"; 5 6export type Stop = { title: string; note: string; kicker?: string }; 7 8type Props = { 9 stops: Stop[]; 10 scroller?: RefObject<HTMLElement | null>; 11}; 12 13type Point = { x: number; y: number }; 14 15type Geometry = { 16 w: number; 17 h: number; 18 road: number; 19 points: Point[]; 20 d: string; 21 segments: string[]; 22 end: Point; 23}; 24 25function curve(a: Point, b: Point) { 26 const k = (b.y - a.y) * 0.55; 27 return `C ${a.x} ${a.y + k}, ${b.x} ${b.y - k}, ${b.x} ${b.y}`; 28} 29 30export default function RoadJourney({ stops, scroller }: Props) { 31 const map = useRef<HTMLDivElement>(null); 32 const list = useRef<HTMLOListElement>(null); 33 const segRef = useRef<SVGGElement>(null); 34 const car = useRef<HTMLDivElement>(null); 35 const [geo, setGeo] = useState<Geometry | null>(null); 36 37 useEffect(() => { 38 const el = map.current; 39 const ol = list.current; 40 if (!el || !ol) return; 41 42 const measure = () => { 43 const w = el.clientWidth; 44 const h = el.clientHeight; 45 const narrow = w < 640; 46 const road = narrow ? 16 : 28; 47 const amp = narrow ? w * 0.035 : Math.min(80, w * 0.06); 48 const items = Array.from(ol.children) as HTMLElement[]; 49 const lanes = items.map((_, i) => 50 i % 2 === 0 ? w * (narrow ? 0.885 : 0.7) : w * (narrow ? 0.115 : 0.3), 51 ); 52 const points = items.map((item, i) => ({ 53 x: lanes[i] + (i % 2 === 0 ? amp : -amp), 54 y: item.offsetTop + item.offsetHeight / 2, 55 })); 56 const route = items.flatMap((item, i) => [ 57 { x: lanes[i], y: item.offsetTop + 10 }, 58 points[i], 59 { x: lanes[i], y: item.offsetTop + item.offsetHeight - 10 }, 60 ]); 61 const last = items[items.length - 1]; 62 const end = { 63 x: lanes[lanes.length - 1], 64 y: Math.min(h, last.offsetTop + last.offsetHeight + 80), 65 }; 66 const all = [{ x: lanes[0], y: 0 }, ...route, end]; 67 const segments = all 68 .slice(1) 69 .map((b, i) => `M ${all[i].x} ${all[i].y} ${curve(all[i], b)}`); 70 setGeo({ 71 w, 72 h, 73 road, 74 points, 75 end, 76 segments, 77 d: 78 `M ${all[0].x} ${all[0].y} ` + 79 all 80 .slice(1) 81 .map((b, i) => curve(all[i], b)) 82 .join(" "), 83 }); 84 }; 85 86 measure(); 87 const ro = new ResizeObserver(measure); 88 ro.observe(el); 89 return () => ro.disconnect(); 90 }, [stops]); 91 92 useEffect(() => { 93 const el = map.current; 94 const rig = car.current; 95 const group = segRef.current; 96 if (!geo || !el || !rig || !group) return; 97 98 const segs = Array.from(group.querySelectorAll<SVGPathElement>("path")); 99 const segLen = segs.map((p) => p.getTotalLength()); 100 const segStart = segLen.map((_, i) => 101 segLen.slice(0, i).reduce((a, b) => a + b, 0), 102 ); 103 const xs: number[] = []; 104 const ys: number[] = []; 105 const lens: number[] = []; 106 segs.forEach((p, s) => { 107 const n = Math.max(6, Math.ceil(segLen[s] / 8)); 108 for (let i = s === 0 ? 0 : 1; i <= n; i++) { 109 const pt = p.getPointAtLength((segLen[s] * i) / n); 110 xs.push(pt.x); 111 ys.push(pt.y); 112 lens.push(segStart[s] + (segLen[s] * i) / n); 113 } 114 p.style.strokeDasharray = `${segLen[s] + 1}`; 115 p.style.strokeDashoffset = `${segLen[s] + 1}`; 116 }); 117 const N = ys.length - 1; 118 const angles = xs.map((_, i) => { 119 const j = Math.min(N, i + 1); 120 const k = j === i ? i - 1 : i; 121 return Math.atan2(ys[j] - ys[k], xs[j] - xs[k]); 122 }); 123 124 const items = Array.from(list.current?.children ?? []) as HTMLElement[]; 125 const lane = geo.road * 0.24; 126 const parkY = geo.end.y - geo.road * 2.5; 127 let current = -1; 128 let reached = -1; 129 let frame = 0; 130 131 const update = () => { 132 frame = 0; 133 const box = scroller?.current; 134 const viewTop = box ? box.getBoundingClientRect().top : 0; 135 const viewH = box ? box.clientHeight : window.innerHeight; 136 const raw = viewH * 0.6 - (el.getBoundingClientRect().top - viewTop); 137 const y = Math.min(parkY, raw); 138 139 let lo = 0; 140 let hi = N; 141 if (y <= ys[0]) hi = 1; 142 else if (y >= ys[N]) lo = N - 1; 143 else 144 while (hi - lo > 1) { 145 const mid = (lo + hi) >> 1; 146 if (ys[mid] < y) lo = mid; 147 else hi = mid; 148 } 149 const t = Math.min(1, Math.max(0, (y - ys[lo]) / (ys[hi] - ys[lo] || 1))); 150 const px = xs[lo] + (xs[hi] - xs[lo]) * t; 151 const py = ys[lo] + (ys[hi] - ys[lo]) * t; 152 const a = angles[lo] + (angles[hi] - angles[lo]) * t; 153 const len = lens[lo] + (lens[hi] - lens[lo]) * t; 154 155 rig.style.transform = `translate3d(${px + Math.sin(a) * lane}px, ${py - Math.cos(a) * lane}px, 0) rotate(${(a * 180) / Math.PI}deg)`; 156 157 let k = segStart.length - 1; 158 while (k > 0 && segStart[k] > len) k--; 159 if (k !== current) { 160 segs.forEach((p, i) => { 161 if (i < k) p.style.strokeDashoffset = "0"; 162 else if (i > k) p.style.strokeDashoffset = `${segLen[i] + 1}`; 163 }); 164 current = k; 165 } 166 segs[k].style.strokeDashoffset = 167 `${segLen[k] + 1 - Math.min(segLen[k], len - segStart[k])}`; 168 169 let count = 0; 170 while (count < geo.points.length && py >= geo.points[count].y - 4) 171 count++; 172 if (count !== reached) { 173 reached = count; 174 items.forEach((item, i) => 175 item.classList.toggle("is-reached", i < count), 176 ); 177 } 178 }; 179 180 const schedule = () => { 181 if (!frame) frame = requestAnimationFrame(update); 182 }; 183 184 update(); 185 const target: HTMLElement | Window = scroller?.current ?? window; 186 target.addEventListener("scroll", schedule, { passive: true }); 187 window.addEventListener("resize", schedule); 188 return () => { 189 cancelAnimationFrame(frame); 190 target.removeEventListener("scroll", schedule); 191 window.removeEventListener("resize", schedule); 192 }; 193 }, [geo, scroller]); 194 195 return ( 196 <div className="rj" ref={map}> 197 {geo && ( 198 <svg 199 className="rj__road" 200 width={geo.w} 201 height={geo.h} 202 viewBox={`0 0 ${geo.w} ${geo.h}`} 203 aria-hidden="true" 204 > 205 <defs> 206 <linearGradient id="rj-fade" x1="0" y1="0" x2="0" y2="1"> 207 <stop 208 offset="0" 209 style={{ stopColor: "var(--rj-bg)", stopOpacity: 0 }} 210 /> 211 <stop offset="1" style={{ stopColor: "var(--rj-bg)" }} /> 212 </linearGradient> 213 </defs> 214 <path 215 d={geo.d} 216 className="rj__asphalt rj__asphalt--ghost" 217 strokeWidth={geo.road} 218 /> 219 <g ref={segRef}> 220 {geo.segments.map((d, i) => ( 221 <path 222 key={i} 223 d={d} 224 className="rj__asphalt" 225 strokeWidth={geo.road + 0.5} 226 /> 227 ))} 228 </g> 229 <path 230 d={geo.d} 231 className="rj__lines" 232 strokeWidth={geo.road < 20 ? 1.5 : 2} 233 /> 234 <rect 235 x={geo.end.x - geo.road * 2} 236 y={geo.end.y - 110} 237 width={geo.road * 4} 238 height={120} 239 fill="url(#rj-fade)" 240 /> 241 </svg> 242 )} 243 244 <ol className="rj__stops" ref={list}> 245 {stops.map((s, i) => ( 246 <li 247 key={s.title} 248 className={`rj__stop ${i % 2 === 0 ? "is-left" : "is-right"}`} 249 > 250 <div className="rj__card"> 251 <span className="rj__kicker"> 252 {s.kicker ?? `Stop ${String(i + 1).padStart(2, "0")}`} 253 </span> 254 <h3 className="rj__title">{s.title}</h3> 255 <p className="rj__note">{s.note}</p> 256 </div> 257 </li> 258 ))} 259 </ol> 260 261 {geo && ( 262 <div className="rj__car" ref={car} aria-hidden="true"> 263 <svg viewBox="-16 -10 32 20" width={geo.road < 20 ? 22 : 34}> 264 <rect x="-9" y="-8" width="4.5" height="2.5" rx="1" fill="#111" /> 265 <rect x="4" y="-8" width="4.5" height="2.5" rx="1" fill="#111" /> 266 <rect x="-9" y="5.5" width="4.5" height="2.5" rx="1" fill="#111" /> 267 <rect x="4" y="5.5" width="4.5" height="2.5" rx="1" fill="#111" /> 268 <rect 269 x="-11" 270 y="-6.5" 271 width="22" 272 height="13" 273 rx="5" 274 fill="var(--rj-accent)" 275 /> 276 <rect x="2" y="-4.8" width="5" height="9.6" rx="1.5" fill="#111" /> 277 <circle cx="10.5" cy="-3.5" r="1.1" fill="#fff3c4" /> 278 <circle cx="10.5" cy="3.5" r="1.1" fill="#fff3c4" /> 279 </svg> 280 </div> 281 )} 282 </div> 283 ); 284}

