{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "aperture-card",
  "type": "registry:item",
  "title": "Aperture Card",
  "description": "A card closed by a camera-like iris whose blades turn open to reveal the content.",
  "categories": [
    "card"
  ],
  "dependencies": [
    "@shopify/react-native-skia",
    "react-native-gesture-handler",
    "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/aperture-card.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/aperture-card.tsx",
      "content": "import {\n  Canvas,\n  FillType,\n  Group,\n  Path,\n  RadialGradient,\n  Skia,\n  vec,\n  type SkPath,\n  type SkSize,\n} from '@shopify/react-native-skia';\nimport { useEffect, useState, type ReactNode } from 'react';\nimport {\n  StyleSheet,\n  View,\n  type AccessibilityActionEvent,\n  type StyleProp,\n  type ViewStyle,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n  useAnimatedStyle,\n  useDerivedValue,\n  useSharedValue,\n  withSpring,\n  withTiming,\n  type SharedValue,\n} 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 { spring, timing } from '../../lib/kinetik/motion/motion';\nimport { useKinetikTheme } from '../../lib/kinetik/tokens/theme';\nimport { withAlpha } from '../../lib/kinetik/tokens/tokens';\n\nexport type ApertureCardProps = {\n  /** Content revealed when the aperture opens. */\n  children: ReactNode;\n  /** Shown on the closed blades, e.g. a label or icon. */\n  cover?: ReactNode;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Initial state when uncontrolled. Defaults to false. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Number of iris blades, 5–9. Defaults to 7. */\n  blades?: number;\n  /** Toggle on tap. Defaults to true. */\n  toggleOnPress?: boolean;\n  /** A tap when the aperture finishes opening. Defaults to true. */\n  haptics?: boolean;\n  /** Accessible name of the card. */\n  accessibilityLabel?: string;\n  /** Corner radius. Defaults to the theme `xl` radius. */\n  radius?: number;\n  style?: StyleProp<ViewStyle>;\n};\n\n/**\n * A card closed by a camera-like iris. Tapping it turns the blades and opens\n * the aperture outward, revealing the content beneath.\n */\nexport function ApertureCard({\n  children,\n  cover,\n  open: controlled,\n  defaultOpen = false,\n  onOpenChange,\n  blades = 7,\n  toggleOnPress = true,\n  haptics = true,\n  accessibilityLabel,\n  radius,\n  style,\n}: ApertureCardProps) {\n  const theme = useKinetikTheme();\n  const reduced = useReduceMotion();\n  const haptic = useHaptic(haptics);\n  const [internal, setInternal] = useState(defaultOpen);\n  const open = controlled ?? internal;\n  const r = radius ?? theme.radius.xl;\n  const n = Math.max(5, Math.min(9, Math.round(blades)));\n\n  const progress = useSharedValue(open ? 1 : 0);\n  const size = useSharedValue<SkSize>({ width: 0, height: 0 });\n\n  const landed = () => haptic('tap');\n  useEffect(() => {\n    const target = open ? 1 : 0;\n    const done = (finished?: boolean) => {\n      'worklet';\n      if (finished && target === 1) scheduleOnRN(landed);\n    };\n    progress.set(\n      reduced\n        ? withTiming(target, timing('base', true), done)\n        : withSpring(target, { ...spring('smooth'), overshootClamping: true }, done),\n    );\n    // `landed` only plays a haptic; it does not need to retrigger the animation.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open, reduced, progress]);\n\n  const toggle = () => {\n    const next = !open;\n    if (controlled === undefined) setInternal(next);\n    onOpenChange?.(next);\n  };\n\n  const tap = Gesture.Tap()\n    .withTestId('aperture-card')\n    .enabled(toggleOnPress)\n    .onEnd((_e, success) => {\n      if (success) scheduleOnRN(toggle);\n    });\n\n  // Shared geometry of the iris: centre, aperture radius and how far the blades\n  // have turned. Fully open, the aperture's inscribed circle covers the corners.\n  const iris = useDerivedValue<Iris>(() => {\n    const { width, height } = size.get();\n    const p = reduced ? 0 : progress.get();\n    const cx = width / 2;\n    const cy = height / 2;\n    const reach = Math.sqrt(cx * cx + cy * cy) / Math.cos(Math.PI / n) + 2;\n    return { cx, cy, rho: p * reach, turn: (1 - p) * TURN, far: reach * 1.7 + 16, width, height };\n  });\n\n  // Everything the blades cover: the card minus the aperture polygon.\n  const covered = useDerivedValue(() => {\n    const { cx, cy, rho, turn, width, height } = iris.get();\n    return Skia.PathBuilder.Make()\n      .addRect({ x: 0, y: 0, width, height })\n      .addPath(aperturePath(cx, cy, rho, turn, n))\n      .setFillType(FillType.EvenOdd) // the rect minus the aperture\n      .detach();\n  });\n  const rim = useDerivedValue(() => {\n    const { cx, cy, rho, turn } = iris.get();\n    return aperturePath(cx, cy, rho, turn, n);\n  });\n\n  // The blades darken towards the opening, as if it falls away into the lens.\n  const center = useDerivedValue(() => vec(iris.get().cx, iris.get().cy));\n  const depth = useDerivedValue(() => {\n    const { rho, width, height } = iris.get();\n    return Math.max(1, rho + Math.min(width, height) * 0.55);\n  });\n\n  const canvasStyle = useAnimatedStyle(() => ({\n    // Reduced motion: the blades fade instead of turning.\n    opacity: reduced ? 1 - progress.get() : progress.get() > 0.995 ? 0 : 1,\n  }));\n  const coverStyle = useAnimatedStyle(() => ({\n    opacity: Math.max(0, 1 - progress.get() * 3),\n    transform: [{ scale: 1 - progress.get() * 0.08 }],\n  }));\n\n  // Blade shading: black ink in both schemes and the theme's white highlight.\n  const dark = theme.scheme === 'dark';\n  const ink = dark ? theme.colors.background : theme.colors.text;\n  const light = theme.colors.highlight;\n\n  const onAccessibilityAction = (e: AccessibilityActionEvent) => {\n    if (e.nativeEvent.actionName === 'activate' && toggleOnPress) toggle();\n  };\n\n  return (\n    <GestureDetector gesture={tap}>\n      <View\n        collapsable={false}\n        accessible\n        accessibilityRole={toggleOnPress ? 'button' : undefined}\n        accessibilityLabel={accessibilityLabel}\n        accessibilityHint={\n          toggleOnPress ? (open ? 'Closes the card' : 'Opens the card') : undefined\n        }\n        accessibilityState={{ expanded: open }}\n        accessibilityActions={toggleOnPress ? [{ name: 'activate' }] : undefined}\n        onAccessibilityAction={onAccessibilityAction}\n        style={[\n          styles.card,\n          {\n            borderRadius: r,\n            backgroundColor: theme.colors.surface,\n            borderColor: theme.colors.border,\n            boxShadow: theme.shadow.md,\n          },\n          style,\n        ]}\n      >\n        <View\n          style={styles.content}\n          importantForAccessibility={open ? 'auto' : 'no-hide-descendants'}\n          accessibilityElementsHidden={!open}\n        >\n          {children}\n        </View>\n        <Animated.View pointerEvents=\"none\" style={[StyleSheet.absoluteFill, canvasStyle]}>\n          <Canvas style={StyleSheet.absoluteFill} onSize={size}>\n            <Path path={covered} color={theme.colors.surfaceRaised} />\n            {Array.from({ length: n }, (_, i) => (\n              <Blade\n                key={i}\n                index={i}\n                n={n}\n                iris={iris}\n                ink={ink}\n                light={light}\n                lift={dark ? 0.13 : 0.5}\n                shade={dark ? 0.55 : 0.16}\n                seam={dark ? 0.55 : 0.18}\n              />\n            ))}\n            <Path path={covered}>\n              <RadialGradient\n                c={center}\n                r={depth}\n                colors={[withAlpha(ink, dark ? 0.7 : 0.22), withAlpha(ink, 0)]}\n              />\n            </Path>\n            <Path\n              path={rim}\n              style=\"stroke\"\n              strokeWidth={1.5}\n              strokeJoin=\"round\"\n              color={withAlpha(light, dark ? 0.35 : 0.9)}\n            />\n          </Canvas>\n        </Animated.View>\n        {cover ? (\n          <Animated.View pointerEvents=\"none\" style={[styles.cover, coverStyle]}>\n            {cover}\n          </Animated.View>\n        ) : null}\n      </View>\n    </GestureDetector>\n  );\n}\n\ntype Iris = {\n  cx: number;\n  cy: number;\n  /** Radius of the aperture polygon. */\n  rho: number;\n  /** Rotation of the blades, in radians; they turn as the iris opens. */\n  turn: number;\n  /** A distance past the card's corners, where blade edges end. */\n  far: number;\n  width: number;\n  height: number;\n};\n\n/** How far the blades turn between closed and open. */\nconst TURN = Math.PI / 2.4;\n/** How far each blade edge curls between the aperture and the card's edge. */\nconst CURL = 0.55;\n/** Direction the light comes from: top left. */\nconst LIGHT = -Math.PI * 0.72;\n\nfunction aperturePath(cx: number, cy: number, rho: number, turn: number, n: number): SkPath {\n  'worklet';\n  const path = Skia.PathBuilder.Make();\n  for (let i = 0; i < n; i++) {\n    const a = turn + (i / n) * Math.PI * 2;\n    const x = cx + Math.cos(a) * rho;\n    const y = cy + Math.sin(a) * rho;\n    if (i === 0) path.moveTo(x, y);\n    else path.lineTo(x, y);\n  }\n  return path.close().detach();\n}\n\n/**\n * One iris blade: the band between two neighbouring edges of the aperture,\n * each extended past its corner to beyond the card. Lit by its angle to the\n * light, with a bevel along its edges.\n */\nfunction Blade({\n  index,\n  n,\n  iris,\n  ink,\n  light,\n  lift,\n  shade,\n  seam,\n}: {\n  index: number;\n  n: number;\n  iris: SharedValue<Iris>;\n  ink: string;\n  light: string;\n  lift: number;\n  shade: number;\n  /** Opacity of the dark gap between blades. */\n  seam: number;\n}) {\n  const path = useDerivedValue(() => {\n    const { cx, cy, rho, turn, far } = iris.get();\n    const step = (Math.PI * 2) / n;\n    const a0 = turn + index * step;\n    const a1 = a0 + step;\n    const x0 = cx + Math.cos(a0) * rho;\n    const y0 = cy + Math.sin(a0) * rho;\n    const x1 = cx + Math.cos(a1) * rho;\n    const y1 = cy + Math.sin(a1) * rho;\n    // Directions of this blade's edge and the previous one. Taken from the\n    // angles, so they stay defined when the aperture closes to a point.\n    const out = a0 + step / 2 + Math.PI / 2;\n    const back = a0 - step / 2 + Math.PI / 2;\n    // Each edge leaves the aperture straight and curls as it goes, like the\n    // curved blades of a real lens. Neighbours share the same curve.\n    const blade = Skia.PathBuilder.Make();\n    blade.moveTo(x0, y0);\n    blade.lineTo(x1, y1);\n    blade.quadTo(\n      x1 + Math.cos(out) * far * 0.5,\n      y1 + Math.sin(out) * far * 0.5,\n      x1 + Math.cos(out + CURL) * far,\n      y1 + Math.sin(out + CURL) * far,\n    );\n    blade.lineTo(x0 + Math.cos(back + CURL) * far, y0 + Math.sin(back + CURL) * far);\n    blade.quadTo(x0 + Math.cos(back) * far * 0.5, y0 + Math.sin(back) * far * 0.5, x0, y0);\n    return blade.close().detach();\n  });\n\n  // 1 when the blade faces the light, 0 when it faces away.\n  const facing = useDerivedValue(() => {\n    const a = iris.get().turn + (index + 0.5) * ((Math.PI * 2) / n) + Math.PI / 2;\n    return 0.5 + 0.5 * Math.cos(a - LIGHT);\n  });\n  const lit = useDerivedValue(() => facing.get() * lift);\n  const unlit = useDerivedValue(() => (1 - facing.get()) * shade);\n\n  return (\n    <Group>\n      <Path path={path} color={light} opacity={lit} />\n      <Path path={path} color={ink} opacity={unlit} />\n      {/* Bevel: a light lip inside the blade, then the dark gap between blades. */}\n      <Group clip={path}>\n        <Path path={path} style=\"stroke\" strokeWidth={3} color={withAlpha(light, 0.1)} />\n      </Group>\n      <Path\n        path={path}\n        style=\"stroke\"\n        strokeWidth={1}\n        strokeJoin=\"round\"\n        color={withAlpha(ink, seam)}\n      />\n    </Group>\n  );\n}\n\nconst styles = StyleSheet.create({\n  card: { overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth, minHeight: 200 },\n  content: { flexGrow: 1 },\n  cover: { ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center' },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "component",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "card",
    "web": "live",
    "perf": {
      "budget": "While opening or closing, two small paths (blades and seams) are rebuilt on the UI thread each frame; nothing runs at rest.",
      "measurements": [
        {
          "device": "Pixel 9 emulator, API 35",
          "kind": "emulator",
          "date": "2026-10-12",
          "build": "release",
          "frameMs": 21,
          "p90Ms": 29,
          "baselineMs": 20,
          "jankyPercent": 3,
          "atRest": "idle",
          "memoryMb": 161
        }
      ]
    },
    "a11y": {
      "reducedMotion": "The blades fade out instead of turning.",
      "screenReader": "Button role with expanded state; the hidden content is not exposed until the card is open.",
      "touchTarget": "The whole card (at least 200 pt tall)."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { ApertureCard } from '@/components/kinetik/aperture-card';\n\n<ApertureCard\n  accessibilityLabel=\"Mystery reward\"\n  cover={<Text>Tap to open</Text>}\n  style={{ height: 280 }}\n>\n  <Reward />\n</ApertureCard>;\n```\n\nUncontrolled by default: a tap opens and closes the iris. Pass `open` and `onOpenChange` to drive it from state, for example after a purchase completes.\n\n## When to use\n\n- Reveals with a little ceremony: rewards, unlocked content, a daily surprise.\n- Camera, photo and media products, where the iris is a natural metaphor.\n\n## When not to use\n\n- Hiding content people need right away. A reveal is a delay.\n- Small cards; the blades need room to read as an aperture.\n\n## Notes\n\n- The revealed content is hidden from screen readers until the card opens.\n- `blades` takes 5 to 9; odd numbers look most like a real lens.\n- A light tap plays when the aperture finishes opening.\n"
  },
  "docs": "Prefer `npx kinetik-ui add aperture-card`: it installs dependencies with expo install, so versions match your Expo SDK."
}
