- Launch
- Press call
Route
import { useEffect, useRef, useState } from 'react';
const css = `
.card-route {
position: relative;
width: 240px;
height: 176px;
border-radius: 18px;
overflow: hidden;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.card-route__map { position: absolute; inset: 0; width: 100%; height: 100%; }
.card-route__block { fill: light-dark(#f3f4f7, #1a1c1f); }
.card-route__park { fill: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 6%, light-dark(#f3f4f7, #1a1c1f)); }
.card-route__path-bg { fill: none; stroke: light-dark(#dbdde1, #2e3035); stroke-width: 6; stroke-linecap: round; stroke-linejoin: round; }
.card-route__path { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; }
.card-route__pin { fill: light-dark(#fbfcfd, #121417); stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2.5; }
.card-route__car { fill: light-dark(#373a40, #f1f2f3); filter: drop-shadow(0 0 4px light-dark(#373a40, #f1f2f3)); }
.card-route__panel {
position: absolute;
left: 10px;
right: 10px;
bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border-radius: 12px;
background: color-mix(in oklab, light-dark(#f3f4f7, #1a1c1f) 88%, transparent);
backdrop-filter: blur(6px);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.card-route__panel b { display: block; color: light-dark(#373a40, #f1f2f3); font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; }
.card-route__panel span { color: light-dark(#5f626a, #a7abb2); font-size: 10px; }
.card-route__go { padding: 4px 10px; border-radius: 999px; background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); font-size: 10px; font-weight: 600; }
`;
const ROUTE = 'M28 30 H92 V74 H150 V40 H206';
const LOOP = 5200; // ms: drive 3.8 s, arrive, hold, reset
export function RouteCard() {
const path = useRef<SVGPathElement>(null);
const line = useRef<SVGPathElement>(null);
const car = useRef<SVGCircleElement>(null);
const [eta, setEta] = useState(12);
useEffect(() => {
const p = path.current!;
const len = p.getTotalLength();
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
let raf = 0;
const start = performance.now();
const tick = (now: number) => {
const t = ((now - start) % LOOP) / 3800;
const u = Math.min(1, t);
const e = u < 0.5 ? 2 * u * u : 1 - (-2 * u + 2) ** 2 / 2; // ease in-out
const pt = p.getPointAtLength(e * len);
car.current!.setAttribute('cx', pt.x.toFixed(1));
car.current!.setAttribute('cy', pt.y.toFixed(1));
// The route ahead stays bright; what is driven fades back.
line.current!.style.strokeDashoffset = String(-e);
setEta(Math.max(0, Math.ceil(12 * (1 - e))));
if (!still) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
return (
<>
<style>
{css}
</style>
<div className="card-route" role="group" aria-label={`Route to Studio, ${eta} minutes`}>
<svg className="card-route__map" viewBox="0 0 240 176" aria-hidden="true">
{/* City blocks between the streets. */}
{[
[0, 0, 84, 22],
[100, 0, 140, 32],
[0, 38, 84, 28],
[100, 48, 42, 18],
[158, 48, 82, 40],
[0, 82, 142, 40],
[158, 96, 82, 30],
].map(([x, y, w, h], i) => (
<rect key={i} className={i === 3 ? 'card-route__park' : 'card-route__block'} x={x} y={y} width={w} height={h} rx={4} />
))}
<path className="card-route__path-bg" d={ROUTE} />
<path ref={line} className="card-route__path" d={ROUTE} pathLength={1} />
<path ref={path} d={ROUTE} fill="none" />
<circle className="card-route__pin" cx={206} cy={40} r={5} />
<circle ref={car} className="card-route__car" cx={28} cy={30} r={5} />
</svg>
<div className="card-route__panel">
<div>
<b>{eta === 0 ? 'Arrived' : `${eta} min`}</b>
<span>Studio · 3.2 km</span>
</div>
<span className="card-route__go">{eta === 0 ? 'Done' : 'Live'}</span>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardRoute(el) on the mounted element instead.
<div class="card-route" role="group" aria-label="Route to Studio, 12 minutes">
<svg class="card-route__map" viewBox="0 0 240 176" aria-hidden="true">
<rect class="card-route__block" x="0" y="0" width="84" height="22" rx="4"></rect>
<rect class="card-route__block" x="100" y="0" width="140" height="32" rx="4"></rect>
<rect class="card-route__block" x="0" y="38" width="84" height="28" rx="4"></rect>
<rect class="card-route__park" x="100" y="48" width="42" height="18" rx="4"></rect>
<rect class="card-route__block" x="158" y="48" width="82" height="40" rx="4"></rect>
<rect class="card-route__block" x="0" y="82" width="142" height="40" rx="4"></rect>
<rect class="card-route__block" x="158" y="96" width="82" height="30" rx="4"></rect>
<path class="card-route__path-bg" d="M28 30 H92 V74 H150 V40 H206"></path>
<path class="card-route__path" d="M28 30 H92 V74 H150 V40 H206" pathLength="1"></path>
<path d="M28 30 H92 V74 H150 V40 H206" fill="none"></path>
<circle class="card-route__pin" cx="206" cy="40" r="5"></circle>
<circle class="card-route__car" cx="28" cy="30" r="5"></circle>
</svg>
<div class="card-route__panel">
<div>
<b>12 min</b>
<span>Studio · 3.2 km</span>
</div>
<span class="card-route__go">Live</span>
</div>
</div>.card-route {
position: relative;
width: 240px;
height: 176px;
border-radius: 18px;
overflow: hidden;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.card-route__map { position: absolute; inset: 0; width: 100%; height: 100%; }
.card-route__block { fill: light-dark(#f3f4f7, #1a1c1f); }
.card-route__park { fill: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 6%, light-dark(#f3f4f7, #1a1c1f)); }
.card-route__path-bg { fill: none; stroke: light-dark(#dbdde1, #2e3035); stroke-width: 6; stroke-linecap: round; stroke-linejoin: round; }
.card-route__path { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; }
.card-route__pin { fill: light-dark(#fbfcfd, #121417); stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2.5; }
.card-route__car { fill: light-dark(#373a40, #f1f2f3); filter: drop-shadow(0 0 4px light-dark(#373a40, #f1f2f3)); }
.card-route__panel {
position: absolute;
left: 10px;
right: 10px;
bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border-radius: 12px;
background: color-mix(in oklab, light-dark(#f3f4f7, #1a1c1f) 88%, transparent);
backdrop-filter: blur(6px);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.card-route__panel b { display: block; color: light-dark(#373a40, #f1f2f3); font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; }
.card-route__panel span { color: light-dark(#5f626a, #a7abb2); font-size: 10px; }
.card-route__go { padding: 4px 10px; border-radius: 999px; background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); font-size: 10px; font-weight: 600; }function initCardRoute(root) {
var paths = root.querySelectorAll('svg path');
var car = root.querySelector('.card-route__car');
var eta = root.querySelector('.card-route__panel b');
var badge = root.querySelector('.card-route__go');
var line = root.querySelector('.card-route__path');
var path = paths[paths.length - 1];
if (!car || !eta || !badge || !line || !path) return;
var LOOP = 5200; // ms: drive 3.8 s, arrive, hold, reset
var len = path.getTotalLength();
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var start = performance.now();
var raf = 0;
function tick(now) {
var u = Math.min(1, ((now - start) % LOOP) / 3800);
var e = u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2; // ease in-out
var pt = path.getPointAtLength(e * len);
car.setAttribute('cx', pt.x.toFixed(1));
car.setAttribute('cy', pt.y.toFixed(1));
// The route ahead stays bright; what is driven fades back.
line.style.strokeDashoffset = String(-e);
var m = Math.max(0, Math.ceil(12 * (1 - e)));
eta.textContent = m === 0 ? 'Arrived' : m + ' min';
badge.textContent = m === 0 ? 'Done' : 'Live';
root.setAttribute('aria-label', 'Route to Studio, ' + m + ' minutes');
if (!still) raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return function () { cancelAnimationFrame(raf); };
}
document.querySelectorAll('.card-route').forEach(initCardRoute);Goal: Create a "Route" card.
Visual behavior: A map card where a car drives the route between pins, the road ahead bright and the driven part fading back, while the ETA in the panel counts down to Arrived.
Technique:
- City blocks are rounded SVG rects; the route is one right-angled path drawn twice: a wide dim road and a bright line with pathLength 1.
- Each frame an ease-in-out progress runs over 3.8s of a 5.2s loop; getPointAtLength() places the car on the path.
- The bright line's dashoffset is minus the progress, so the stretch already driven drops away behind the car.
- The ETA counts down from 12 minutes with the progress and the badge switches to Done on arrival.
Accessibility: A group whose aria-label carries the destination and ETA; the map is aria-hidden. Under prefers-reduced-motion it stays at the start.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Route" card.
Visual behavior: A map card where a car drives the route between pins, the road ahead bright and the driven part fading back, while the ETA in the panel counts down to Arrived.
Technique:
- City blocks are rounded SVG rects; the route is one right-angled path drawn twice: a wide dim road and a bright line with pathLength 1.
- Each frame an ease-in-out progress runs over 3.8s of a 5.2s loop; getPointAtLength() places the car on the path.
- The bright line's dashoffset is minus the progress, so the stretch already driven drops away behind the car.
- The ETA counts down from 12 minutes with the progress and the badge switches to Done on arrival.
Accessibility: A group whose aria-label carries the destination and ETA; the map is aria-hidden. Under prefers-reduced-motion it stays at the start.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A map card where a car drives the route between pins, the road ahead bright and the driven part fading back, while the ETA in the panel counts down to Arrived.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- continuous, draw, card
- Techniques
- svg, raf, stroke dashoffset
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A group whose aria-label carries the destination and ETA; the map is aria-hidden. Under prefers-reduced-motion it stays at the start.