{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "confetti-burst",
  "type": "registry:item",
  "title": "Confetti Burst",
  "description": "A burst of confetti drawn as one Skia Atlas, with physics on the UI thread.",
  "categories": [
    "feedback"
  ],
  "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/confetti-burst.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/confetti-burst.tsx",
      "content": "import {\n  Atlas,\n  Canvas,\n  Circle,\n  Group,\n  Rect,\n  Skia,\n  rect,\n  useRSXformBuffer,\n  useTexture,\n  type SkSize,\n} from '@shopify/react-native-skia';\nimport { useEffect, useImperativeHandle, useMemo, useRef, type ReactNode, type Ref } from 'react';\nimport { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { useFrameCallback, useSharedValue } from 'react-native-reanimated';\nimport { scheduleOnRN } from 'react-native-worklets';\n\nimport { useHaptic } from '../../lib/kinetik/hooks/use-haptic';\nimport { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';\nimport { useKinetikTheme } from '../../lib/kinetik/tokens/theme';\n\nexport type ConfettiHandle = {\n  /** Fires a burst from a point in the component's coordinates (defaults to `origin`). */\n  burst: (at?: { x: number; y: number }) => void;\n};\n\nexport type ConfettiBurstProps = {\n  /** Content the confetti flies over. */\n  children?: ReactNode;\n  /** Particles per burst, up to 300. Defaults to 90. */\n  count?: number;\n  /** Particle colours. Defaults to the theme accents. */\n  colors?: string[];\n  /**\n   * Where bursts start, as fractions of the size (0–1). Defaults to the\n   * centre, slightly below the middle.\n   */\n  origin?: { x: number; y: number };\n  /** Launch speed multiplier. Defaults to 1. */\n  power?: number;\n  /** Change this value to fire a burst (e.g. a counter). */\n  trigger?: number | string;\n  /** A success haptic with each burst. Defaults to true. */\n  haptics?: boolean;\n  /** Imperative handle with `burst()`. */\n  ref?: Ref<ConfettiHandle>;\n  style?: StyleProp<ViewStyle>;\n};\n\ntype Particle = {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  r: number;\n  vr: number;\n  /** 0–1, shrinks towards the end of life. */\n  scale: number;\n  age: number;\n  life: number;\n  /** Sprite: 0 ribbon, 1 dot. */\n  shape: number;\n};\n\nconst MAX = 300;\nconst GRAVITY = 1400;\nconst DRAG = 1.4;\n\n// Two white sprites in one small texture; Atlas tints each particle.\nconst RIBBON = rect(0, 0, 12, 7);\nconst DOT = rect(16, 0, 8, 8);\nconst TEXTURE = { width: 24, height: 8 };\n\n/**\n * A burst of confetti drawn as one Skia Atlas: hundreds of tinted sprites in\n * a single draw call, with physics stepped on the UI thread only while the\n * burst is in the air.\n */\nexport function ConfettiBurst({\n  children,\n  count = 90,\n  colors,\n  origin = { x: 0.5, y: 0.55 },\n  power = 1,\n  trigger,\n  haptics = true,\n  ref,\n  style,\n}: ConfettiBurstProps) {\n  const theme = useKinetikTheme();\n  const reduced = useReduceMotion();\n  const haptic = useHaptic(haptics);\n  const n = Math.max(1, Math.min(MAX, Math.round(count)));\n  const size = useSharedValue<SkSize>({ width: 0, height: 0 });\n  const particles = useSharedValue<Particle[]>([]);\n  const tick = useSharedValue(0);\n  const alive = useSharedValue(0);\n\n  // The highlight is left out: it is white in both schemes and vanishes on a light ground.\n  const palette = colors ?? [\n    theme.colors.ember,\n    theme.colors.ice,\n    theme.colors.textMuted,\n    theme.colors.textSubtle,\n  ];\n  const paletteKey = palette.join('|');\n  // Keyed on the colours' text so a new array with the same colours is free.\n  const tints = useMemo(() => {\n    const list = paletteKey.split('|');\n    return Array.from({ length: n }, (_, i) => Skia.Color(list[i % list.length] ?? 'white'));\n  }, [n, paletteKey]);\n  const sprites = useMemo(\n    () => Array.from({ length: n }, (_, i) => (i % 3 === 0 ? DOT : RIBBON)),\n    [n],\n  );\n\n  const texture = useTexture(\n    <Group>\n      <Rect x={RIBBON.x} y={RIBBON.y} width={RIBBON.width} height={RIBBON.height} color=\"white\" />\n      <Circle cx={DOT.x + 4} cy={DOT.y + 4} r={4} color=\"white\" />\n    </Group>,\n    TEXTURE,\n  );\n\n  const stopRef = useRef<(() => void) | null>(null);\n  const stop = () => stopRef.current?.();\n\n  const physics = useFrameCallback((info) => {\n    const dt = Math.min(0.05, (info.timeSincePreviousFrame ?? 16) / 1000);\n    particles.modify((list) => {\n      'worklet';\n      let living = 0;\n      for (const p of list) {\n        if (p.age >= p.life) continue;\n        living++;\n        p.age += dt;\n        p.vx -= p.vx * DRAG * dt;\n        p.vy += GRAVITY * dt - p.vy * DRAG * dt;\n        p.x += p.vx * dt;\n        p.y += p.vy * dt;\n        p.r += p.vr * dt;\n        const remaining = p.life - p.age;\n        p.scale = remaining < 0.5 ? Math.max(0, remaining / 0.5) : 1;\n      }\n      alive.set(living);\n      return list;\n    });\n    tick.set(tick.get() + 1);\n    if (alive.get() === 0) scheduleOnRN(stop);\n  }, false);\n\n  useEffect(() => {\n    stopRef.current = () => physics.setActive(false);\n  }, [physics]);\n\n  const fire = (at?: { x: number; y: number }) => {\n    haptic('success');\n    if (reduced) return;\n    const { width, height } = size.get();\n    const ox = at?.x ?? width * origin.x;\n    const oy = at?.y ?? height * origin.y;\n    const k = Math.max(0.2, power);\n    particles.set(\n      Array.from({ length: n }, (_, i) => {\n        // Mostly upwards, fanning out to the sides.\n        const angle = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 1.1;\n        const speed = (520 + Math.random() * 680) * k;\n        return {\n          x: ox,\n          y: oy,\n          vx: Math.cos(angle) * speed,\n          vy: Math.sin(angle) * speed,\n          r: Math.random() * Math.PI * 2,\n          vr: (Math.random() - 0.5) * 14,\n          scale: 1,\n          age: 0,\n          life: 1.6 + Math.random() * 1.2,\n          shape: i % 3 === 0 ? 1 : 0,\n        };\n      }),\n    );\n    physics.setActive(true);\n  };\n\n  useImperativeHandle(ref, () => ({ burst: fire }));\n\n  const first = useRef(true);\n  useEffect(() => {\n    if (first.current) {\n      first.current = false;\n      return;\n    }\n    if (trigger !== undefined) fire();\n    // Fires only when `trigger` changes.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [trigger]);\n\n  const transforms = useRSXformBuffer(n, (val, i) => {\n    'worklet';\n    tick.get();\n    const p = particles.get()[i];\n    if (!p || p.age >= p.life) {\n      val.set(0, 0, -100, -100);\n      return;\n    }\n    const sprite = p.shape === 1 ? DOT : RIBBON;\n    const s = p.scale;\n    const c = Math.cos(p.r) * s;\n    const sn = Math.sin(p.r) * s;\n    // Rotate about the sprite's centre.\n    const hw = sprite.width / 2;\n    const hh = sprite.height / 2;\n    val.set(c, sn, p.x - (c * hw - sn * hh), p.y - (sn * hw + c * hh));\n  });\n\n  return (\n    <View style={[styles.root, style]}>\n      {children}\n      {/* The wrapper lets touches through: on the web the canvas itself ignores pointerEvents. */}\n      <View\n        pointerEvents=\"none\"\n        style={StyleSheet.absoluteFill}\n        accessibilityElementsHidden\n        importantForAccessibility=\"no-hide-descendants\"\n      >\n        <Canvas style={StyleSheet.absoluteFill} onSize={size}>\n          <Atlas\n            image={texture}\n            sprites={sprites}\n            transforms={transforms}\n            colors={tints}\n            colorBlendMode=\"modulate\"\n          />\n        </Canvas>\n      </View>\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  root: {},\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "component",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "feedback",
    "web": "live",
    "perf": {
      "budget": "All particles are one Atlas draw call. Physics runs in a frame callback only while particles are alive (about 3 s per burst), then stops.",
      "measurements": [
        {
          "device": "Pixel 9 emulator, API 35",
          "kind": "emulator",
          "date": "2026-10-12",
          "build": "release",
          "frameMs": 27,
          "p90Ms": 31,
          "baselineMs": 20,
          "jankyPercent": 2.95,
          "atRest": "idle",
          "memoryMb": 154
        }
      ]
    },
    "a11y": {
      "reducedMotion": "No particles; the success haptic still plays.",
      "screenReader": "Decorative: the canvas is hidden. Announce the event that triggered the burst in your own UI.",
      "touchTarget": "Not interactive; touches pass through to the content."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { ConfettiBurst, type ConfettiHandle } from '@/components/kinetik/confetti-burst';\n\nconst confetti = useRef<ConfettiHandle>(null);\n\n<ConfettiBurst ref={confetti} style={StyleSheet.absoluteFill}>\n  <Checkout onPaid={() => confetti.current?.burst()} />\n</ConfettiBurst>;\n```\n\nFire it with the ref (`burst()` or `burst({ x, y })` to start from a point) or by changing `trigger`. The confetti is drawn over the children and never blocks touches.\n\n## When to use\n\n- Rare moments worth celebrating: first purchase, a finished goal, a streak.\n- Paired with a clear message about what happened.\n\n## When not to use\n\n- Routine confirmations. Celebrating every save makes the moment meaningless; use a toast.\n- On its own for important feedback. Screen readers do not see confetti; say what happened in text.\n\n## Notes\n\n- Hundreds of particles cost one draw call; the physics runs only while they are in the air.\n- Under reduced motion no particles appear, but the success haptic still plays.\n- `colors` defaults to the theme accents; pass brand colours for campaigns.\n"
  },
  "docs": "Prefer `npx kinetik-ui add confetti-burst`: it installs dependencies with expo install, so versions match your Expo SDK."
}
