{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "activity-rings",
  "type": "registry:item",
  "title": "Activity Rings",
  "description": "Concentric progress rings that wind up one after another; a ring past its goal laps itself.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "@shopify/react-native-skia",
    "react-native-reanimated",
    "react-native-worklets"
  ],
  "registryDependencies": [
    "https://kinetik-ui.dev/r/kinetik-tokens.json",
    "https://kinetik-ui.dev/r/kinetik-motion.json",
    "https://kinetik-ui.dev/r/kinetik-haptics.json"
  ],
  "files": [
    {
      "path": "src/components/kinetik/activity-rings.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/activity-rings.tsx",
      "content": "import {\n  Canvas,\n  Circle,\n  Group,\n  Path,\n  Shadow,\n  Skia,\n  SweepGradient,\n  vec,\n  type SkPath,\n} from '@shopify/react-native-skia';\nimport { useEffect, useMemo, useState, type ReactNode } from 'react';\nimport { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport Animated, {\n  Easing,\n  useAnimatedStyle,\n  useDerivedValue,\n  useSharedValue,\n  withDelay,\n  withTiming,\n} from 'react-native-reanimated';\n\nimport { useHaptic } from '../../lib/kinetik/hooks/use-haptic';\nimport { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';\nimport { timing } from '../../lib/kinetik/motion/motion';\nimport { useKinetikTheme } from '../../lib/kinetik/tokens/theme';\nimport { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';\n\nexport type ActivityRing = {\n  key: string;\n  label: string;\n  value: number;\n  goal: number;\n  /** Ring colour. Defaults to the theme's azure, sky, success and warning in turn. */\n  color?: string;\n};\n\nexport type ActivityRingsProps = {\n  /** One to four rings, outermost first. */\n  rings: readonly ActivityRing[];\n  /** Diameter. Defaults to 180. */\n  size?: number;\n  /** Width of each ring. Defaults to 18. */\n  thickness?: number;\n  /** Space between rings. Defaults to 4. */\n  gap?: number;\n  /** Shown in the middle. */\n  center?: ReactNode;\n  /** Rows beside the rings; tap one to bring its ring forward. Defaults to true. */\n  legend?: boolean;\n  /** Formats a ring's value and goal, e.g. \"420 / 600 kcal\". */\n  formatValue?: (ring: ActivityRing) => string;\n  /** A tick when a ring is brought forward. Defaults to true. */\n  haptics?: boolean;\n  /** A summary of what the rings show, e.g. \"Today's activity\". */\n  accessibilityLabel: string;\n  style?: StyleProp<ViewStyle>;\n};\n\n/** Laps a ring can show: past the goal it goes round again, up to twice. */\nconst MAX_LAPS = 2;\n\n/**\n * Concentric progress rings that wind up one after another. A ring past its\n * goal keeps going round, its second lap casting a shadow on the first, and\n * each ring brightens from its start to its tip.\n */\nexport function ActivityRings({\n  rings,\n  size = 180,\n  thickness = 18,\n  gap = 4,\n  center,\n  legend = true,\n  formatValue = (r) => `${Math.round(r.value)} / ${Math.round(r.goal)}`,\n  haptics = true,\n  accessibilityLabel,\n  style,\n}: ActivityRingsProps) {\n  const { colors, font, radius, space, scheme } = useKinetikTheme();\n  const reduced = useReduceMotion();\n  const haptic = useHaptic(haptics);\n  // The second lap's shadow falls on the first; it is dark in both schemes.\n  const shade = withAlpha(scheme === 'dark' ? colors.background : colors.text, 0.45);\n  const [focus, setFocus] = useState<number | null>(null);\n\n  const palette = [colors.azure, colors.sky, colors.success, colors.warning];\n  const tint = (r: ActivityRing, i: number) => r.color ?? palette[i % palette.length]!;\n  const share = (r: ActivityRing) => (r.goal > 0 ? Math.max(0, r.value) / r.goal : 0);\n\n  // One circle per ring, starting at 3 o'clock; the group turns it to start at 12.\n  const circles = useMemo(\n    () =>\n      rings.map((_, i) => {\n        const r = size / 2 - thickness / 2 - i * (thickness + gap);\n        return {\n          r,\n          path: Skia.PathBuilder.Make()\n            .addArc({ x: size / 2 - r, y: size / 2 - r, width: r * 2, height: r * 2 }, 0, 359.99)\n            .detach(),\n        };\n      }),\n    // Only the geometry matters here, not the values.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [rings.length, size, thickness, gap],\n  );\n\n  const choose = (i: number) => {\n    const next = focus === i ? null : i;\n    setFocus(next);\n    if (next !== null) haptic('tick');\n  };\n\n  const pct = (r: ActivityRing) => `${Math.round(share(r) * 100)}%`;\n\n  return (\n    <View style={[styles.row, { gap: space[6] }, style]}>\n      <View\n        accessible\n        accessibilityRole=\"image\"\n        accessibilityLabel={`${accessibilityLabel}. ${rings\n          .map((r) => `${r.label} ${formatValue(r)}, ${pct(r)}`)\n          .join('; ')}`}\n        style={{ width: size, height: size }}\n      >\n        <View pointerEvents=\"none\" style={StyleSheet.absoluteFill}>\n          <Canvas style={StyleSheet.absoluteFill}>\n            <Group origin={vec(size / 2, size / 2)} transform={[{ rotate: -Math.PI / 2 }]}>\n              {rings.map((ring, i) => (\n                <Lap\n                  key={ring.key}\n                  index={i}\n                  size={size}\n                  r={circles[i]!.r}\n                  path={circles[i]!.path}\n                  share={share(ring)}\n                  color={tint(ring, i)}\n                  thickness={thickness}\n                  shade={shade}\n                  dimmed={focus !== null && focus !== i}\n                  reduced={reduced}\n                />\n              ))}\n            </Group>\n          </Canvas>\n        </View>\n        {center ? (\n          <View pointerEvents=\"none\" style={styles.center}>\n            <Center reduced={reduced}>{center}</Center>\n          </View>\n        ) : null}\n      </View>\n\n      {legend ? (\n        <View style={styles.legend}>\n          {rings.map((ring, i) => {\n            const on = focus === i;\n            return (\n              <Pressable\n                key={ring.key}\n                onPress={() => choose(i)}\n                accessibilityRole=\"button\"\n                accessibilityLabel={`${ring.label}, ${formatValue(ring)}, ${pct(ring)}`}\n                accessibilityState={{ selected: on }}\n                style={[\n                  styles.entry,\n                  {\n                    borderRadius: radius.md,\n                    paddingHorizontal: space[3],\n                    backgroundColor: on ? colors.surfaceRaised : 'transparent',\n                    opacity: focus === null || on ? 1 : 0.45,\n                  },\n                ]}\n              >\n                <View style={styles.entryTop}>\n                  <View style={[styles.dot, { backgroundColor: tint(ring, i) }]} />\n                  <Text style={{ color: colors.textMuted, fontSize: font.size.xs, flex: 1 }}>\n                    {ring.label}\n                  </Text>\n                  <Text\n                    style={{\n                      color: tint(ring, i),\n                      fontSize: font.size.xs,\n                      fontWeight: font.weight.semibold,\n                      fontVariant: ['tabular-nums'],\n                    }}\n                  >\n                    {pct(ring)}\n                  </Text>\n                </View>\n                <Text\n                  style={{\n                    color: colors.text,\n                    fontSize: font.size.md,\n                    fontWeight: font.weight.semibold,\n                    fontVariant: ['tabular-nums'],\n                  }}\n                >\n                  {formatValue(ring)}\n                </Text>\n              </Pressable>\n            );\n          })}\n        </View>\n      ) : null}\n    </View>\n  );\n}\n\nfunction Lap({\n  index,\n  size,\n  r,\n  path,\n  share,\n  color,\n  thickness,\n  shade,\n  dimmed,\n  reduced,\n}: {\n  index: number;\n  size: number;\n  r: number;\n  path: SkPath;\n  share: number;\n  color: string;\n  thickness: number;\n  shade: string;\n  dimmed: boolean;\n  reduced: boolean;\n}) {\n  const progress = useSharedValue(reduced ? share : 0);\n  const fade = useSharedValue(1);\n  const target = Math.min(MAX_LAPS, share);\n\n  useEffect(() => {\n    // Rings wind up one after another, the longer ones taking a little longer.\n    const to = withTiming(target, {\n      duration: 900 + Math.min(1, target) * 500,\n      easing: Easing.bezier(0.2, 0, 0, 1),\n    });\n    progress.set(reduced ? target : withDelay(index * 140, to));\n  }, [target, index, reduced, progress]);\n  useEffect(() => {\n    fade.set(withTiming(dimmed ? 0.28 : 1, timing('base', reduced)));\n  }, [dimmed, reduced, fade]);\n\n  const first = useDerivedValue(() => Math.min(1, progress.get()));\n  const second = useDerivedValue(() => Math.max(0, Math.min(1, progress.get() - 1)));\n  const lapped = useDerivedValue(() => (progress.get() > 1 ? 1 : 0));\n  const c = vec(size / 2, size / 2);\n\n  return (\n    <Group opacity={fade}>\n      <Circle c={c} r={r} style=\"stroke\" strokeWidth={thickness} color={withAlpha(color, 0.16)} />\n      <Path path={path} style=\"stroke\" strokeWidth={thickness} strokeCap=\"round\" end={first}>\n        <SweepGradient c={c} colors={[withAlpha(color, 0.72), color]} />\n      </Path>\n      <Group opacity={lapped}>\n        <Path\n          path={path}\n          style=\"stroke\"\n          strokeWidth={thickness}\n          strokeCap=\"round\"\n          color={color}\n          end={second}\n        >\n          <Shadow dx={0} dy={0} blur={thickness * 0.3} color={shade} />\n        </Path>\n      </Group>\n    </Group>\n  );\n}\n\n/** The centre slot, faded in once the rings have started. */\nfunction Center({ children, reduced }: { children: ReactNode; reduced: boolean }) {\n  const t = useSharedValue(reduced ? 1 : 0);\n  useEffect(() => {\n    t.set(withDelay(reduced ? 0 : 300, withTiming(1, timing('slow', reduced))));\n  }, [reduced, t]);\n  const style = useAnimatedStyle(() => ({\n    opacity: t.get(),\n    transform: [{ scale: 0.92 + t.get() * 0.08 }],\n  }));\n  return <Animated.View style={[styles.centerInner, style]}>{children}</Animated.View>;\n}\n\nconst styles = StyleSheet.create({\n  row: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'center' },\n  center: { ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center' },\n  centerInner: { alignItems: 'center' },\n  legend: { gap: 2, minWidth: 160, flexGrow: 1, flexBasis: 160 },\n  entry: { minHeight: minTouchTarget, paddingVertical: 6, justifyContent: 'center', gap: 2 },\n  entryTop: { flexDirection: 'row', alignItems: 'center', gap: 6 },\n  dot: { width: 8, height: 8, borderRadius: 4 },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "component",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "data",
    "web": "live",
    "perf": {
      "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.",
      "measurements": [
        {
          "device": "Pixel 9 emulator, API 35",
          "kind": "emulator",
          "date": "2026-10-12",
          "build": "release",
          "frameMs": 18,
          "p90Ms": 28,
          "baselineMs": 20,
          "jankyPercent": 3.8,
          "atRest": "idle",
          "memoryMb": 153
        }
      ]
    },
    "a11y": {
      "reducedMotion": "Rings appear filled to their values and selection fades without delay.",
      "screenReader": "The rings read as an image listing every ring's value, goal and share; legend rows are buttons with a selected state.",
      "touchTarget": "Legend rows are at least 44 pt tall and span the legend's width."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { ActivityRings } from '@/components/kinetik/activity-rings';\n\n<ActivityRings\n  accessibilityLabel=\"Today's activity\"\n  rings={[\n    { key: 'move', label: 'Move', value: 420, goal: 600 },\n    { key: 'steps', label: 'Steps', value: 9800, goal: 8000 },\n    { key: 'focus', label: 'Focus', value: 35, goal: 60 },\n  ]}\n  formatValue={(r) => `${r.value} / ${r.goal}`}\n/>;\n```\n\nRings 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`.\n\n## When to use\n\n- Daily goals with a clear target: activity, habits, savings, reading.\n- Dashboards where two to four related goals are read together at a glance.\n\n## When not to use\n\n- Values without a goal. Use a bar or line chart.\n- More than four series. Inner rings get too small to read.\n\n## Notes\n\n- A ring past its goal keeps going round, up to two laps, and the second lap casts a shadow on the first.\n- Tap a legend row to bring its ring forward; the others step back.\n- With reduced motion the rings appear already filled.\n"
  },
  "docs": "Prefer `npx kinetik-ui add activity-rings`: it installs dependencies with expo install, so versions match your Expo SDK."
}
