Animated, multi-layer radial (activity-ring) charts for React Native — powered by react-native-svg, written in strict TypeScript.
Give it an array of { value, max, color, label } and it renders concentric rings — like Apple Watch activity rings — with smooth requestAnimationFrame animations, responsive sizing, gradients, overflow laps, goal markers, and a built-in legend. All the math is pure and framework-agnostic; the view layer is a thin react-native-svg wrapper.
Using React on the web? See the sibling package
multi-layer-radial-chart— this is its React Native port. Same core engine and API surface, adapted toreact-native-svgand native platform APIs.
A runnable Expo demo lives in example/ — it showcases activity rings with a count-up center, live controls (animate, rounded, legend, overflow), plus gradient, overflow, goal-marker, gauge, and dashed cards with automatic light/dark theming:
cd example
npm install
npm run start # press a / i / w for Android / iOS / webPrefer a hosted playground? Try it on Expo Snack — the React-Native equivalent of StackBlitz (runs in the browser and on a device via QR). Goes live once the package is published to npm.
- Multi-layer rings — any number of concentric series, outermost first.
- Smooth animation —
requestAnimationFrametween with ease-out cubic; honors the OS "Reduce Motion" setting viaAccessibilityInfo. - Responsive — omit
sizeand the chart measures its container withonLayout, or pass a fixedsize. - Gradients — per-ring
linear/radialgradients using native<LinearGradient>/<RadialGradient>. - Overflow laps — Apple-Watch-style overlapping extra lap when a value exceeds
max(allowOverflow). - Goal / threshold markers — a tick on the track marking a target, with a light outline so it stays visible on any color.
- Gauge & semicircle — set
maxSweepDegrees(e.g.270gauge,180semicircle). - Interactivity —
onSegmentPress(datum, index)makes each ring pressable. - Center content — render any node (count-up total, icon, label) in the middle.
- Accessible — the chart exposes an
imagerole with a summary label, and each ring exposes aprogressbarrole withaccessibilityValue. The legend is color-independent. - Headless —
useRadialChart()returns validated geometry and ready-to-use SVG path strings so you can draw the rings yourself. - Framework-agnostic core — import
multi-layer-radial-chart-native/corefor pure geometry/scale/validation with zero React/RN at runtime. - Strict typing — no
any, fully typed public API, math fully decoupled from the UI.
Challenge: An SVG elliptical arc (
Acommand) cannot draw a full 360° circle — the start and end points coincide, so the arc collapses and renders nothing. Solution:describeArcdetects a full sweep and emits two half-arcs instead, while still supporting partial sweeps (gauges/semicircles) through a singlesweepDegreesparameter. This lives in a pure, unit-testedcore/geometrymodule with zero React/RN, keeping trigonometry completely separate from the rendering layer.
Challenge: Animating many rings without stale closures or leaked animation frames. Solution: A dedicated
useAnimatedValuehook owns onerequestAnimationFrameloop per ring, restarts cleanly from the currently displayed value when the target changes, and always cancels the frame on cleanup. Per-ring hooks live inside theRadialRingcomponent (never inside a.map()), respecting the Rules of Hooks.
Challenge: The web version leans on the DOM (
ResizeObserver,matchMedia, CSS variables) that don't exist in React Native. Solution: Only the thin adapter layer changed — sizing moved toViewonLayout, reduced-motion toAccessibilityInfo, and theming to explicit props. The entirecore/+utils/math layer is shared verbatim with the web package.
- Language / UI: TypeScript (strict), React, React Native
- Rendering:
react-native-svg - Native APIs:
requestAnimationFrame,ViewonLayout,AccessibilityInfo - Tooling:
react-native-builder-bob(build), Vitest (core tests), ESLint, Changesets, Expo (demo)
npm install multi-layer-radial-chart-native react-native-svg
# or
yarn add multi-layer-radial-chart-native react-native-svgreact, react-native, and react-native-svg are peer dependencies.
Expo:
npx expo install react-native-svg
npm install multi-layer-radial-chart-nativeBare React Native: after installing react-native-svg, run cd ios && pod install (iOS).
import { RadialChart } from "multi-layer-radial-chart-native";
import { View } from "react-native";
const data = [
{ value: 82, max: 100, color: "#fb2576", label: "Move" },
{ value: 45, max: 60, color: "#22d3ee", label: "Exercise" },
{ value: 9, max: 12, color: "#a3e635", label: "Stand" },
];
export function Activity() {
return (
<View style={{ padding: 24 }}>
<RadialChart data={data} size={220} showLegend />
</View>
);
}| Prop | Type | Default | Description |
|---|---|---|---|
data |
RadialDatum[] |
— | Series to render, outermost ring first. (required) |
size |
number |
container width | Fixed square size in px. Omit to size responsively via onLayout. |
startAngle |
number |
-90 |
Angle (deg) where every arc begins (-90 = 12 o'clock). |
gap |
number |
6 |
Pixel gap between adjacent rings. |
ringWidth |
number |
auto | Fixed stroke width; auto-derived from size/count when omitted. |
rounded |
boolean |
true |
Round the arc line caps. |
allowOverflow |
boolean |
false |
Let values above max overrun as an overlapping extra lap. |
animate |
boolean |
true |
Animate value transitions. |
animationDurationMs |
number |
800 |
Tween duration in ms. |
clockwise |
boolean |
true |
Draw arcs clockwise. |
maxSweepDegrees |
number |
360 |
Total sweep in deg (270 gauge, 180 semicircle). |
showLegend |
boolean |
false |
Render the built-in legend below the chart. |
onSegmentPress |
(datum, index) => void |
— | Press a ring. Providing this makes each ring pressable. |
trackColor |
string |
translucent gray | Background track color when a datum omits trackColor. |
markerColor |
string |
dark | Inner (dark) goal/threshold marker tick color. |
markerOutlineColor |
string |
light | Outer (light) outline of the marker tick. |
legendTextColor |
string |
#111827 |
Text color for the built-in legend. |
style |
StyleProp<ViewStyle> |
— | Extra style for the outer container View. |
children |
ReactNode |
— | Content rendered in the centre (absolutely positioned). |
interface RadialDatum {
value: number; // current value (clamped to [0, max] unless allowOverflow)
max: number; // upper bound (> 0)
color: string; // progress color
label: string; // legend + accessible name
trackColor?: string; // per-ring track override
pattern?: "solid" | "dashed"; // color-independent differentiation
gradient?: RingGradient; // overrides `color`
threshold?: number; // goal marker, in the same units as `value`
}Omit size and wrap the chart in a container with a known width — it measures itself via onLayout:
<View style={{ width: "100%" }}>
<RadialChart data={data} />
</View>import { RadialChart, useCountUp } from "multi-layer-radial-chart-native";
import { Text } from "react-native";
function CenteredChart() {
const total = 82;
const shown = useCountUp(total, { durationMs: 900 });
return (
<RadialChart data={data} size={220}>
<Text style={{ fontSize: 34, fontWeight: "700" }}>{shown}%</Text>
</RadialChart>
);
}<RadialChart data={data} maxSweepDegrees={270} startAngle={135} />const data = [
{
value: 82,
max: 100,
label: "Move",
color: "#fb2576", // fallback for the legend swatch
gradient: {
type: "linear",
angle: 45,
stops: [
{ offset: 0, color: "#fb2576" },
{ offset: 1, color: "#7b2ff7" },
],
},
},
];<RadialChart data={[{ value: 140, max: 100, color: "#fb2576", label: "Move" }]} allowOverflow /><RadialChart data={[{ value: 68, max: 100, color: "#22d3ee", label: "Exercise", threshold: 80 }]} /><RadialChart
data={data}
onSegmentPress={(datum, index) => console.log("pressed", index, datum.label)}
/>Compute geometry and draw the rings with your own react-native-svg markup:
import { useRadialChart } from "multi-layer-radial-chart-native";
import Svg, { Path } from "react-native-svg";
function Headless() {
const { center, rings } = useRadialChart(data, { size: 240, gap: 8 });
return (
<Svg width={240} height={240} viewBox="0 0 240 240">
{rings.map((ring) => (
<Path
key={ring.datum.label}
d={ring.progressPath}
fill="none"
stroke={ring.datum.color}
strokeWidth={ring.strokeWidth}
strokeLinecap="round"
/>
))}
</Svg>
);
}The math is fully decoupled from React and React Native. Import the /core subpath for pure geometry, scaling, layout, validation and color helpers with zero React/RN in the runtime graph — use it from a <canvas>/Skia renderer, a worker, or the server:
import { validateData, computeRingLayout, describeArc } from "multi-layer-radial-chart-native/core";
const rings = validateData([{ value: 82, max: 100, color: "#fb2576", label: "Move" }]);
const layout = computeRingLayout(rings.length, 240, 6);
const path = describeArc(120, 120, layout[0].radius, rings[0].fraction, -90, true);This port keeps the same core engine and API names where they make sense on native. Web-only concepts were adapted:
Web (multi-layer-radial-chart) |
Native (-native) |
|---|---|
SVG DOM elements (<svg>, <path>, <g>) |
react-native-svg (Svg, Path, G, Line, …) |
ResizeObserver |
View onLayout |
prefers-reduced-motion (matchMedia) |
AccessibilityInfo.isReduceMotionEnabled() |
| CSS variables / stylesheet | Props: trackColor, markerColor, legendTextColor, … |
className |
style (StyleProp<ViewStyle>) |
onSegmentClick + keyboard focus |
onSegmentPress |
Hover tooltip (onSegmentHover, tooltip) |
Not applicable on touch — omitted |
CSS blur() on the overflow shadow |
Approximated with a contrast-colored tip segment |
- The chart
<Svg>carriesaccessibilityRole="image"with a summaryaccessibilityLabel("Move 82%, Exercise 75%, …"). - Each ring carries
accessibilityRole="progressbar", anaccessibilityLabelof"{label}: {value}/{max} ({percent}%)", andaccessibilityValue. - Motion honors the OS Reduce Motion setting (rings snap instead of tween).
- The legend is color-independent (labels + values), and
pattern: "dashed"differentiates rings without relying on color alone.
src/
core/ # pure math — geometry, scale, layout, constants (no React/RN)
utils/ # math, color (WCAG contrast), validation (no React/RN)
hooks/ # useAnimatedValue, useCountUp, useReducedMotion, useElementSize, useRadialChart
components/ # RadialChart, RadialRing, RadialChartLabels (react-native-svg)
types.ts # public types
index.ts # main entry
core.ts # framework-agnostic /core entry
example/ # runnable Expo demo app (imports the library from ../src)
The core/ and utils/ folders are copied verbatim from the web package — the geometry and math are identical. Only the hooks and components differ.
npm install # install deps
# verify everything (same as CI)
npm run typecheck && npm run lint && npm run test && npm run build && npm run check:package
# run the demo app
cd example
npm install
npm run start # press a / i / w for Android / iOS / webnpm run testruns the pure-coreunit tests with Vitest (no React Native needed — the geometry/scale math is framework-agnostic).npm run buildusesreact-native-builder-bobto emit ESM, CommonJS, and TypeScript declaration outputs. Metro consumes the TypeScriptsourcedirectly during development, so edits hot-reload in the demo without a rebuild.npm run check:packagevalidates the published package layout withpublint.
CI (.github/workflows/ci.yml) runs the same checks on every PR.
The package is versioned with Changesets and published automatically from CI.
npm run changeset # describe the change; pick the semver bump
git commit -am "feat: ..." && git pushOn push to main, the Release workflow opens a "Version Packages" PR. Merging that PR bumps the version, updates CHANGELOG.md, and publishes to npm with provenance. Publishing uses npm OIDC trusted publishing (configure the package's trusted publisher on npm, or set an NPM_TOKEN repository secret).
MIT © Moshe Hatab
