{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "like-burst",
  "type": "registry:item",
  "title": "Like Burst",
  "description": "A like button whose heart pops with a ring of light and a spray of sparks while the count rolls over.",
  "categories": [
    "interaction"
  ],
  "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/like-burst.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/like-burst.tsx",
      "content": "import { Canvas, Circle, Group, Path, Skia, vec } from '@shopify/react-native-skia';\nimport { useEffect, useRef, useState } from 'react';\nimport {\n  Pressable,\n  StyleSheet,\n  View,\n  type StyleProp,\n  type TextStyle,\n  type ViewStyle,\n} from 'react-native';\nimport Animated, {\n  Easing,\n  useAnimatedStyle,\n  useDerivedValue,\n  useSharedValue,\n  withSequence,\n  withSpring,\n  withTiming,\n  type SharedValue,\n} from 'react-native-reanimated';\n\nimport { useHaptic } from '../../lib/kinetik/hooks/use-haptic';\nimport { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';\nimport { spring, timing } from '../../lib/kinetik/motion/motion';\nimport { useKinetikTheme } from '../../lib/kinetik/tokens/theme';\nimport { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';\n\nexport type LikeBurstProps = {\n  /** Controlled state. Leave unset to let the button keep its own. */\n  liked?: boolean;\n  /** Initial state when uncontrolled. Defaults to false. */\n  defaultLiked?: boolean;\n  onLikedChange?: (liked: boolean) => void;\n  /**\n   * Likes from everyone else. The viewer's own like is added on top, so the\n   * number moves the moment they tap. Leave unset to hide the count.\n   */\n  count?: number;\n  formatCount?: (count: number) => string;\n  /** Heart colour once liked. Defaults to the theme danger red. */\n  color?: string;\n  /** Heart size. Defaults to 26. */\n  size?: number;\n  /** Defaults to 'Like'. */\n  accessibilityLabel?: string;\n  /** A light tap on like. Defaults to true. */\n  haptics?: boolean;\n  disabled?: boolean;\n  testID?: string;\n  style?: StyleProp<ViewStyle>;\n  countStyle?: StyleProp<TextStyle>;\n};\n\n/** Sparks around the heart: angle in degrees, and whether it is a large one. */\nconst SPARKS = Array.from({ length: 10 }, (_, i) => ({\n  angle: -90 + i * 36 + (i % 2 ? 10 : 0),\n  large: i % 2 === 0,\n}));\n\n/** A heart on a 24 pt grid, drawn once. */\nconst HEART = Skia.Path.MakeFromSVGString(\n  'M12 20.3C11.7 20.3 11.4 20.2 11.2 20C7.4 16.7 3 13.1 3 8.9C3 6.2 5.1 4 7.8 4C9.5 4 11 4.8 12 6.1C13 4.8 14.5 4 16.2 4C18.9 4 21 6.2 21 8.9C21 13.1 16.6 16.7 12.8 20C12.6 20.2 12.3 20.3 12 20.3Z',\n)!;\n\n/** Compact counts: 980, 1.2K, 24K, 3.1M. */\nexport function compactCount(n: number): string {\n  if (n < 1000) return String(n);\n  const [d, s] = n < 1_000_000 ? [1000, 'K'] : [1_000_000, 'M'];\n  const v = n / d;\n  return `${v < 10 ? Math.floor(v * 10) / 10 : Math.floor(v)}${s}`;\n}\n\n/**\n * A like button that celebrates: the heart dips, then pops full with a ring\n * of light and a spray of sparks, and the count rolls to its new number.\n * Unliking is quiet: the heart just empties.\n */\nexport function LikeBurst({\n  liked,\n  defaultLiked = false,\n  onLikedChange,\n  count,\n  formatCount = compactCount,\n  color,\n  size = 26,\n  accessibilityLabel = 'Like',\n  haptics = true,\n  disabled = false,\n  testID,\n  style,\n  countStyle,\n}: LikeBurstProps) {\n  const { colors, font } = useKinetikTheme();\n  const reduced = useReduceMotion();\n  const haptic = useHaptic(haptics);\n  const [inner, setInner] = useState(defaultLiked);\n  const on = liked ?? inner;\n  const accent = color ?? colors.danger;\n\n  // The canvas leaves room around the heart for the burst.\n  const box = size * 2.2;\n  const unit = size / 24;\n  const c = box / 2;\n\n  const fill = useSharedValue(on ? 1 : 0);\n  const pop = useSharedValue(1);\n  const burst = useSharedValue(1);\n  const pressed = useSharedValue(0);\n\n  // Only a change made here celebrates; a like that arrives from the parent just fills.\n  const celebrate = useRef(false);\n  useEffect(() => {\n    const play = celebrate.current && on && !reduced;\n    celebrate.current = false;\n    fill.set(withTiming(on ? 1 : 0, timing(on ? 'quick' : 'base', reduced)));\n    if (!play) return;\n    pop.set(withSequence(withTiming(0.7, { duration: 90 }), withSpring(1, spring('bouncy'))));\n    burst.set(0);\n    burst.set(withTiming(1, { duration: 620, easing: Easing.out(Easing.cubic) }));\n  }, [on, reduced, fill, pop, burst]);\n\n  const toggle = () => {\n    const next = !on;\n    celebrate.current = true;\n    if (liked === undefined) setInner(next);\n    if (next) haptic('tap');\n    onLikedChange?.(next);\n  };\n\n  const heart = useDerivedValue(() => [\n    { translateX: c - 12 * unit * pop.get() },\n    { translateY: c - 12 * unit * pop.get() },\n    { scale: unit * pop.get() },\n  ]);\n  const outline = useDerivedValue(() => 1 - fill.get());\n  // The ring opens fast and thins to nothing; sparks fly out a little behind it.\n  const ringR = useDerivedValue(() => size * (0.25 + burst.get() * 0.75));\n  const ringW = useDerivedValue(() => Math.max(0, (1 - burst.get()) * size * 0.32));\n  const ringO = useDerivedValue(() => (burst.get() < 1 ? 1 : 0));\n\n  const shell = useAnimatedStyle(() => ({\n    transform: [{ scale: 1 - pressed.get() * 0.08 }],\n  }));\n\n  const total = count === undefined ? undefined : count + (on ? 1 : 0);\n  const slop = Math.max(0, (minTouchTarget - box) / 2);\n\n  return (\n    <Pressable\n      testID={testID}\n      disabled={disabled}\n      onPress={toggle}\n      onPressIn={() => pressed.set(withSpring(1, spring('snappy', reduced)))}\n      onPressOut={() => pressed.set(withSpring(0, spring('bouncy', reduced)))}\n      hitSlop={slop}\n      accessibilityRole=\"togglebutton\"\n      accessibilityLabel={accessibilityLabel}\n      accessibilityState={{ checked: on, disabled }}\n      accessibilityValue={total === undefined ? undefined : { text: formatCount(total) }}\n      style={[styles.row, { opacity: disabled ? 0.45 : 1, minHeight: minTouchTarget }, style]}\n    >\n      <Animated.View style={[{ width: box, height: box, margin: -size * 0.35 }, shell]}>\n        {/* Wrapped: the web canvas ignores pointerEvents and would swallow the press. */}\n        <View pointerEvents=\"none\" style={StyleSheet.absoluteFill}>\n          <Canvas style={StyleSheet.absoluteFill}>\n            <Circle\n              c={vec(c, c)}\n              r={ringR}\n              style=\"stroke\"\n              strokeWidth={ringW}\n              color={withAlpha(accent, 0.55)}\n              opacity={ringO}\n            />\n            {SPARKS.map((s, i) => (\n              <Spark\n                key={i}\n                center={c}\n                angle={s.angle}\n                reach={size * (s.large ? 0.95 : 0.78)}\n                dot={size * (s.large ? 0.09 : 0.06)}\n                color={s.large ? accent : colors.sky}\n                burst={burst}\n              />\n            ))}\n            <Group transform={heart}>\n              <Path\n                path={HEART}\n                style=\"stroke\"\n                strokeWidth={1.9}\n                strokeJoin=\"round\"\n                color={colors.textMuted}\n                opacity={outline}\n              />\n              <Path path={HEART} color={accent} opacity={fill} />\n            </Group>\n          </Canvas>\n        </View>\n      </Animated.View>\n      {total !== undefined ? (\n        <Roll\n          text={formatCount(total)}\n          up={on}\n          reduced={reduced}\n          style={[\n            {\n              color: on ? accent : colors.textMuted,\n              fontSize: font.size.sm,\n              fontWeight: font.weight.semibold,\n            },\n            countStyle,\n          ]}\n        />\n      ) : null}\n    </Pressable>\n  );\n}\n\nfunction Spark({\n  center,\n  angle,\n  reach,\n  dot,\n  color,\n  burst,\n}: {\n  center: number;\n  angle: number;\n  reach: number;\n  dot: number;\n  color: string;\n  burst: SharedValue<number>;\n}) {\n  const rad = (angle * Math.PI) / 180;\n  const at = useDerivedValue(() => {\n    // Sparks leave once the ring has opened a little.\n    const t = Math.max(0, (burst.get() - 0.15) / 0.85);\n    const d = reach * (0.55 + t * 0.45);\n    return vec(center + Math.cos(rad) * d, center + Math.sin(rad) * d);\n  });\n  const r = useDerivedValue(() => {\n    const t = burst.get();\n    return t <= 0.15 || t >= 1 ? 0 : dot * (1 - (t - 0.15) / 0.85);\n  });\n  return <Circle c={at} r={r} color={color} />;\n}\n\n/** The count: the old number slides out as the new one slides in. */\nfunction Roll({\n  text,\n  up,\n  reduced,\n  style,\n}: {\n  text: string;\n  up: boolean;\n  reduced: boolean;\n  style: StyleProp<TextStyle>;\n}) {\n  const [shown, setShown] = useState({ now: text, was: text });\n  const t = useSharedValue(1);\n  const dir = up ? 1 : -1;\n\n  if (shown.now !== text) setShown({ now: text, was: shown.now });\n\n  useEffect(() => {\n    if (shown.now === shown.was) return;\n    t.set(0);\n    t.set(withTiming(1, timing('base', reduced)));\n  }, [shown, reduced, t]);\n\n  const incoming = useAnimatedStyle(() => ({\n    opacity: t.get(),\n    transform: [{ translateY: (1 - t.get()) * 10 * dir }],\n  }));\n  const outgoing = useAnimatedStyle(() => ({\n    opacity: 1 - t.get(),\n    transform: [{ translateY: -t.get() * 10 * dir }],\n  }));\n\n  return (\n    <View\n      style={styles.roll}\n      accessibilityElementsHidden\n      importantForAccessibility=\"no-hide-descendants\"\n    >\n      <Animated.Text style={[style, styles.digits, incoming]}>{shown.now}</Animated.Text>\n      {shown.was !== shown.now ? (\n        <Animated.Text style={[style, styles.digits, styles.stacked, outgoing]}>\n          {shown.was}\n        </Animated.Text>\n      ) : null}\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  row: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', gap: 6 },\n  roll: { overflow: 'hidden', paddingVertical: 2 },\n  digits: { fontVariant: ['tabular-nums'] },\n  stacked: { position: 'absolute', top: 2, left: 0 },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "component",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "interaction",
    "web": "live",
    "perf": {
      "budget": "One small Skia canvas: a ring, ten dots and the heart, all read from three shared values. Nothing runs at rest.",
      "measurements": [
        {
          "device": "Pixel 9 emulator, API 35",
          "kind": "emulator",
          "date": "2026-10-12",
          "build": "release",
          "frameMs": 20,
          "p90Ms": 31,
          "baselineMs": 20,
          "jankyPercent": 4.47,
          "atRest": "idle",
          "memoryMb": 153
        }
      ]
    },
    "a11y": {
      "reducedMotion": "The heart fills without the pop, the ring or the sparks, and the count changes in place.",
      "screenReader": "A toggle button with a checked state; the count is its value.",
      "touchTarget": "The heart's box is 57 pt at the default size and the row is at least 44 pt tall."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { LikeBurst } from '@/components/kinetik/like-burst';\n\n<LikeBurst count={post.likesFromOthers} liked={post.liked} onLikedChange={toggleLike} />;\n```\n\n`count` is everyone else's likes; the viewer's own like is added on top, so the number moves the moment they tap, before your request returns. Counts are compact by default (1.2K, 3.1M); pass `formatCount` to change that.\n\n## When to use\n\n- Likes, favourites and saves on posts, photos and products, where a little delight rewards the tap.\n- Feeds, where the heart sits beside a count.\n\n## When not to use\n\n- Settings and anything that is not an expression of taste. Use a switch.\n- Many hearts in a dense grid. The burst needs a little room and draws attention.\n\n## Notes\n\n- Only a tap here bursts. A like that arrives from the parent (another device, a refetch) just fills the heart.\n- The burst reaches about a third of the heart's size past its box. Leave a little space around it.\n- With reduced motion the heart fills quietly and the count changes in place.\n"
  },
  "docs": "Prefer `npx kinetik-ui add like-burst`: it installs dependencies with expo install, so versions match your Expo SDK."
}
