About this animation
A running tracker with a growing route and expanding session summary, with configurable route, metrics, colors, and fonts.
Why this one
A static map and a stop button lose the feeling of movement. This tracker keeps the runner alive, follows the route, and makes finishing a physical gesture: the sheet expands, the map pulls back, the route turns lime to white, and the final figures count upward. No screen change is required. The recovery card and editorial chronometer give the composition its own identity.
How it works
- Route points are clamped and sampled at equal screen-space distances. The same progress trims the Skia path and positions the runner, keeping them aligned.
- A UI-thread clock advances only while running. React receives whole-second changes for metric copy; it never receives the animated route position.
- Each chronometer column observes its own digit. Only a changed digit rolls, and wraparound lands exactly at zero. Colons have their own narrower width.
- One shared sheet progress controls expansion, map framing, route completion, and the live-to-summary crossfade. Release above 40% or flick upward to commit; shorter drags spring back with the release velocity.
- Pause freezes the clock and route, cross-morphs the Lucide pause/play icons, dims the time, and adds a pause ring. Finishing freezes the metrics and counts the summary up from zero.
- The demo loops through run (6 seconds), pause (1.5 seconds), resume, finish at 9.3 seconds, summary, and reset at 14.5 seconds. The glowing route draws its opening sweep during the first 1.6 seconds; the completion transformation comes later in the specified sequence.
Install
npx expo install @shopify/react-native-skia expo-image expo-blur react-native-gesture-handler react-native-reanimated react-native-workletsUse a GestureHandlerRootView in your app. Load your fonts before mounting. On web, initialize Skia's CanvasKit before importing the component (for example, an app-level WithSkiaWeb boundary); serve canvaskit.wasm from your public directory. The component requires no external image files, network, location permission, or real map service.
Usage
import { LiveRunTracker } from './live-run-tracker';
<LiveRunTracker
route={[{ x: 0.2, y: 0.8 }, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.3 }]}
metrics={{ distance: 5.24, heartRate: 174 }}
onFinish={(run) => saveRun(run)}
/>const tracker = useRef<LiveRunTrackerRef>(null);
<LiveRunTracker
ref={tracker}
fontFamily="Geist_400Regular"
serifFontFamily="InstrumentSerif_400Regular"
monoFontFamily="GeistMono_400Regular"
routeSpeed={0.018}
routeWidth={4}
glowIntensity={0.4}
recovery={[0.72, 0.86, 0.61]}
labels={{ complete: 'Session saved', restart: 'Run again' }}
onPauseChange={(paused) => syncPause(paused)}
/>
// Call these from event handlers, never during render:
tracker.current?.pause();
tracker.current?.resume();
tracker.current?.finish();Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
route | readonly RunPoint[] | Abstract loop | Normalized points; at least two finite points. Degenerate routes remain safe. |
metrics | Partial<RunMetrics> | 38:20:12, 495 s pace, 5.24 distance, 174 heart rate, 394 elevation, 172 cadence | Starting session values. The time uses minutes:seconds:hundredths; hundredths retain the starting sample. |
units | Partial<RunUnits> | mi, ft, bpm, spm | Distance, elevation, heart rate, cadence suffixes. Convert input metrics and distance rate yourself. |
colors | Partial<RunColors> | Warm black, lime, off-white | Entire palette: background, surface, block, street, accent, text, muted, border, clockBorder, controlInk. |
labels | Partial<RunLabels> | American English | All structural text, weather, GPS, actions, and announcements. |
formatDurationLabel | (seconds: number) => string | Minutes and seconds | Override the spoken duration and unit names. |
header | ReactNode | None | Optional identity beside the duration label. |
fontFamily, serifFontFamily, monoFontFamily | string | System font | Inject loaded fonts; no external theme imports in the component. |
initialProgress | number | 0.42 | Starting route position, clamped to 0–0.98. |
routeSpeed | number | 0.012 | Route fraction per second; stops at 0.98 until completion. |
distanceRate | number | 0.002 | Distance units added per second. |
routeWidth | number | 4 | Route stroke in points. |
glowIntensity | number | 0.55 | Route and runner glow opacity, clamped to 0–1. |
recovery | readonly [number, number, number] | [0.72, 0.86, 0.61] | Muscles, energy, hydration levels. |
onFinish | (metrics: RunMetrics) => void | None | Once per finished run; returns frozen metrics. |
onPauseChange | (paused: boolean) => void | None | Pause/resume transitions. |
onBack | () => void | None | Back action. |
style, testID | StyleProp<ViewStyle>, string | None, live-run-tracker | Host layout and stable control identifiers. |
Ref: pause(), resume(), finish(), reset(). Pause/resume cannot reopen a completed run. Reset restores the starting metrics. Finish is idempotent until reset. Route/metrics props represent starting data, not a GPS subscription; integrate a real tracker in the parent.
Compatibility
Expo SDK 58; React Native 0.88.0; Reanimated 4.7.1; worklets 0.13.0; Skia 2.14.0. Uses the modules bundled in the project's Expo Go configuration. Supports iOS, Android, and web.
The parent owns safe-area insets. The component measures its container, caps the scene width at 520 pt, and scrolls a minimum-height scene inside short containers (including 300×400 and landscape). The top-right 68 pt are reserved for the catalog close button.
Accessibility
Controls have button roles, labels, actions, and stable test identifiers. Screen-reader activation finishes immediately; ordinary touch can swipe upward or hold the handle for one second. A short tap announces the finish instruction. Back and restart are standard accessible buttons. Pause/resume and completion are announced.
Reduced motion removes route drawing, runner travel, map zoom, timer blinking, and digit rolling. State transitions use 200 ms fades, and the map shows a static route. Both live and summary layers hide inactive content from assistive technology. No location data is implied by the abstract map.
Icons: Lucide (ISC). Demo identity: Tidewell from the project's supplied brand kit. No real maps, people, teams, or brand assets.
Icons use the supplied Lucide SVG paths through expo-image (inline data URIs, no network). Only the map allocates a Skia Canvas/WebGL context; keeping a separate Canvas per icon exhausts browser context limits when live and summary layers coexist. No custom runtime shader or Skia font API is used. Digit rows and the full reel have explicit heights to prevent flex compression, and each reel starts at its configured digit before animation reactions run.



