Activity Rings
Concentric progress rings that wind up one after another; a ring past its goal laps itself.
import {
Canvas,
Circle,
Group,
Path,
Shadow,
Skia,
SweepGradient,
vec,
type SkPath,
} from '@shopify/react-native-skia';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
Easing,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withDelay,
withTiming,
} from 'react-native-reanimated';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type ActivityRing = {
key: string;
label: string;
value: number;
goal: number;
/** Ring colour. Defaults to the theme's azure, sky, success and warning in turn. */
color?: string;
};
export type ActivityRingsProps = {
/** One to four rings, outermost first. */
rings: readonly ActivityRing[];
/** Diameter. Defaults to 180. */
size?: number;
/** Width of each ring. Defaults to 18. */
thickness?: number;
/** Space between rings. Defaults to 4. */
gap?: number;
/** Shown in the middle. */
center?: ReactNode;
/** Rows beside the rings; tap one to bring its ring forward. Defaults to true. */
legend?: boolean;
/** Formats a ring's value and goal, e.g. "420 / 600 kcal". */
formatValue?: (ring: ActivityRing) => string;
/** A tick when a ring is brought forward. Defaults to true. */
haptics?: boolean;
/** A summary of what the rings show, e.g. "Today's activity". */
accessibilityLabel: string;
style?: StyleProp<ViewStyle>;
};
/** Laps a ring can show: past the goal it goes round again, up to twice. */
const MAX_LAPS = 2;
/**
* Concentric progress rings that wind up one after another. A ring past its
* goal keeps going round, its second lap casting a shadow on the first, and
* each ring brightens from its start to its tip.
*/
export function ActivityRings({
rings,
size = 180,
thickness = 18,
gap = 4,
center,
legend = true,
formatValue = (r) => `${Math.round(r.value)} / ${Math.round(r.goal)}`,
haptics = true,
accessibilityLabel,
style,
}: ActivityRingsProps) {
const { colors, font, radius, space, scheme } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
// The second lap's shadow falls on the first; it is dark in both schemes.
const shade = withAlpha(scheme === 'dark' ? colors.background : colors.text, 0.45);
const [focus, setFocus] = useState<number | null>(null);
const palette = [colors.azure, colors.sky, colors.success, colors.warning];
const tint = (r: ActivityRing, i: number) => r.color ?? palette[i % palette.length]!;
const share = (r: ActivityRing) => (r.goal > 0 ? Math.max(0, r.value) / r.goal : 0);
// One circle per ring, starting at 3 o'clock; the group turns it to start at 12.
const circles = useMemo(
() =>
rings.map((_, i) => {
const r = size / 2 - thickness / 2 - i * (thickness + gap);
return {
r,
path: Skia.PathBuilder.Make()
.addArc({ x: size / 2 - r, y: size / 2 - r, width: r * 2, height: r * 2 }, 0, 359.99)
.detach(),
};
}),
// Only the geometry matters here, not the values.
// eslint-disable-next-line react-hooks/exhaustive-deps
[rings.length, size, thickness, gap],
);
const choose = (i: number) => {
const next = focus === i ? null : i;
setFocus(next);
if (next !== null) haptic('tick');
};
const pct = (r: ActivityRing) => `${Math.round(share(r) * 100)}%`;
return (
<View style={[styles.row, { gap: space[6] }, style]}>
<View
accessible
accessibilityRole="image"
accessibilityLabel={`${accessibilityLabel}. ${rings
.map((r) => `${r.label} ${formatValue(r)}, ${pct(r)}`)
.join('; ')}`}
style={{ width: size, height: size }}
>
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
<Canvas style={StyleSheet.absoluteFill}>
<Group origin={vec(size / 2, size / 2)} transform={[{ rotate: -Math.PI / 2 }]}>
{rings.map((ring, i) => (
<Lap
key={ring.key}
index={i}
size={size}
r={circles[i]!.r}
path={circles[i]!.path}
share={share(ring)}
color={tint(ring, i)}
thickness={thickness}
shade={shade}
dimmed={focus !== null && focus !== i}
reduced={reduced}
/>
))}
</Group>
</Canvas>
</View>
{center ? (
<View pointerEvents="none" style={styles.center}>
<Center reduced={reduced}>{center}</Center>
</View>
) : null}
</View>
{legend ? (
<View style={styles.legend}>
{rings.map((ring, i) => {
const on = focus === i;
return (
<Pressable
key={ring.key}
onPress={() => choose(i)}
accessibilityRole="button"
accessibilityLabel={`${ring.label}, ${formatValue(ring)}, ${pct(ring)}`}
accessibilityState={{ selected: on }}
style={[
styles.entry,
{
borderRadius: radius.md,
paddingHorizontal: space[3],
backgroundColor: on ? colors.surfaceRaised : 'transparent',
opacity: focus === null || on ? 1 : 0.45,
},
]}
>
<View style={styles.entryTop}>
<View style={[styles.dot, { backgroundColor: tint(ring, i) }]} />
<Text style={{ color: colors.textMuted, fontSize: font.size.xs, flex: 1 }}>
{ring.label}
</Text>
<Text
style={{
color: tint(ring, i),
fontSize: font.size.xs,
fontWeight: font.weight.semibold,
fontVariant: ['tabular-nums'],
}}
>
{pct(ring)}
</Text>
</View>
<Text
style={{
color: colors.text,
fontSize: font.size.md,
fontWeight: font.weight.semibold,
fontVariant: ['tabular-nums'],
}}
>
{formatValue(ring)}
</Text>
</Pressable>
);
})}
</View>
) : null}
</View>
);
}
function Lap({
index,
size,
r,
path,
share,
color,
thickness,
shade,
dimmed,
reduced,
}: {
index: number;
size: number;
r: number;
path: SkPath;
share: number;
color: string;
thickness: number;
shade: string;
dimmed: boolean;
reduced: boolean;
}) {
const progress = useSharedValue(reduced ? share : 0);
const fade = useSharedValue(1);
const target = Math.min(MAX_LAPS, share);
useEffect(() => {
// Rings wind up one after another, the longer ones taking a little longer.
const to = withTiming(target, {
duration: 900 + Math.min(1, target) * 500,
easing: Easing.bezier(0.2, 0, 0, 1),
});
progress.set(reduced ? target : withDelay(index * 140, to));
}, [target, index, reduced, progress]);
useEffect(() => {
fade.set(withTiming(dimmed ? 0.28 : 1, timing('base', reduced)));
}, [dimmed, reduced, fade]);
const first = useDerivedValue(() => Math.min(1, progress.get()));
const second = useDerivedValue(() => Math.max(0, Math.min(1, progress.get() - 1)));
const lapped = useDerivedValue(() => (progress.get() > 1 ? 1 : 0));
const c = vec(size / 2, size / 2);
return (
<Group opacity={fade}>
<Circle c={c} r={r} style="stroke" strokeWidth={thickness} color={withAlpha(color, 0.16)} />
<Path path={path} style="stroke" strokeWidth={thickness} strokeCap="round" end={first}>
<SweepGradient c={c} colors={[withAlpha(color, 0.72), color]} />
</Path>
<Group opacity={lapped}>
<Path
path={path}
style="stroke"
strokeWidth={thickness}
strokeCap="round"
color={color}
end={second}
>
<Shadow dx={0} dy={0} blur={thickness * 0.3} color={shade} />
</Path>
</Group>
</Group>
);
}
/** The centre slot, faded in once the rings have started. */
function Center({ children, reduced }: { children: ReactNode; reduced: boolean }) {
const t = useSharedValue(reduced ? 1 : 0);
useEffect(() => {
t.set(withDelay(reduced ? 0 : 300, withTiming(1, timing('slow', reduced))));
}, [reduced, t]);
const style = useAnimatedStyle(() => ({
opacity: t.get(),
transform: [{ scale: 0.92 + t.get() * 0.08 }],
}));
return <Animated.View style={[styles.centerInner, style]}>{children}</Animated.View>;
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'center' },
center: { ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center' },
centerInner: { alignItems: 'center' },
legend: { gap: 2, minWidth: 160, flexGrow: 1, flexBasis: 160 },
entry: { minHeight: minTouchTarget, paddingVertical: 6, justifyContent: 'center', gap: 2 },
entryTop: { flexDirection: 'row', alignItems: 'center', gap: 6 },
dot: { width: 8, height: 8, borderRadius: 4 },
});Installation
npx kinetik-ui add activity-ringspnpm dlx kinetik-ui add activity-ringsbunx kinetik-ui add activity-ringsyarn dlx kinetik-ui add activity-ringsThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/activity-rings.json
Install manually
- Install dependencies
npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/activity-rings.tsx
Usage
import { ActivityRings } from '@/components/kinetik/activity-rings';
<ActivityRings
accessibilityLabel="Today's activity"
rings={[
{ key: 'move', label: 'Move', value: 420, goal: 600 },
{ key: 'steps', label: 'Steps', value: 9800, goal: 8000 },
{ key: 'focus', label: 'Focus', value: 35, goal: 60 },
]}
formatValue={(r) => `${r.value} / ${r.goal}`}
/>;Rings are listed outermost first and coloured azure, sky, success and warning in turn unless you pass color. Put a figure or a date in the middle with center.
When to use
- Daily goals with a clear target: activity, habits, savings, reading.
- Dashboards where two to four related goals are read together at a glance.
When not to use
- Values without a goal. Use a bar or line chart.
- More than four series. Inner rings get too small to read.
Notes
- A ring past its goal keeps going round, up to two laps, and the second lap casts a shadow on the first.
- Tap a legend row to bring its ring forward; the others step back.
- With reduced motion the rings appear already filled.
Props
ActivityRingsProps
| Prop | Type | Default | Description |
|---|---|---|---|
ringsrequired | readonly ActivityRing[] | — | One to four rings, outermost first. |
accessibilityLabelrequired | string | — | A summary of what the rings show, e.g. "Today's activity". |
size | number | 180 | Diameter. Defaults to 180. |
thickness | number | 18 | Width of each ring. Defaults to 18. |
gap | number | 4 | Space between rings. Defaults to 4. |
center | ReactNode | — | Shown in the middle. |
legend | boolean | true | Rows beside the rings; tap one to bring its ring forward. Defaults to true. |
formatValue | (ring: ActivityRing) => string | (r) => `${Math.round(r.value)} / ${Math.round(r.goal)}` | Formats a ring's value and goal, e.g. "420 / 600 kcal". |
haptics | boolean | true | A tick when a ring is brought forward. Defaults to true. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Ring paths are built once per size; the wind-up trims them with one shared value per ring, drawn by a single Skia canvas.
- Measured
Real-device measurement pending.
Accessibility
- Reduced motion
- Rings appear filled to their values and selection fades without delay.
- Screen readers
- The rings read as an image listing every ring's value, goal and share; legend rows are buttons with a selected state.
- Touch target
- Legend rows are at least 44 pt tall and span the legend's width.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/activity-rings.tsx
import {
Canvas,
Circle,
Group,
Path,
Shadow,
Skia,
SweepGradient,
vec,
type SkPath,
} from '@shopify/react-native-skia';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
Easing,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withDelay,
withTiming,
} from 'react-native-reanimated';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type ActivityRing = {
key: string;
label: string;
value: number;
goal: number;
/** Ring colour. Defaults to the theme's azure, sky, success and warning in turn. */
color?: string;
};
export type ActivityRingsProps = {
/** One to four rings, outermost first. */
rings: readonly ActivityRing[];
/** Diameter. Defaults to 180. */
size?: number;
/** Width of each ring. Defaults to 18. */
thickness?: number;
/** Space between rings. Defaults to 4. */
gap?: number;
/** Shown in the middle. */
center?: ReactNode;
/** Rows beside the rings; tap one to bring its ring forward. Defaults to true. */
legend?: boolean;
/** Formats a ring's value and goal, e.g. "420 / 600 kcal". */
formatValue?: (ring: ActivityRing) => string;
/** A tick when a ring is brought forward. Defaults to true. */
haptics?: boolean;
/** A summary of what the rings show, e.g. "Today's activity". */
accessibilityLabel: string;
style?: StyleProp<ViewStyle>;
};
/** Laps a ring can show: past the goal it goes round again, up to twice. */
const MAX_LAPS = 2;
/**
* Concentric progress rings that wind up one after another. A ring past its
* goal keeps going round, its second lap casting a shadow on the first, and
* each ring brightens from its start to its tip.
*/
export function ActivityRings({
rings,
size = 180,
thickness = 18,
gap = 4,
center,
legend = true,
formatValue = (r) => `${Math.round(r.value)} / ${Math.round(r.goal)}`,
haptics = true,
accessibilityLabel,
style,
}: ActivityRingsProps) {
const { colors, font, radius, space, scheme } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
// The second lap's shadow falls on the first; it is dark in both schemes.
const shade = withAlpha(scheme === 'dark' ? colors.background : colors.text, 0.45);
const [focus, setFocus] = useState<number | null>(null);
const palette = [colors.azure, colors.sky, colors.success, colors.warning];
const tint = (r: ActivityRing, i: number) => r.color ?? palette[i % palette.length]!;
const share = (r: ActivityRing) => (r.goal > 0 ? Math.max(0, r.value) / r.goal : 0);
// One circle per ring, starting at 3 o'clock; the group turns it to start at 12.
const circles = useMemo(
() =>
rings.map((_, i) => {
const r = size / 2 - thickness / 2 - i * (thickness + gap);
return {
r,
path: Skia.PathBuilder.Make()
.addArc({ x: size / 2 - r, y: size / 2 - r, width: r * 2, height: r * 2 }, 0, 359.99)
.detach(),
};
}),
// Only the geometry matters here, not the values.
// eslint-disable-next-line react-hooks/exhaustive-deps
[rings.length, size, thickness, gap],
);
const choose = (i: number) => {
const next = focus === i ? null : i;
setFocus(next);
if (next !== null) haptic('tick');
};
const pct = (r: ActivityRing) => `${Math.round(share(r) * 100)}%`;
return (
<View style={[styles.row, { gap: space[6] }, style]}>
<View
accessible
accessibilityRole="image"
accessibilityLabel={`${accessibilityLabel}. ${rings
.map((r) => `${r.label} ${formatValue(r)}, ${pct(r)}`)
.join('; ')}`}
style={{ width: size, height: size }}
>
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
<Canvas style={StyleSheet.absoluteFill}>
<Group origin={vec(size / 2, size / 2)} transform={[{ rotate: -Math.PI / 2 }]}>
{rings.map((ring, i) => (
<Lap
key={ring.key}
index={i}
size={size}
r={circles[i]!.r}
path={circles[i]!.path}
share={share(ring)}
color={tint(ring, i)}
thickness={thickness}
shade={shade}
dimmed={focus !== null && focus !== i}
reduced={reduced}
/>
))}
</Group>
</Canvas>
</View>
{center ? (
<View pointerEvents="none" style={styles.center}>
<Center reduced={reduced}>{center}</Center>
</View>
) : null}
</View>
{legend ? (
<View style={styles.legend}>
{rings.map((ring, i) => {
const on = focus === i;
return (
<Pressable
key={ring.key}
onPress={() => choose(i)}
accessibilityRole="button"
accessibilityLabel={`${ring.label}, ${formatValue(ring)}, ${pct(ring)}`}
accessibilityState={{ selected: on }}
style={[
styles.entry,
{
borderRadius: radius.md,
paddingHorizontal: space[3],
backgroundColor: on ? colors.surfaceRaised : 'transparent',
opacity: focus === null || on ? 1 : 0.45,
},
]}
>
<View style={styles.entryTop}>
<View style={[styles.dot, { backgroundColor: tint(ring, i) }]} />
<Text style={{ color: colors.textMuted, fontSize: font.size.xs, flex: 1 }}>
{ring.label}
</Text>
<Text
style={{
color: tint(ring, i),
fontSize: font.size.xs,
fontWeight: font.weight.semibold,
fontVariant: ['tabular-nums'],
}}
>
{pct(ring)}
</Text>
</View>
<Text
style={{
color: colors.text,
fontSize: font.size.md,
fontWeight: font.weight.semibold,
fontVariant: ['tabular-nums'],
}}
>
{formatValue(ring)}
</Text>
</Pressable>
);
})}
</View>
) : null}
</View>
);
}
function Lap({
index,
size,
r,
path,
share,
color,
thickness,
shade,
dimmed,
reduced,
}: {
index: number;
size: number;
r: number;
path: SkPath;
share: number;
color: string;
thickness: number;
shade: string;
dimmed: boolean;
reduced: boolean;
}) {
const progress = useSharedValue(reduced ? share : 0);
const fade = useSharedValue(1);
const target = Math.min(MAX_LAPS, share);
useEffect(() => {
// Rings wind up one after another, the longer ones taking a little longer.
const to = withTiming(target, {
duration: 900 + Math.min(1, target) * 500,
easing: Easing.bezier(0.2, 0, 0, 1),
});
progress.set(reduced ? target : withDelay(index * 140, to));
}, [target, index, reduced, progress]);
useEffect(() => {
fade.set(withTiming(dimmed ? 0.28 : 1, timing('base', reduced)));
}, [dimmed, reduced, fade]);
const first = useDerivedValue(() => Math.min(1, progress.get()));
const second = useDerivedValue(() => Math.max(0, Math.min(1, progress.get() - 1)));
const lapped = useDerivedValue(() => (progress.get() > 1 ? 1 : 0));
const c = vec(size / 2, size / 2);
return (
<Group opacity={fade}>
<Circle c={c} r={r} style="stroke" strokeWidth={thickness} color={withAlpha(color, 0.16)} />
<Path path={path} style="stroke" strokeWidth={thickness} strokeCap="round" end={first}>
<SweepGradient c={c} colors={[withAlpha(color, 0.72), color]} />
</Path>
<Group opacity={lapped}>
<Path
path={path}
style="stroke"
strokeWidth={thickness}
strokeCap="round"
color={color}
end={second}
>
<Shadow dx={0} dy={0} blur={thickness * 0.3} color={shade} />
</Path>
</Group>
</Group>
);
}
/** The centre slot, faded in once the rings have started. */
function Center({ children, reduced }: { children: ReactNode; reduced: boolean }) {
const t = useSharedValue(reduced ? 1 : 0);
useEffect(() => {
t.set(withDelay(reduced ? 0 : 300, withTiming(1, timing('slow', reduced))));
}, [reduced, t]);
const style = useAnimatedStyle(() => ({
opacity: t.get(),
transform: [{ scale: 0.92 + t.get() * 0.08 }],
}));
return <Animated.View style={[styles.centerInner, style]}>{children}</Animated.View>;
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'center' },
center: { ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center' },
centerInner: { alignItems: 'center' },
legend: { gap: 2, minWidth: 160, flexGrow: 1, flexBasis: 160 },
entry: { minHeight: minTouchTarget, paddingVertical: 6, justifyContent: 'center', gap: 2 },
entryTop: { flexDirection: 'row', alignItems: 'center', gap: 6 },
dot: { width: 8, height: 8, borderRadius: 4 },
});