{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "parallax-carousel",
  "type": "registry:item",
  "title": "Parallax Carousel",
  "description": "A paging carousel whose pictures drift inside their cards as they slide, with the centre card brought forward.",
  "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/parallax-carousel.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/parallax-carousel.tsx",
      "content": "import { useState } from 'react';\nimport {\n  Image,\n  StyleSheet,\n  Text,\n  View,\n  type AccessibilityActionEvent,\n  type ImageSourcePropType,\n  type StyleProp,\n  type ViewStyle,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n  Extrapolation,\n  interpolate,\n  useAnimatedReaction,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  type SharedValue,\n} from 'react-native-reanimated';\nimport { scheduleOnRN } from 'react-native-worklets';\n\nimport { gradient } from '../../lib/kinetik/tokens/gradient';\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';\nimport { withAlpha } from '../../lib/kinetik/tokens/tokens';\n\nexport type CarouselItem = {\n  key: string;\n  image: ImageSourcePropType;\n  title?: string;\n  subtitle?: string;\n  /** Accessible description of the image. Defaults to the title. */\n  accessibilityLabel?: string;\n};\n\nexport type ParallaxCarouselProps = {\n  items: readonly CarouselItem[];\n  /** Card width as a share of the carousel's width, 0.5–0.95. Defaults to 0.78. */\n  cardWidth?: number;\n  /** Card height divided by width. Defaults to 1.25. */\n  aspect?: number;\n  /** How far the image drifts inside its card, 0–1. Defaults to 0.6. */\n  parallax?: number;\n  /** Space between cards. Defaults to 14. */\n  gap?: number;\n  /** Thin progress line under the cards. Defaults to true. */\n  showProgress?: boolean;\n  /** Called when the centre card is tapped. A tap on a side card brings it to the centre. */\n  onPress?: (item: CarouselItem, index: number) => void;\n  /** Called when a new card reaches the centre. */\n  onIndexChange?: (index: number) => void;\n  /** A tick as each card reaches the centre. Defaults to true. */\n  haptics?: boolean;\n  testID?: string;\n  style?: StyleProp<ViewStyle>;\n};\n\n/** Width of the progress line. */\nconst TRACK = 72;\n\n/**\n * A paging carousel where each picture sits a little behind its card: as\n * cards slide past, the images move more slowly than the frames that hold\n * them. The centre card comes forward and its caption drifts in. Drag it with\n * a finger or a mouse; a flick carries on to the next cards.\n */\nexport function ParallaxCarousel({\n  items,\n  cardWidth = 0.78,\n  aspect = 1.25,\n  parallax = 0.6,\n  gap = 14,\n  showProgress = true,\n  onPress,\n  onIndexChange,\n  haptics = true,\n  testID,\n  style,\n}: ParallaxCarouselProps) {\n  const { colors, radius, space } = useKinetikTheme();\n  const reduced = useReduceMotion();\n  const haptic = useHaptic(haptics);\n  const [width, setWidth] = useState(0);\n  const [index, setIndex] = useState(0);\n  const offset = useSharedValue(0);\n  const start = useSharedValue(0);\n\n  const cardW = Math.round(width * Math.max(0.5, Math.min(0.95, cardWidth)));\n  const step = cardW + gap;\n  const side = (width - cardW) / 2;\n  const height = Math.round(cardW * aspect);\n  const last = Math.max(0, items.length - 1);\n\n  const arrive = (next: number) => {\n    setIndex(next);\n    haptic('tick');\n    onIndexChange?.(next);\n  };\n  // The centre card, reported once per change: live while dragging, and when a card settles.\n  const current = useSharedValue(0);\n  const report = (i: number) => {\n    'worklet';\n    if (i === current.get()) return;\n    current.set(i);\n    scheduleOnRN(arrive, i);\n  };\n  useAnimatedReaction(\n    () => (step > 0 ? Math.max(0, Math.min(last, Math.round(offset.get() / step))) : 0),\n    (next, before) => {\n      if (before !== null && next !== before) report(next);\n    },\n    [step, last],\n  );\n\n  const settleOn = (i: number, velocity = 0) => {\n    'worklet';\n    const target = Math.max(0, Math.min(last, i));\n    offset.set(withSpring(target * step, spring('smooth', reduced, velocity)));\n    report(target);\n  };\n\n  const press = (i: number) => {\n    const item = items[i];\n    if (item) onPress?.(item, i);\n  };\n\n  // Past either end the row resists like rubber; a flick carries on to the\n  // next cards, up to three at a time.\n  const pan = Gesture.Pan()\n    .withTestId('parallax-carousel')\n    .enabled(items.length > 1 && step > 0)\n    .activeOffsetX([-8, 8])\n    .failOffsetY([-12, 12])\n    .onBegin(() => {\n      start.set(offset.get());\n    })\n    .onUpdate((e) => {\n      const raw = start.get() - e.translationX;\n      const max = last * step;\n      const over = raw < 0 ? raw : raw > max ? raw - max : 0;\n      offset.set(raw - over * 0.65);\n    })\n    .onEnd((e) => {\n      const from = Math.round(start.get() / step);\n      const nearest = Math.round((offset.get() - e.velocityX * 0.18) / step);\n      // A deliberate swipe always moves at least one card.\n      const next =\n        nearest === from && Math.abs(e.translationX) > step * 0.18\n          ? from - Math.sign(e.translationX)\n          : Math.max(from - 3, Math.min(from + 3, nearest));\n      settleOn(next, -e.velocityX);\n    });\n\n  // A tap on the centre card presses it; a tap on a side card brings it over.\n  const tap = Gesture.Tap()\n    .withTestId('parallax-carousel-tap')\n    .enabled(step > 0)\n    .maxDistance(8)\n    .onEnd((e, ok) => {\n      if (!ok) return;\n      const i = Math.floor((e.x - side + offset.get() + gap / 2) / step);\n      if (i < 0 || i > last) return;\n      if (i === Math.round(offset.get() / step)) scheduleOnRN(press, i);\n      else settleOn(i);\n    });\n\n  const row = useAnimatedStyle(() => ({ transform: [{ translateX: side - offset.get() }] }));\n\n  const progress = useAnimatedStyle(() => {\n    const span = Math.max(1, last * step);\n    const t = Math.max(0, Math.min(1, offset.get() / span));\n    const thumb = 1 / Math.max(1, items.length);\n    return {\n      width: `${thumb * 100}%`,\n      transform: [{ translateX: t * (1 - thumb) * TRACK }],\n    };\n  });\n\n  const onAccessibilityAction = (i: number) => (e: AccessibilityActionEvent) => {\n    const name = e.nativeEvent.actionName;\n    if (name === 'activate') press(i);\n    else if (name === 'next') settleOn(index + 1);\n    else if (name === 'previous') settleOn(index - 1);\n  };\n\n  return (\n    <View\n      testID={testID}\n      style={[{ gap: space[4] }, style]}\n      onLayout={(e) => setWidth(e.nativeEvent.layout.width)}\n    >\n      {width > 0 && (\n        <GestureDetector gesture={Gesture.Exclusive(pan, tap)}>\n          <View collapsable={false} style={[styles.window, { height: height + 14 }]}>\n            <Animated.View style={[styles.row, { gap }, row]}>\n              {items.map((item, i) => (\n                <Card\n                  key={item.key}\n                  item={item}\n                  index={i}\n                  active={i === index}\n                  width={cardW}\n                  height={height}\n                  step={step}\n                  offset={offset}\n                  parallax={reduced ? 0 : Math.max(0, Math.min(1, parallax))}\n                  still={reduced}\n                  radius={radius.xl}\n                  pressable={Boolean(onPress)}\n                  onAccessibilityAction={onAccessibilityAction(i)}\n                />\n              ))}\n            </Animated.View>\n          </View>\n        </GestureDetector>\n      )}\n      {showProgress && items.length > 1 && (\n        <View\n          style={[styles.track, { backgroundColor: colors.track }]}\n          accessibilityElementsHidden\n          importantForAccessibility=\"no-hide-descendants\"\n        >\n          <Animated.View style={[styles.thumb, { backgroundColor: colors.text }, progress]} />\n        </View>\n      )}\n    </View>\n  );\n}\n\nfunction Card({\n  item,\n  index,\n  active,\n  width,\n  height,\n  step,\n  offset,\n  parallax,\n  still,\n  radius,\n  pressable,\n  onAccessibilityAction,\n}: {\n  item: CarouselItem;\n  index: number;\n  active: boolean;\n  width: number;\n  height: number;\n  step: number;\n  offset: SharedValue<number>;\n  parallax: number;\n  still: boolean;\n  radius: number;\n  pressable: boolean;\n  onAccessibilityAction: (e: AccessibilityActionEvent) => void;\n}) {\n  const { colors, font, space, shadow, scheme } = useKinetikTheme();\n  // Captions sit on black ink in both schemes; they are drawn over a picture.\n  const ink = scheme === 'dark' ? colors.background : colors.text;\n  // The image is wider than its card so it has room to drift.\n  const extra = width * 0.35 * parallax;\n\n  const frame = useAnimatedStyle(() => {\n    if (still) return {};\n    const d = Math.abs((offset.get() - index * step) / step);\n    return {\n      transform: [\n        { scale: interpolate(d, [0, 1], [1, 0.9], Extrapolation.CLAMP) },\n        { translateY: interpolate(d, [0, 1], [0, 10], Extrapolation.CLAMP) },\n      ],\n      opacity: interpolate(d, [0, 1.2], [1, 0.55], Extrapolation.CLAMP),\n    };\n  });\n  const picture = useAnimatedStyle(() => {\n    const d = (offset.get() - index * step) / step;\n    return {\n      transform: [\n        { translateX: interpolate(d, [-1, 1], [-extra / 2, extra / 2], Extrapolation.CLAMP) },\n      ],\n    };\n  });\n  const caption = useAnimatedStyle(() => {\n    if (still) return {};\n    const d = (offset.get() - index * step) / step;\n    return {\n      opacity: interpolate(Math.abs(d), [0, 0.6], [1, 0], Extrapolation.CLAMP),\n      transform: [{ translateX: interpolate(d, [-1, 1], [40, -40], Extrapolation.CLAMP) }],\n    };\n  });\n\n  return (\n    <Animated.View style={[{ width, height }, frame]}>\n      <View\n        accessible\n        accessibilityRole={pressable ? 'button' : 'image'}\n        accessibilityLabel={item.accessibilityLabel ?? item.title}\n        accessibilityState={{ selected: active }}\n        accessibilityActions={[\n          ...(pressable ? [{ name: 'activate' }] : []),\n          { name: 'next', label: 'Next card' },\n          { name: 'previous', label: 'Previous card' },\n        ]}\n        onAccessibilityAction={onAccessibilityAction}\n        style={[\n          styles.card,\n          { borderRadius: radius, backgroundColor: colors.surfaceSunken, boxShadow: shadow.lg },\n        ]}\n      >\n        <Animated.View style={[styles.picture, { left: -extra / 2, right: -extra / 2 }, picture]}>\n          <Image source={item.image} style={StyleSheet.absoluteFill} resizeMode=\"cover\" />\n        </Animated.View>\n        {item.title || item.subtitle ? (\n          <View\n            pointerEvents=\"none\"\n            style={[\n              styles.scrim,\n              { padding: space[5] },\n              gradient(\n                `linear-gradient(180deg, ${withAlpha(ink, 0)} 0%, ${withAlpha(ink, 0)} 45%, ${withAlpha(ink, 0.78)} 100%)`,\n              ),\n            ]}\n          >\n            <Animated.View style={caption}>\n              {item.subtitle ? (\n                <Text\n                  numberOfLines={1}\n                  style={{\n                    color: colors.highlight,\n                    opacity: 0.75,\n                    fontSize: font.size.xs,\n                    fontWeight: font.weight.semibold,\n                    letterSpacing: 1.2,\n                    textTransform: 'uppercase',\n                  }}\n                >\n                  {item.subtitle}\n                </Text>\n              ) : null}\n              {item.title ? (\n                <Text\n                  numberOfLines={2}\n                  style={{\n                    color: colors.highlight,\n                    fontSize: font.size.xl,\n                    fontWeight: font.weight.bold,\n                    letterSpacing: -0.4,\n                    marginTop: 4,\n                  }}\n                >\n                  {item.title}\n                </Text>\n              ) : null}\n            </Animated.View>\n          </View>\n        ) : null}\n      </View>\n    </Animated.View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  window: { overflow: 'hidden' },\n  row: { flexDirection: 'row', alignItems: 'flex-start' },\n  card: { flex: 1, overflow: 'hidden' },\n  picture: { position: 'absolute', top: 0, bottom: 0 },\n  scrim: { ...StyleSheet.absoluteFill, justifyContent: 'flex-end' },\n  track: { alignSelf: 'center', width: TRACK, height: 3, borderRadius: 2, overflow: 'hidden' },\n  thumb: { height: '100%', borderRadius: 2 },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "component",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "card",
    "web": "live",
    "perf": {
      "budget": "One drag offset drives three transforms per card on the UI thread; React renders only when the centre card changes.",
      "measurements": [
        {
          "device": "Pixel 9 emulator, API 35",
          "kind": "emulator",
          "date": "2026-10-12",
          "build": "release",
          "frameMs": 17,
          "p90Ms": 26,
          "baselineMs": 20,
          "jankyPercent": 3.5,
          "atRest": "idle",
          "memoryMb": 160
        }
      ]
    },
    "a11y": {
      "reducedMotion": "Cards keep their size and the pictures stay put; cards settle without overshoot.",
      "screenReader": "Each card is an image (or a button when onPress is set) named by its title or accessibilityLabel, with Next card and Previous card actions; the centre card is selected.",
      "touchTarget": "Cards are at least half the carousel's width."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { ParallaxCarousel } from '@/components/kinetik/parallax-carousel';\n\n<ParallaxCarousel\n  items={trips.map((t) => ({\n    key: t.id,\n    image: { uri: t.photo },\n    title: t.name,\n    subtitle: t.city,\n  }))}\n  onPress={(item) => router.push(`/trips/${item.key}`)}\n/>;\n```\n\nThe carousel fills its parent's width. Cards are `cardWidth` of it (78% by default) so the neighbours peek in from the sides, and `aspect` sets their height. Captions sit on a dark gradient at the bottom of each picture.\n\n## When to use\n\n- Browsing a handful of visual items: places, products, albums, artworks.\n- Feature or story cards on a home screen.\n\n## When not to use\n\n- Long lists that people search or compare. Use a grid or a list.\n- Content that has to be read in full; only the centre card has room to breathe.\n\n## Notes\n\n- `parallax` sets how far each picture drifts inside its card; the image is drawn wider than the card to make room.\n- A tick plays each time a new card comes to the centre.\n- Drag with a finger, a mouse or a pen; past either end the row resists like rubber, and a flick moves up to three cards.\n- Tap the centre card to press it; tap a side card to bring it to the centre.\n- Screen readers get Next card and Previous card actions on every card.\n"
  },
  "docs": "Prefer `npx kinetik-ui add parallax-carousel`: it installs dependencies with expo install, so versions match your Expo SDK."
}
