Ride Tracker
A ride on its way: a schematic route with the car moving along it, ETA, driver and plate, and share or cancel actions.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/ride-tracker.json'use client'
import { useEffect, useRef, useState } from 'react'
import './ride-tracker.css'
export interface RideDriver { name: string; car: string; plate: string; rating: number }
export interface RideTrackerProps {
driver: RideDriver
minutes?: number
speed?: number
onCancel?: () => void
fill?: 'dark' | 'light'
className?: string
}
const ROUTE = 'M30 150 L30 110 Q30 96 44 96 L140 96 Q154 96 154 82 L154 50 Q154 36 168 36 L290 36'
/** Ride ETA tracker with moving car along a route. */
export function RideTracker({ driver, minutes = 6, speed = 1, onCancel, fill = 'dark', className }: RideTrackerProps) {
const total = minutes * 60
const [left, setLeft] = useState(total)
const [stage, setStage] = useState<0 | 1 | 2 | 3>(0) // 0 arriving, 1 here, 2 on trip, 3 cancelled
const [confirm, setConfirm] = useState(false)
const [pos, setPos] = useState<[number, number, number]>([30, 150, 0])
const [shared, setShared] = useState(false)
const path = useRef<SVGPathElement>(null)
useEffect(() => {
if (stage !== 0) return
const id = setInterval(() => setLeft((l) => { const n = Math.max(0, l - speed * 0.25); if (n === 0) setStage(1); return n }), 250)
return () => clearInterval(id)
}, [stage, speed])
const frac = 1 - left / total
useEffect(() => {
const p = path.current; if (!p) return
const L = p.getTotalLength(), a = p.getPointAtLength(frac * L), b = p.getPointAtLength(Math.min(L, frac * L + 1))
setPos([Math.round(a.x * 10) / 10, Math.round(a.y * 10) / 10, Math.round((Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI)])
}, [frac])
const mins = Math.ceil(left / 60)
const title = ['Driver is on the way', `${driver.name} is here`, 'On your way', 'Ride cancelled'][stage]
const sub = [`Arriving in ${mins} min · meet at the corner`, `Look for ${driver.plate}`, 'Arriving 9:42 PM', 'No charge for this cancellation'][stage]
return (
<div className={['sd-rdt', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-rdt__map" aria-hidden>
<svg viewBox="0 0 320 180" preserveAspectRatio="xMidYMid slice">
{[0, 1, 2, 3, 4, 5, 6, 7].map((i) => <line key={'v' + i} className="sd-rdt__grid" x1={i * 46} y1="0" x2={i * 46 + 20} y2="180" />)}
{[0, 1, 2, 3].map((i) => <line key={'h' + i} className="sd-rdt__grid" x1="0" y1={i * 52 + 20} x2="320" y2={i * 52 + 10} />)}
<path className="sd-rdt__done" d={ROUTE} />
<path ref={path} className="sd-rdt__route" d={ROUTE} pathLength={100} strokeDasharray={`0 ${(frac * 100).toFixed(2)} 100`} />
<circle className="sd-rdt__pin" cx="290" cy="36" r="7" />
{stage !== 3 && <g className="sd-rdt__car" transform={`translate(${pos[0]} ${pos[1]}) rotate(${pos[2]})`}><rect x="-11" y="-7" width="22" height="14" rx="4" /><circle cx="6" cy="0" r="2.5" /></g>}
</svg>
{stage === 0 && <div className="sd-rdt__eta"><b>{mins}</b> min</div>}
</div>
<div className="sd-rdt__body">
<div className="sd-rdt__st" aria-live="polite">{title}<small>{sub}</small></div>
<div className="sd-rdt__bar" aria-hidden>{[0, 1, 2].map((i) => <i key={i} data-on={(stage !== 3 && i <= stage) || undefined} />)}</div>
<div className="sd-rdt__dr"><span className="sd-rdt__av" aria-hidden>{driver.name[0]}</span><div><b>{driver.name} · {driver.rating.toFixed(2)}★</b><small>{driver.car}</small></div><span className="sd-rdt__pl" aria-label={`Plate ${driver.plate}`}>{driver.plate}</span></div>
<div className="sd-rdt__act">
{stage === 1 ? <button type="button" onClick={() => setStage(2)}>I’m in</button> : <button type="button" disabled={stage === 3} onClick={() => { setShared(true); setTimeout(() => setShared(false), 1400) }}>{shared ? 'Link copied' : 'Share trip'}</button>}
<button type="button" disabled={stage >= 2} data-confirm={confirm || undefined} onClick={() => { if (!confirm) { setConfirm(true); return } setStage(3); setConfirm(false); onCancel?.() }} onBlur={() => setConfirm(false)}>{confirm ? 'Confirm cancel' : 'Cancel'}</button>
</div>
</div>
</div>
)
}.sd-rdt { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-rdt[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-rdt :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-rdt :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-rdt .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-rdt { width: min(100%, 330px); grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; overflow: hidden; }
.sd-rdt__map { position: relative; height: 180px; background: var(--soft); }
.sd-rdt__map svg { width: 100%; height: 100%; display: block; }
.sd-rdt__grid { stroke: var(--line); stroke-width: 1; }
.sd-rdt__route { fill: none; stroke: var(--fg); stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; }
.sd-rdt__done { fill: none; stroke: var(--mute); stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; opacity: 0.5; }
.sd-rdt__pin { fill: var(--bg); stroke: var(--fg); stroke-width: 3; }
.sd-rdt__car rect { fill: var(--fg); }
.sd-rdt__car circle { fill: var(--bg); }
.sd-rdt__eta { position: absolute; left: 12px; top: 12px; padding: 6px 10px; border-radius: 10px; background: var(--strong) !important; color: var(--on) !important; font-size: 12px; }
.sd-rdt__eta b { font-size: 16px; font-variant-numeric: tabular-nums; }
.sd-rdt__body { padding: 14px; display: grid; gap: 12px; }
.sd-rdt__st { font-size: 15px; font-weight: 700; }
.sd-rdt__st small { display: block; font-size: 12px; font-weight: 400; color: var(--mute); margin-top: 2px; }
.sd-rdt__bar { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
.sd-rdt__bar i { height: 3px; border-radius: 2px; background: var(--line); }
.sd-rdt__bar i[data-on] { background: var(--fg); }
.sd-rdt__dr { display: flex; align-items: center; gap: 10px; }
.sd-rdt__av { width: 40px; height: 40px; border-radius: 50%; background: var(--soft); display: grid; place-items: center; font-weight: 700; flex: none; }
.sd-rdt__dr > div { flex: 1; min-width: 0; }
.sd-rdt__dr small { display: block; color: var(--mute); font-size: 12px; }
.sd-rdt__pl { padding: 5px 9px; border-radius: 7px; box-shadow: inset 0 0 0 1.5px var(--fg); font: 700 14px ui-monospace, monospace; letter-spacing: 0.06em; }
.sd-rdt__act { display: flex; gap: 6px; }
.sd-rdt__act button { flex: 1; height: 38px; border-radius: 10px; font-size: 13px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); }
.sd-rdt__act button[data-confirm] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-rdt__act button:disabled { opacity: 0.4; }
@media (prefers-reduced-motion: reduce) { .sd-rdt *, .sd-rdt *::before, .sd-rdt *::after { animation: none !important; transition: none !important; } }Overview
The route is an SVG path; the car marker travels along it using the path length, and the ETA counts down with it. Stages go from arriving to on trip to arrived.
Driver details carry the plate in large type, since that is what people look for at the kerb. Cancel asks first.
When to use
- Ride-hailing, deliveries, field-service arrivals.
When not to use
- Real maps with turn-by-turn: use a map SDK.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/ride-tracker.json$
npx shadcn@latest add https://sundaeui.dev/r/ride-tracker.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/ride-tracker.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/ride-tracker.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy ride-tracker.tsx and ride-tracker.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { RideTracker } from '@/components/sundae/ride-tracker'
<RideTracker />Props
| Prop | Type | Default |
|---|---|---|
driver*Driver. | { name: string; car: string; plate: string; rating: number } | — |
minutesStarting ETA. | number | 6 |
speedDemo speed multiplier. | number | 1 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- ETA and stage are announced politely; the map is decorative.
- Cancel needs confirmation.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-rdt; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Ride Tracker free?
- Yes. Ride Tracker is free for personal and commercial projects, with full source on this page.
- How do I install Ride Tracker?
- Run npx shadcn@latest add https://sundaeui.dev/r/ride-tracker.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Ride Tracker support keyboard and screen readers?
- ETA and stage are announced politely; the map is decorative. Cancel needs confirmation.
- Does Ride Tracker respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Order Tracker
Components · RefinedStore Locator
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined- Pro
Notification Center
Components · Refined