{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expand-card",
  "type": "registry:item",
  "title": "Expand Card",
  "description": "A row that opens into a card: the thumbnail grows into a wide picture and the details rise in beneath it.",
  "categories": [
    "card"
  ],
  "dependencies": [
    "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/expand-card.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/expand-card.tsx",
      "content": "import { useEffect, useState, type ReactNode } from 'react';\nimport {\n  Image,\n  StyleSheet,\n  Text,\n  View,\n  type AccessibilityActionEvent,\n  type ImageSourcePropType,\n  type LayoutChangeEvent,\n  type StyleProp,\n  type ViewStyle,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n  interpolate,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\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 } from '../../lib/kinetik/motion/motion';\nimport { useKinetikTheme } from '../../lib/kinetik/tokens/theme';\n\nexport type ExpandCardProps = {\n  image: ImageSourcePropType;\n  title: string;\n  subtitle?: string;\n  /** Shown under the title once the card is open. */\n  children?: ReactNode;\n  /** Controlled open state. */\n  expanded?: boolean;\n  /** Initial state when uncontrolled. Defaults to false. */\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  /** Image height divided by width when open. Defaults to 0.62. */\n  imageAspect?: number;\n  /** Size of the closed thumbnail. Defaults to 64. */\n  thumbSize?: number;\n  /** A light tap when it opens or closes. Defaults to true. */\n  haptics?: boolean;\n  style?: StyleProp<ViewStyle>;\n};\n\nconst PAD = 12;\nconst GAP = 14;\n\n/**\n * A row that opens into a card. The thumbnail grows into a wide picture,\n * the title slides under it and the body rises in; tap again and it all\n * folds back into the row.\n */\nexport function ExpandCard({\n  image,\n  title,\n  subtitle,\n  children,\n  expanded: controlled,\n  defaultExpanded = false,\n  onExpandedChange,\n  imageAspect = 0.62,\n  thumbSize = 64,\n  haptics = true,\n  style,\n}: ExpandCardProps) {\n  const { colors, font, radius, space, shadow } = useKinetikTheme();\n  const reduced = useReduceMotion();\n  const haptic = useHaptic(haptics);\n  const [internal, setInternal] = useState(defaultExpanded);\n  const open = controlled ?? internal;\n\n  const [width, setWidth] = useState(0);\n  const [titleH, setTitleH] = useState(0);\n  const [bodyH, setBodyH] = useState(0);\n  const p = useSharedValue(open ? 1 : 0);\n\n  useEffect(() => {\n    p.set(withSpring(open ? 1 : 0, spring('smooth', reduced)));\n  }, [open, reduced, p]);\n\n  const toggle = () => {\n    const next = !open;\n    haptic('tap');\n    if (controlled === undefined) setInternal(next);\n    onExpandedChange?.(next);\n  };\n\n  const tap = Gesture.Tap()\n    .withTestId('expand-card')\n    .maxDistance(16)\n    .onEnd((_e, ok) => {\n      if (ok) scheduleOnRN(toggle);\n    });\n\n  // Geometry in both states, from the measured width, title and body.\n  const inner = Math.max(0, width - PAD * 2);\n  const imageH = Math.round(inner * imageAspect);\n  const closedH = PAD * 2 + thumbSize;\n  const openH = PAD + imageH + GAP + titleH + (children ? space[3] + bodyH : 0) + PAD + 4;\n  const titleClosed = { x: PAD + thumbSize + GAP, y: PAD + (thumbSize - titleH) / 2 };\n  const titleOpen = { x: PAD + 4, y: PAD + imageH + GAP };\n\n  const card = useAnimatedStyle(() => ({\n    height: width === 0 ? closedH : interpolate(p.get(), [0, 1], [closedH, openH]),\n  }));\n  const picture = useAnimatedStyle(() => {\n    const t = p.get();\n    return {\n      width: interpolate(t, [0, 1], [thumbSize, inner || thumbSize]),\n      height: interpolate(t, [0, 1], [thumbSize, imageH || thumbSize]),\n      borderRadius: interpolate(t, [0, 1], [radius.sm, radius.lg]),\n    };\n  });\n  const heading = useAnimatedStyle(() => {\n    const t = p.get();\n    // The title dips out mid-journey so it never sits on the picture as it resizes.\n    const mid = Math.sin(Math.PI * Math.max(0, Math.min(1, t)));\n    return {\n      opacity: 1 - mid * 0.92,\n      width: interpolate(t, [0, 1], [inner - thumbSize - GAP - 36, inner - 44]),\n      transform: [\n        { translateX: interpolate(t, [0, 1], [titleClosed.x, titleOpen.x]) },\n        { translateY: interpolate(t, [0, 1], [titleClosed.y, titleOpen.y]) },\n      ],\n    };\n  });\n  const chevron = useAnimatedStyle(() => {\n    const t = p.get();\n    return {\n      transform: [\n        {\n          translateY: interpolate(\n            t,\n            [0, 1],\n            [PAD + thumbSize / 2 - 15, titleOpen.y + titleH / 2 - 15],\n          ),\n        },\n        { rotate: `${interpolate(t, [0, 1], [0, 180])}deg` },\n      ],\n    };\n  });\n  const body = useAnimatedStyle(() => {\n    const t = p.get();\n    return {\n      opacity: interpolate(t, [0.45, 1], [0, 1], 'clamp'),\n      transform: [\n        { translateY: titleOpen.y + titleH + space[3] + interpolate(t, [0, 1], [14, 0]) },\n      ],\n    };\n  });\n\n  const onLayout = (e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width);\n  const onAccessibilityAction = (e: AccessibilityActionEvent) => {\n    if (e.nativeEvent.actionName === 'activate') toggle();\n  };\n\n  return (\n    <GestureDetector gesture={tap}>\n      <Animated.View\n        collapsable={false}\n        accessible\n        accessibilityRole=\"button\"\n        accessibilityLabel={subtitle ? `${title}, ${subtitle}` : title}\n        accessibilityState={{ expanded: open }}\n        accessibilityActions={[{ name: 'activate' }]}\n        onAccessibilityAction={onAccessibilityAction}\n        onLayout={onLayout}\n        style={[\n          styles.card,\n          {\n            borderRadius: radius.xl,\n            backgroundColor: colors.surface,\n            borderColor: colors.border,\n            boxShadow: shadow.md,\n          },\n          style,\n          card,\n        ]}\n      >\n        <Animated.View style={[styles.picture, picture]}>\n          <Image source={image} style={StyleSheet.absoluteFill} resizeMode=\"cover\" />\n        </Animated.View>\n\n        <Animated.View style={[styles.abs, heading]}>\n          <View onLayout={(e) => setTitleH(Math.ceil(e.nativeEvent.layout.height))}>\n            <Text\n              numberOfLines={1}\n              style={{\n                color: colors.text,\n                fontSize: font.size.md,\n                fontWeight: font.weight.semibold,\n              }}\n            >\n              {title}\n            </Text>\n            {subtitle ? (\n              <Text\n                numberOfLines={1}\n                style={{ color: colors.textMuted, fontSize: font.size.sm, marginTop: 2 }}\n              >\n                {subtitle}\n              </Text>\n            ) : null}\n          </View>\n        </Animated.View>\n\n        <Animated.View\n          style={[\n            styles.chevron,\n            { right: PAD + 2, backgroundColor: colors.surfaceRaised, borderColor: colors.border },\n            chevron,\n          ]}\n          pointerEvents=\"none\"\n        >\n          <View style={[styles.chevronGlyph, { borderColor: colors.textMuted }]} />\n        </Animated.View>\n\n        {children ? (\n          <Animated.View\n            style={[styles.abs, { left: PAD + 4, right: PAD + 4 }, body]}\n            importantForAccessibility={open ? 'auto' : 'no-hide-descendants'}\n            accessibilityElementsHidden={!open}\n          >\n            <View onLayout={(e) => setBodyH(Math.ceil(e.nativeEvent.layout.height))}>\n              {children}\n            </View>\n          </Animated.View>\n        ) : null}\n      </Animated.View>\n    </GestureDetector>\n  );\n}\n\nconst styles = StyleSheet.create({\n  card: { overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth },\n  picture: { position: 'absolute', left: PAD, top: PAD, overflow: 'hidden' },\n  abs: { position: 'absolute', left: 0, top: 0 },\n  chevron: {\n    position: 'absolute',\n    top: 0,\n    width: 30,\n    height: 30,\n    borderRadius: 15,\n    alignItems: 'center',\n    justifyContent: 'center',\n    borderWidth: StyleSheet.hairlineWidth,\n  },\n  chevronGlyph: {\n    width: 8,\n    height: 8,\n    marginTop: -3,\n    borderRightWidth: 1.75,\n    borderBottomWidth: 1.75,\n    transform: [{ rotate: '45deg' }],\n  },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "component",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "card",
    "web": "live",
    "perf": {
      "budget": "One spring drives the card height, picture size and title position on the UI thread; nothing runs at rest.",
      "measurements": [
        {
          "device": "Pixel 9 emulator, API 35",
          "kind": "emulator",
          "date": "2026-10-12",
          "build": "release",
          "frameMs": 20,
          "p90Ms": 32,
          "baselineMs": 20,
          "jankyPercent": 4.72,
          "atRest": "idle",
          "memoryMb": 153
        }
      ]
    },
    "a11y": {
      "reducedMotion": "Opens and closes in one step, without the morph.",
      "screenReader": "A button named by the title and subtitle with an expanded state; the body is hidden from screen readers while closed.",
      "touchTarget": "The whole row, at least 88 pt tall."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { ExpandCard } from '@/components/kinetik/expand-card';\n\n<ExpandCard image={{ uri: trip.photo }} title={trip.name} subtitle={trip.dates}>\n  <Text>{trip.summary}</Text>\n</ExpandCard>;\n```\n\nUncontrolled by default. Pass `expanded` and `onExpandedChange` to drive it, for example to keep only one card of a list open at a time.\n\n## When to use\n\n- Lists where each row has a picture and a little more to say: trips, recipes, events, artworks.\n- Settings or FAQs that benefit from a visual anchor.\n\n## When not to use\n\n- Long content. Navigate to a detail screen instead.\n- Rows without an image; a plain disclosure row is clearer.\n\n## Notes\n\n- The card measures its title and body, so the open height always fits the content.\n- Children are laid out at the card's width; keep interactive content out of the body, since a tap on the card toggles it.\n- A light tap plays as it opens and closes.\n"
  },
  "docs": "Prefer `npx kinetik-ui add expand-card`: it installs dependencies with expo install, so versions match your Expo SDK."
}
