{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "deckfall",
  "type": "registry:item",
  "title": "Deckfall",
  "description": "A stack of cards swiped away one at a time, with the cards behind rising into place.",
  "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/deckfall.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/deckfall.tsx",
      "content": "import { useImperativeHandle, useState, type ReactNode, type Ref } from 'react';\nimport {\n  StyleSheet,\n  Text,\n  View,\n  useWindowDimensions,\n  type AccessibilityActionEvent,\n  type StyleProp,\n  type ViewStyle,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n  interpolate,\n  useAnimatedReaction,\n  useAnimatedStyle,\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';\n\nexport type SwipeDirection = 'left' | 'right';\n\nexport type DeckfallHandle = {\n  /** Swipes the top card away, e.g. from a button. */\n  swipe: (direction: SwipeDirection) => void;\n};\n\nexport type DeckfallProps<T> = {\n  data: readonly T[];\n  keyExtractor: (item: T, index: number) => string;\n  renderCard: (item: T, index: number) => ReactNode;\n  /** Called after a card leaves the deck. */\n  onSwipe?: (item: T, direction: SwipeDirection, index: number) => void;\n  /** Called once the last card has left. */\n  onEmpty?: () => void;\n  /** Rendered when no cards are left. */\n  renderEmpty?: () => ReactNode;\n  /** Cards visible in the stack, including the top one. Defaults to 3. */\n  visible?: number;\n  /** Fraction of the deck width a drag must cross to swipe. Defaults to 0.3. */\n  threshold?: number;\n  /** Stamps shown on the top card while dragging left / right, e.g. \"Skip\" / \"Save\". */\n  leftLabel?: string;\n  rightLabel?: string;\n  /** Ticks when a drag crosses the swipe threshold. Defaults to true. */\n  haptics?: boolean;\n  /** Imperative handle with `swipe(direction)`. */\n  ref?: Ref<DeckfallHandle>;\n  style?: StyleProp<ViewStyle>;\n  /** Style of each card frame (radius, background, padding). */\n  cardStyle?: StyleProp<ViewStyle>;\n};\n\n/**\n * A stack of cards swiped away one at a time. The top card follows the\n * finger and tilts; the cards behind it rise into place as it leaves. A swipe\n * past the threshold or a quick flick sends the card off-screen.\n */\nexport function Deckfall<T>({\n  data,\n  keyExtractor,\n  renderCard,\n  onSwipe,\n  onEmpty,\n  renderEmpty,\n  visible = 3,\n  threshold = 0.3,\n  leftLabel,\n  rightLabel,\n  haptics = true,\n  ref,\n  style,\n  cardStyle,\n}: DeckfallProps<T>) {\n  const theme = useKinetikTheme();\n  const reduced = useReduceMotion();\n  const haptic = useHaptic(haptics);\n  const { width: screen } = useWindowDimensions();\n  const [index, setIndex] = useState(0);\n\n  const top = useSharedValue(0);\n  const dx = useSharedValue(0);\n  const dy = useSharedValue(0);\n  const width = useSharedValue(0);\n  const flying = useSharedValue(false);\n\n  const advance = (direction: SwipeDirection, from: number) => {\n    const item = data[from];\n    setIndex(from + 1);\n    if (item !== undefined) onSwipe?.(item, direction, from);\n    if (from + 1 >= data.length) onEmpty?.();\n  };\n\n  const fling = (direction: SwipeDirection, velocity: number) => {\n    'worklet';\n    if (flying.get()) return;\n    flying.set(true);\n    const from = top.get();\n    const to = (direction === 'right' ? 1 : -1) * (Math.max(width.get(), screen) * 1.4);\n    const done = (finished?: boolean) => {\n      'worklet';\n      if (!finished) return;\n      // Advance the deck and reset the drag in the same UI frame, so the card\n      // behind is already in its resting pose when it becomes the top card.\n      top.set(from + 1);\n      dx.set(0);\n      dy.set(0);\n      flying.set(false);\n      scheduleOnRN(advance, direction, from);\n    };\n    if (reduced) {\n      dx.set(withTiming(to, timing('quick', true), done));\n    } else {\n      dx.set(\n        withSpring(to, { ...spring('snappy', false, velocity), overshootClamping: true }, done),\n      );\n    }\n  };\n\n  useImperativeHandle(ref, () => ({\n    swipe: (direction: SwipeDirection) => fling(direction, 0),\n  }));\n\n  // A tick when the drag crosses the threshold: releasing now will swipe.\n  const tick = () => haptic('tick');\n  useAnimatedReaction(\n    () => width.get() > 0 && Math.abs(dx.get()) > width.get() * threshold && !flying.get(),\n    (past, prev) => {\n      if (prev !== null && past && !prev) scheduleOnRN(tick);\n    },\n  );\n\n  // Drag handlers shared by every card; only the top card's gesture is enabled.\n  const drag = (tx: number, ty: number) => {\n    'worklet';\n    if (flying.get()) return;\n    dx.set(tx);\n    dy.set(reduced ? 0 : ty * 0.25);\n  };\n  const release = (tx: number, vx: number) => {\n    'worklet';\n    if (flying.get()) return;\n    const far = Math.abs(tx) > width.get() * threshold;\n    const flick = Math.abs(vx) > 900 && Math.sign(vx) === Math.sign(tx);\n    if (far || flick) {\n      fling(tx > 0 ? 'right' : 'left', vx);\n    } else {\n      dx.set(withSpring(0, spring('bouncy', reduced)));\n      dy.set(withSpring(0, spring('bouncy', reduced)));\n    }\n  };\n\n  const onAccessibilityAction = (e: AccessibilityActionEvent) => {\n    if (e.nativeEvent.actionName === 'swipeLeft') fling('left', 0);\n    if (e.nativeEvent.actionName === 'swipeRight') fling('right', 0);\n  };\n\n  const remaining = data.length - index;\n  const shown = data.slice(index, index + visible + 1);\n\n  return (\n    <View\n      style={[styles.deck, { paddingBottom: (visible - 1) * 12 }, style]}\n      onLayout={(e) => width.set(e.nativeEvent.layout.width)}\n    >\n      {remaining <= 0 && renderEmpty ? <View style={styles.empty}>{renderEmpty()}</View> : null}\n      {/* Render back to front so the top card paints last. */}\n      {shown\n        .map((item, i) => ({ item, n: index + i }))\n        .reverse()\n        .map(({ item, n }) => (\n          <DeckCard\n            key={keyExtractor(item, n)}\n            n={n}\n            isTop={n === index}\n            top={top}\n            dx={dx}\n            dy={dy}\n            width={width}\n            threshold={threshold}\n            visible={visible}\n            reduced={reduced}\n            leftLabel={leftLabel}\n            rightLabel={rightLabel}\n            onDrag={drag}\n            onRelease={release}\n            onAccessibilityAction={onAccessibilityAction}\n            style={[\n              {\n                borderRadius: theme.radius.xl,\n                backgroundColor: theme.colors.surface,\n                borderColor: theme.colors.border,\n                boxShadow: theme.shadow.md,\n              },\n              cardStyle,\n            ]}\n          >\n            {renderCard(item, n)}\n          </DeckCard>\n        ))}\n    </View>\n  );\n}\n\nfunction DeckCard({\n  n,\n  top,\n  dx,\n  dy,\n  width,\n  threshold,\n  visible,\n  reduced,\n  leftLabel,\n  rightLabel,\n  isTop,\n  onDrag,\n  onRelease,\n  onAccessibilityAction,\n  style,\n  children,\n}: {\n  n: number;\n  top: SharedValue<number>;\n  dx: SharedValue<number>;\n  dy: SharedValue<number>;\n  width: SharedValue<number>;\n  threshold: number;\n  visible: number;\n  reduced: boolean;\n  leftLabel?: string;\n  rightLabel?: string;\n  isTop: boolean;\n  onDrag: (tx: number, ty: number) => void;\n  onRelease: (tx: number, vx: number) => void;\n  onAccessibilityAction: (e: AccessibilityActionEvent) => void;\n  style: StyleProp<ViewStyle>;\n  children: ReactNode;\n}) {\n  const { colors, font, radius } = useKinetikTheme();\n\n  const animated = useAnimatedStyle(() => {\n    const w = Math.max(1, width.get());\n    const progress = Math.min(1, Math.abs(dx.get()) / (w * threshold));\n    const depth = n - top.get();\n    if (depth < 0) return { opacity: 0 };\n    if (depth === 0) {\n      return {\n        opacity: 1,\n        transform: [\n          { translateX: dx.get() },\n          { translateY: dy.get() },\n          { rotate: `${reduced ? 0 : (dx.get() / w) * 14}deg` },\n        ],\n      };\n    }\n    // Cards behind rise one step as the top card leaves.\n    const pos = Math.max(0, depth - progress);\n    return {\n      opacity: depth >= visible ? interpolate(progress, [0, 1], [0, 1]) : 1,\n      transform: [{ translateY: pos * 12 }, { scale: 1 - pos * 0.05 }],\n    };\n  });\n\n  const leftStamp = useAnimatedStyle(() => {\n    if (n !== top.get()) return { opacity: 0 };\n    const w = Math.max(1, width.get());\n    return { opacity: interpolate(-dx.get(), [w * 0.05, w * threshold], [0, 1], 'clamp') };\n  });\n  const rightStamp = useAnimatedStyle(() => {\n    if (n !== top.get()) return { opacity: 0 };\n    const w = Math.max(1, width.get());\n    return { opacity: interpolate(dx.get(), [w * 0.05, w * threshold], [0, 1], 'clamp') };\n  });\n\n  const pan = Gesture.Pan()\n    .withTestId(`deckfall-${n}`)\n    .enabled(isTop)\n    .activeOffsetX([-10, 10])\n    .failOffsetY([-24, 24])\n    .onUpdate((e) => onDrag(e.translationX, e.translationY))\n    .onEnd((e) => onRelease(e.translationX, e.velocityX));\n\n  return (\n    <GestureDetector gesture={pan}>\n      <Animated.View\n        collapsable={false}\n        accessible={isTop}\n        importantForAccessibility={isTop ? 'auto' : 'no-hide-descendants'}\n        accessibilityElementsHidden={!isTop}\n        accessibilityHint={isTop ? 'Swipe left or right, or use the actions menu' : undefined}\n        accessibilityActions={\n          isTop\n            ? [\n                { name: 'swipeLeft', label: leftLabel ?? 'Swipe left' },\n                { name: 'swipeRight', label: rightLabel ?? 'Swipe right' },\n              ]\n            : undefined\n        }\n        onAccessibilityAction={onAccessibilityAction}\n        style={[styles.card, style, animated]}\n      >\n        {children}\n        {rightLabel ? (\n          <Animated.View\n            pointerEvents=\"none\"\n            style={[\n              styles.stamp,\n              styles.stampLeft,\n              { borderColor: colors.success, borderRadius: radius.sm },\n              rightStamp,\n            ]}\n          >\n            <Text style={[styles.stampText, { color: colors.success, fontSize: font.size.lg }]}>\n              {rightLabel}\n            </Text>\n          </Animated.View>\n        ) : null}\n        {leftLabel ? (\n          <Animated.View\n            pointerEvents=\"none\"\n            style={[\n              styles.stamp,\n              styles.stampRight,\n              { borderColor: colors.danger, borderRadius: radius.sm },\n              leftStamp,\n            ]}\n          >\n            <Text style={[styles.stampText, { color: colors.danger, fontSize: font.size.lg }]}>\n              {leftLabel}\n            </Text>\n          </Animated.View>\n        ) : null}\n      </Animated.View>\n    </GestureDetector>\n  );\n}\n\nconst styles = StyleSheet.create({\n  deck: { minHeight: 300 },\n  empty: { ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center' },\n  card: {\n    ...StyleSheet.absoluteFill,\n    borderWidth: StyleSheet.hairlineWidth,\n    overflow: 'hidden',\n  },\n  stamp: {\n    position: 'absolute',\n    top: 20,\n    borderWidth: 3,\n    paddingHorizontal: 10,\n    paddingVertical: 4,\n  },\n  stampLeft: { left: 20, transform: [{ rotate: '-12deg' }] },\n  stampRight: { right: 20, transform: [{ rotate: '12deg' }] },\n  stampText: { fontWeight: '800', letterSpacing: 1.5, textTransform: 'uppercase' },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "component",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "card",
    "web": "live",
    "perf": {
      "budget": "Only visible cards (plus one) are mounted. Dragging updates two shared values on the UI thread; each card evaluates one style worklet.",
      "measurements": [
        {
          "device": "Pixel 9 emulator, API 35",
          "kind": "emulator",
          "date": "2026-10-12",
          "build": "release",
          "frameMs": 18,
          "p90Ms": 25,
          "baselineMs": 20,
          "jankyPercent": 4.9,
          "atRest": "idle",
          "memoryMb": 158
        }
      ]
    },
    "a11y": {
      "reducedMotion": "No tilt or vertical drift; a swiped card fades out quickly instead of flying away.",
      "screenReader": "Only the top card is exposed, with 'swipe left' and 'swipe right' actions named by leftLabel/rightLabel. Other cards are hidden.",
      "touchTarget": "The whole top card."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { Deckfall, type DeckfallHandle } from '@/components/kinetik/deckfall';\n\nconst deck = useRef<DeckfallHandle>(null);\n\n<Deckfall\n  ref={deck}\n  data={places}\n  keyExtractor={(p) => p.id}\n  renderCard={(p) => <PlaceCard place={p} />}\n  onSwipe={(p, direction) => direction === 'right' && save(p)}\n  leftLabel=\"Skip\"\n  rightLabel=\"Save\"\n  style={{ height: 320 }}\n/>;\n\n<Button title=\"Save\" onPress={() => deck.current?.swipe('right')} />;\n```\n\nGive the deck a height; cards fill it. A drag past `threshold` (30% of the width) or a quick flick swipes the card; anything less springs back.\n\n## When to use\n\n- Deciding on one item at a time: triage, discovery, flashcards, onboarding choices.\n- Short sets where each item deserves full attention.\n\n## When not to use\n\n- Browsing or comparing. A list or grid shows more at once.\n- Anything users may want to undo often; swiped cards are gone unless you add your own undo.\n\n## Notes\n\n- Always offer buttons (via `ref.swipe`) next to the deck; not everyone can or wants to drag.\n- Screen readers get the top card only, with swipe actions named after your labels.\n- The drag is horizontal; vertical movement passes through to scroll views.\n- A tick plays when a drag crosses the threshold, so users feel when letting go will swipe.\n"
  },
  "docs": "Prefer `npx kinetik-ui add deckfall`: it installs dependencies with expo install, so versions match your Expo SDK."
}
