{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "story-card",
  "type": "registry:item",
  "title": "Story Card",
  "description": "Full-bleed stories with progress bars: tap to step, hold to pause, each picture drifting closer as it plays.",
  "categories": [
    "card"
  ],
  "dependencies": [
    "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/story-card.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/story-card.tsx",
      "content": "import { useEffect, useState, type ReactNode } from 'react';\nimport {\n  AccessibilityInfo,\n  Image,\n  Platform,\n  Pressable,\n  StyleSheet,\n  Text,\n  View,\n  type AccessibilityActionEvent,\n  type GestureResponderEvent,\n  type ImageSourcePropType,\n  type StyleProp,\n  type ViewStyle,\n} from 'react-native';\nimport Animated, {\n  Easing,\n  cancelAnimation,\n  useAnimatedStyle,\n  useSharedValue,\n  withDelay,\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 { durations, spring, timing } from '../../lib/kinetik/motion/motion';\nimport { gradient } from '../../lib/kinetik/tokens/gradient';\nimport { useKinetikTheme } from '../../lib/kinetik/tokens/theme';\nimport { withAlpha } from '../../lib/kinetik/tokens/tokens';\n\nexport type Story = {\n  key: string;\n  image: ImageSourcePropType;\n  title?: string;\n  caption?: string;\n  /** Describes the picture. Defaults to the title. */\n  accessibilityLabel?: string;\n};\n\nexport type StoryCardProps = {\n  stories: readonly Story[];\n  /** How long each story shows, in ms. Defaults to 5000. */\n  duration?: number;\n  /** Story to start on. Defaults to 0. */\n  initialIndex?: number;\n  onIndexChange?: (index: number) => void;\n  /** Called after the last story when `loop` is off. */\n  onEnd?: () => void;\n  /** Start again after the last story. Defaults to true. */\n  loop?: boolean;\n  /** Hold every story still, e.g. while the card is off screen. */\n  paused?: boolean;\n  /** Height divided by width. Defaults to 1.5. */\n  aspect?: number;\n  /** Laid over the top of the card, under the progress bars: a source, a time. */\n  header?: ReactNode;\n  /** A tick when stepping by hand. Defaults to true. */\n  haptics?: boolean;\n  testID?: string;\n  style?: StyleProp<ViewStyle>;\n};\n\n/**\n * Full-bleed stories with a progress bar for each one. Tap the right side for\n * the next, the left for the previous, and hold anywhere to pause. Each\n * picture drifts slowly closer while it shows, and the next fades in over it.\n */\nexport function StoryCard({\n  stories,\n  duration = 5000,\n  initialIndex = 0,\n  onIndexChange,\n  onEnd,\n  loop = true,\n  paused = false,\n  aspect = 1.5,\n  header,\n  haptics = true,\n  testID,\n  style,\n}: StoryCardProps) {\n  const { colors, font, radius, space, scheme } = useKinetikTheme();\n  // Scrims are dark in both schemes: the text over them is always light, on a photo.\n  const ink = scheme === 'dark' ? colors.background : colors.text;\n  const reduced = useReduceMotion();\n  const haptic = useHaptic(haptics);\n  const screenReader = useScreenReader();\n  const n = stories.length;\n\n  const [width, setWidth] = useState(0);\n  const [index, setIndex] = useState(() => Math.max(0, Math.min(n - 1, initialIndex)));\n  const [holding, setHolding] = useState(false);\n  const [ended, setEnded] = useState(false);\n  // Bumped whenever a bar restarts, so restarting the same story runs it again.\n  const [round, setRound] = useState(0);\n  // Auto-advance stops for screen readers: content must not move on before it is read.\n  const running = !paused && !holding && !ended && !screenReader && n > 0;\n\n  const progress = useSharedValue(0);\n  const press = useSharedValue(0);\n\n  const go = (next: number, byHand: boolean) => {\n    if (next < 0) {\n      // Before the first story: start it over.\n      progress.set(0);\n      setEnded(false);\n      setRound((r) => r + 1);\n      return;\n    }\n    if (next >= n) {\n      if (!loop) {\n        setEnded(true);\n        onEnd?.();\n        return;\n      }\n      next = 0;\n    }\n    if (byHand) haptic('tick');\n    progress.set(0);\n    setEnded(false);\n    setRound((r) => r + 1);\n    setIndex(next);\n    onIndexChange?.(next);\n  };\n\n  // Runs the current story's bar from wherever it stands, and moves on when it fills.\n  useEffect(() => {\n    if (!running) {\n      cancelAnimation(progress);\n      return;\n    }\n    const advance = () => go(index + 1, false);\n    const left = (1 - progress.get()) * duration;\n    progress.set(\n      withTiming(1, { duration: left, easing: Easing.linear }, (done) => {\n        'worklet';\n        if (done) scheduleOnRN(advance);\n      }),\n    );\n    // `go` reads the latest props on each render; the bar restarts only when these change.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [running, index, round, duration, progress]);\n\n  const onPress = (e: GestureResponderEvent) => {\n    const { locationX } = e.nativeEvent;\n    // The left third goes back; the rest goes forward, as people expect from stories.\n    go(locationX < width / 3 ? index - 1 : index + 1, true);\n  };\n\n  const shell = useAnimatedStyle(() => ({ transform: [{ scale: 1 - press.get() * 0.015 }] }));\n\n  const onAccessibilityAction = (e: AccessibilityActionEvent) => {\n    if (e.nativeEvent.actionName === 'increment') go(index + 1, true);\n    else if (e.nativeEvent.actionName === 'decrement') go(index - 1, true);\n  };\n\n  const story = stories[index];\n\n  return (\n    <Animated.View\n      testID={testID}\n      onLayout={(e) => setWidth(e.nativeEvent.layout.width)}\n      style={[\n        styles.card,\n        { borderRadius: radius.xl, backgroundColor: colors.surfaceSunken },\n        width > 0 && { height: width * aspect },\n        style,\n        shell,\n      ]}\n    >\n      {stories.map((s, i) => (\n        <Layer key={s.key} story={s} active={i === index} progress={progress} still={reduced} />\n      ))}\n\n      <Pressable\n        onPress={onPress}\n        onLongPress={() => {}}\n        delayLongPress={220}\n        onPressIn={() => {\n          setHolding(true);\n          press.set(withSpring(1, spring('snappy', reduced)));\n        }}\n        onPressOut={() => {\n          setHolding(false);\n          press.set(withSpring(0, spring('smooth', reduced)));\n        }}\n        accessibilityRole=\"adjustable\"\n        accessibilityLabel={story?.accessibilityLabel ?? story?.title ?? 'Story'}\n        accessibilityValue={{ text: `Story ${index + 1} of ${n}` }}\n        accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}\n        onAccessibilityAction={onAccessibilityAction}\n        style={[StyleSheet.absoluteFill, styles.touch]}\n      />\n\n      <View\n        pointerEvents=\"box-none\"\n        style={[\n          styles.top,\n          { padding: space[3], gap: space[3] },\n          gradient(`linear-gradient(180deg, ${withAlpha(ink, 0.5)} 0%, ${withAlpha(ink, 0)} 100%)`),\n        ]}\n      >\n        <View style={styles.bars}>\n          {stories.map((s, i) => (\n            <Bar\n              key={s.key}\n              state={i < index ? 'done' : i > index ? 'next' : 'now'}\n              progress={progress}\n            />\n          ))}\n        </View>\n        {header}\n      </View>\n\n      {story?.title || story?.caption ? (\n        <View\n          pointerEvents=\"none\"\n          style={[\n            styles.bottom,\n            { padding: space[5], paddingTop: space[12] },\n            gradient(\n              `linear-gradient(180deg, ${withAlpha(ink, 0)} 0%, ${withAlpha(ink, 0.78)} 100%)`,\n            ),\n          ]}\n        >\n          <Caption key={story.key} still={reduced}>\n            {story.title ? (\n              <Text\n                style={{\n                  color: colors.highlight,\n                  fontSize: font.size['2xl'],\n                  fontWeight: font.weight.bold,\n                  letterSpacing: -0.6,\n                }}\n              >\n                {story.title}\n              </Text>\n            ) : null}\n            {story.caption ? (\n              <Text style={{ color: withAlpha(colors.highlight, 0.78), fontSize: font.size.sm }}>\n                {story.caption}\n              </Text>\n            ) : null}\n          </Caption>\n        </View>\n      ) : null}\n    </Animated.View>\n  );\n}\n\n/**\n * True while a screen reader is running. The web cannot tell (react-native-web\n * always answers yes), so there it is always false.\n */\nfunction useScreenReader(): boolean {\n  const [on, setOn] = useState(false);\n  useEffect(() => {\n    if (Platform.OS === 'web') return;\n    let alive = true;\n    AccessibilityInfo.isScreenReaderEnabled()\n      .then((v) => alive && setOn(v))\n      .catch(() => {});\n    const sub = AccessibilityInfo.addEventListener('screenReaderChanged', setOn);\n    return () => {\n      alive = false;\n      sub.remove();\n    };\n  }, []);\n  return on;\n}\n\n/** One picture: it fades in over the last and drifts closer while its bar fills. */\nfunction Layer({\n  story,\n  active,\n  progress,\n  still,\n}: {\n  story: Story;\n  active: boolean;\n  progress: SharedValue<number>;\n  still: boolean;\n}) {\n  const shown = useSharedValue(active ? 1 : 0);\n  useEffect(() => {\n    if (active) shown.set(withTiming(1, timing('slow', still)));\n    // The outgoing picture stays under the incoming one until it has fully arrived.\n    else shown.set(withDelay(still ? 0 : durations.slow, withTiming(0, { duration: 0 })));\n  }, [active, still, shown]);\n\n  const style = useAnimatedStyle(() => ({\n    opacity: shown.get(),\n    // The drift belongs to the picture showing; one that has left keeps its last framing.\n    transform: [{ scale: still ? 1 : 1.02 + (active ? progress.get() : 1) * 0.06 }],\n  }));\n\n  return (\n    <Animated.View\n      pointerEvents=\"none\"\n      // The incoming picture sits on top.\n      style={[StyleSheet.absoluteFill, { zIndex: active ? 1 : 0 }, style]}\n      accessibilityElementsHidden\n      importantForAccessibility=\"no-hide-descendants\"\n    >\n      <Image source={story.image} style={StyleSheet.absoluteFill} resizeMode=\"cover\" />\n    </Animated.View>\n  );\n}\n\nfunction Bar({\n  state,\n  progress,\n}: {\n  state: 'done' | 'now' | 'next';\n  progress: SharedValue<number>;\n}) {\n  const { colors } = useKinetikTheme();\n  const fill = useAnimatedStyle(() => ({\n    width: `${(state === 'done' ? 1 : state === 'next' ? 0 : progress.get()) * 100}%`,\n  }));\n  return (\n    <View style={[styles.bar, { backgroundColor: withAlpha(colors.highlight, 0.3) }]}>\n      <Animated.View style={[styles.barFill, { backgroundColor: colors.highlight }, fill]} />\n    </View>\n  );\n}\n\n/** The caption rises in with each new story. */\nfunction Caption({ children, still }: { children: ReactNode; still: boolean }) {\n  const t = useSharedValue(still ? 1 : 0);\n  useEffect(() => {\n    t.set(withSpring(1, spring('smooth', still)));\n  }, [still, t]);\n  const style = useAnimatedStyle(() => ({\n    opacity: t.get(),\n    transform: [{ translateY: (1 - t.get()) * 14 }],\n  }));\n  return <Animated.View style={[{ gap: 4 }, style]}>{children}</Animated.View>;\n}\n\nconst styles = StyleSheet.create({\n  card: { overflow: 'hidden', width: '100%' },\n  touch: { zIndex: 2 },\n  top: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 3 },\n  bars: { flexDirection: 'row', gap: 4 },\n  bar: { flex: 1, height: 2.5, borderRadius: 2, overflow: 'hidden' },\n  barFill: { height: '100%', borderRadius: 2 },\n  bottom: { position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 3 },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "component",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "card",
    "web": "live",
    "perf": {
      "budget": "One timing value drives the bar and the drift on the UI thread; every picture is mounted once and crossfaded, so stepping never waits on a decode.",
      "measurements": [
        {
          "device": "Pixel 9 emulator, API 35",
          "kind": "emulator",
          "date": "2026-10-12",
          "build": "release",
          "frameMs": 19,
          "p90Ms": 25,
          "baselineMs": 20,
          "jankyPercent": 1.11,
          "atRest": "animating",
          "memoryMb": 160,
          "notes": "Animates by design while a story plays; pass paused off-screen."
        }
      ]
    },
    "a11y": {
      "reducedMotion": "Pictures switch without the fade or the drift; the bars still fill.",
      "screenReader": "An adjustable element named after the story, valued 'Story 2 of 5'; swipe up or down to step. Stories do not advance on their own while a screen reader runs.",
      "touchTarget": "The whole card: its left third steps back, the rest forward."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { StoryCard } from '@/components/kinetik/story-card';\n\n<StoryCard\n  stories={[\n    { key: 'dunes', image: require('./dunes.jpg'), title: 'Huacachina', caption: 'Day 1' },\n    { key: 'sands', image: require('./sands.jpg'), title: 'White Sands', caption: 'Day 2' },\n  ]}\n  header={<SourceRow />}\n/>;\n```\n\nThe card takes the width it is given and sets its height from `aspect` (1.5 by default). `header` is laid over the top under the bars, for a source and a time.\n\n## When to use\n\n- Short visual sequences people tap through: trip highlights, product launches, a recap.\n- Onboarding told in pictures, where people set the pace.\n\n## When not to use\n\n- Content people need to read closely or come back to. Use a list or a carousel they control.\n- Long videos. This shows pictures; pair it with a video player for footage.\n\n## Notes\n\n- Tap the left third to go back, anywhere else to go forward; hold to pause. Pass `paused` while the card is off screen.\n- While a screen reader runs, stories do not move on by themselves; people step with the adjust gesture.\n- Every picture is mounted once and crossfaded, so keep the set short (up to about ten) and the images small.\n- With reduced motion pictures switch without the fade or the drift.\n"
  },
  "docs": "Prefer `npx kinetik-ui add story-card`: it installs dependencies with expo install, so versions match your Expo SDK."
}
