Kinetik UI

Activity Rings

.md

Concentric progress rings that wind up one after another; a ring past its goal laps itself.

v1.0.0Live on web@shopify/react-native-skiareact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add activity-rings

The 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

  1. Install dependencies
    npx expo install @shopify/react-native-skia react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/activity-rings.tsx
    View source files

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

PropTypeDefaultDescription
ringsrequiredreadonly ActivityRing[]—One to four rings, outermost first.
accessibilityLabelrequiredstring—A summary of what the rings show, e.g. "Today's activity".
sizenumber180Diameter. Defaults to 180.
thicknessnumber18Width of each ring. Defaults to 18.
gapnumber4Space between rings. Defaults to 4.
centerReactNode—Shown in the middle.
legendbooleantrueRows 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".
hapticsbooleantrueA tick when a ring is brought forward. Defaults to true.
styleStyleProp<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 },
});