About this animation
A heart-rate monitor with rolling BPM and a synchronized ECG sweep, with configurable initial BPM, metrics, trace color, and fonts.
Why this one
The heart and ECG use the same integrated beat phase. Changing BPM changes their rhythm together; pausing freezes the trace without resetting its history. The monitor overwrites samples in place and leaves an 18-point clearing gap ahead of its bright head, rather than scrolling a decorative line.
How it works
onLayoutmeasures the host container; all major sections use fractions of its width and height.- One
useFrameCallbackadvances active time and integrates BPM into a continuous beat phase. A P–QRS–T waveform supplies both the trace samples and the QRS crossing that triggers the 220 ms heart pulse. - A 400-sample ring stores four seconds at 10 ms intervals. Skia draws disjoint path segments, omitting the clearing gap instead of placing an opaque block over the ECG.
- BPM rolls on the UI thread. Each numeral column advances only at its own carry and fades digits away from the resting position. Effort-zone position follows an interruptible spring.
- Pause stops the shared clock, lowers the monitor to 50%, and changes the accessible control to Resume. A polite announcement updates every ten seconds of active monitoring.
- Reduced motion draws a complete static waveform, uses integer BPM, and disables the sweep, glow expansion, and heartbeat.
Install
npx expo install @shopify/react-native-skia expo-blur react-native-reanimated react-native-workletsCopy HeartRateLive.tsx, icons.ts, and index.ts into your project. Load your fonts in the host application. For web, initialize CanvasKit before importing this component (Expo's Skia web setup or a deferred WithSkiaWeb boundary). The catalog registration must use its existing withSkia loader.
Icons: Lucide (ISC). All used paths are included in icons.ts.
Usage
import { HeartRateLive } from './heart-rate-live';
export function Workout() {
return <HeartRateLive initialBpm={128} />;
}import { useRef } from 'react';
import { HeartRateLive, type HeartRateLiveRef } from './heart-rate-live';
export function CustomWorkout() {
const monitor = useRef<HeartRateLiveRef>(null);
return (
<HeartRateLive
ref={monitor}
initialBpm={124}
maxBpm={180}
sessionLabel="Evening run · 32:14"
traceColor="#2DD4BF"
beatIntensity={0.12}
waveAmplitude={0.38}
onBpmChange={(bpm) => saveWorkoutRate(bpm)}
/>
);
}saveWorkoutRate represents your application's callback. Call monitor.current?.setBpm(131) from your sensor subscription or simulation, and clean up that subscription in the host. Ref methods: setBpm(number), pause(), resume(). The waveform is illustrative, not sensor ECG data or a diagnostic tool.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| initialBpm | number | 128 | Starting rate, clamped to 40–220. |
| maxBpm | number | 180 | Maximum rate for five effort zones; protected against zero. |
| header | ReactNode | — | Application branding slot. |
| sessionLabel | string | Workout in progress | Session description. |
| metrics | { label; value; unit? }[] | Avg 121 bpm, Max 164 bpm, Calories 412 | Up to three summary cards. |
| fontFamily | string | System | Interface font. |
| mediumFontFamily | string | fontFamily | Strong label font. |
| monoFontFamily | string | System | Numeral font; use a monospaced face. |
| backgroundColor | string | #060B0B | Background color. |
| traceColor | string | #2DD4BF | Waveform accent. |
| beatIntensity | number | 0.12 | QRS heart-scale increment. |
| waveAmplitude | number | 0.38 | Wave height relative to the plot. |
| onStateChange | (state: 'live' | 'paused') => void | — | Pause/resume notification. |
| onBpmChange | (bpm: number) => void | — | Accepted target-rate notification. |
| pauseLabel | string | Pause heart rate monitor | Accessible pause label. |
| resumeLabel | string | Resume heart rate monitor | Accessible resume label. |
| style | StyleProp<ViewStyle> | — | Outer container styling. |
| testID | string | — | Outer test identifier. |
Compatibility
Targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, and Skia 2.14.0. Uses SDK 58's BlurTargetView with dimezisBlurViewSdk31Plus; older Android versions receive its translucent fallback. Compatible with the installed Expo Go modules; custom native projects need matching native dependencies.
The component is self-contained; only the demo imports the catalog's fonts and adds the Tidewell brand and simulated effort curve.
Accessibility
Both the heart and the 64-point pause control support ordinary taps, button roles, and state-dependent labels. The heart exposes its paused selection state. Decorative rolling digits are hidden from assistive technology. A readable accessibilityLiveRegion="polite" announces “128 beats per minute” at ten-second intervals; VoiceOver announcement delivery should be checked on a device. Reduced motion presents a complete static ECG and unanimated integer values.
Demo timeline
Autoplay starts at 124 BPM and requests 131 at 1.8 seconds, settling by 2.35 seconds. The monitor sweeps immediately, with every heart impulse tied to its QRS. Subsequent effort targets change every 3.5 seconds across 118–142 BPM. Pausing also pauses the demo schedule; resume continues the same sample history.
Layout: at 390×844 and 320×694, the header stays left of the viewer's upper-right close control; the hero, zone, ECG, cards, and footer have distinct vertical bands. Numerals scale to 64 points on the smaller width. The 64-point cards end before the footer at both sizes. Glows are radial shaders, the ECG glow blurs the actual path, and the sweep gap omits geometry; no opaque overlay fakes an effect.
Customization notes
The incoming heart-rate data determines beat timing; a cosmetic duration would misrepresent that data.




