{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slide-confirm",
  "type": "registry:item",
  "title": "Slide to Confirm",
  "description": "Drag the knob across to confirm. A trail follows it, light sweeps the track, and it lands as a check.",
  "categories": [
    "interaction"
  ],
  "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/slide-confirm.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/slide-confirm.tsx",
      "content": "import { Canvas, Path, Skia } from '@shopify/react-native-skia';\nimport { useEffect, useState } from 'react';\nimport {\n  StyleSheet,\n  Text,\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  Easing,\n  interpolate,\n  useAnimatedReaction,\n  useAnimatedStyle,\n  useSharedValue,\n  withRepeat,\n  withSpring,\n  withTiming,\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, timing } from '../../lib/kinetik/motion/motion';\nimport { useKinetikTheme } from '../../lib/kinetik/tokens/theme';\nimport { withAlpha } from '../../lib/kinetik/tokens/tokens';\n\nexport type SlideConfirmProps = {\n  /** Instruction on the track, e.g. \"Slide to pay\". */\n  label: string;\n  /** Shown once confirmed, e.g. \"Paid\". Defaults to 'Done'. */\n  confirmedLabel?: string;\n  onConfirm: () => void;\n  /** Share of the track the knob must pass to confirm, 0.5–0.95. Defaults to 0.8. */\n  threshold?: number;\n  /** Return to the start this many ms after confirming. `null` stays confirmed. Defaults to 1800. */\n  resetAfter?: number | null;\n  disabled?: boolean;\n  /** A tick when the knob passes the threshold and success on confirm. Defaults to true. */\n  haptics?: boolean;\n  style?: StyleProp<ViewStyle>;\n};\n\nconst HEIGHT = 64;\nconst INSET = 4;\nconst KNOB = HEIGHT - INSET * 2;\n\nconst CHEVRONS = Skia.Path.MakeFromSVGString('M6 6 L12 12 L6 18 M12 6 L18 12 L12 18')!;\nconst CHECK = Skia.Path.MakeFromSVGString('M5.5 12.5 L10 17 L18.5 7.5')!;\n\n/**\n * Slide to confirm. A trail fills behind the knob as it is dragged, a soft\n * light sweeps the track to invite the gesture, and past the threshold the\n * knob runs to the end and turns into a check. Let go early and it springs\n * back.\n */\nexport function SlideConfirm({\n  label,\n  confirmedLabel = 'Done',\n  onConfirm,\n  threshold = 0.8,\n  resetAfter = 1800,\n  disabled = false,\n  haptics = true,\n  style,\n}: SlideConfirmProps) {\n  const { colors, font, radius, scheme, shadow } = useKinetikTheme();\n  const reduced = useReduceMotion();\n  const haptic = useHaptic(haptics);\n  const limit = Math.max(0.5, Math.min(0.95, threshold));\n\n  const [width, setWidth] = useState(0);\n  const [done, setDone] = useState(false);\n  const max = Math.max(0, width - KNOB - INSET * 2);\n\n  const x = useSharedValue(0);\n  const start = useSharedValue(0);\n  const held = useSharedValue(0);\n  const sweep = useSharedValue(0);\n  const confirmed = useSharedValue(0);\n\n  // A light passes over the track every few seconds while it waits.\n  useEffect(() => {\n    if (reduced || done || disabled) {\n      sweep.set(0);\n      return;\n    }\n    sweep.set(\n      withRepeat(withTiming(1, { duration: 2600, easing: Easing.inOut(Easing.quad) }), -1, false),\n    );\n  }, [reduced, done, disabled, sweep]);\n\n  useEffect(() => {\n    if (!done || resetAfter === null) return;\n    const t = setTimeout(() => {\n      setDone(false);\n      confirmed.set(withTiming(0, timing('base', reduced)));\n      x.set(withSpring(0, spring('smooth', reduced)));\n    }, resetAfter);\n    return () => clearTimeout(t);\n  }, [done, resetAfter, reduced, confirmed, x]);\n\n  const complete = () => {\n    setDone(true);\n    haptic('success');\n    onConfirm();\n  };\n  const armed = () => haptic('tick');\n\n  // Tick once as the knob crosses the threshold.\n  useAnimatedReaction(\n    () => max > 0 && x.get() >= max * limit,\n    (now, before) => {\n      if (now && before === false && held.get() > 0) scheduleOnRN(armed);\n    },\n  );\n\n  const finish = () => {\n    'worklet';\n    x.set(withSpring(max, spring('snappy', reduced)));\n    confirmed.set(withTiming(1, timing('base', reduced)));\n    scheduleOnRN(complete);\n  };\n\n  const pan = Gesture.Pan()\n    .withTestId('slide-confirm')\n    .enabled(!disabled && !done && max > 0)\n    .minDistance(2)\n    .onBegin(() => {\n      start.set(x.get());\n      held.set(withSpring(1, spring('snappy', reduced)));\n    })\n    .onUpdate((e) => {\n      x.set(Math.max(0, Math.min(max, start.get() + e.translationX)));\n    })\n    .onEnd((e) => {\n      const fast = e.velocityX > 900 && x.get() > max * 0.45;\n      if (x.get() >= max * limit || fast) finish();\n      else x.set(withSpring(0, spring('bouncy', reduced, reduced ? 0 : e.velocityX)));\n    })\n    .onFinalize(() => {\n      held.set(withSpring(0, spring('smooth', reduced)));\n    });\n\n  const knob = useAnimatedStyle(() => ({\n    transform: [{ translateX: x.get() }, { scale: 1 + held.get() * 0.04 }],\n  }));\n  const trail = useAnimatedStyle(() => ({\n    width: x.get() + KNOB,\n    // A faint trail while dragging; it floods once confirmed so the label reads on it.\n    opacity: (max > 0 ? interpolate(x.get(), [0, max], [0, 0.22]) : 0) + confirmed.get() * 0.78,\n  }));\n  const hint = useAnimatedStyle(() => ({\n    opacity: max > 0 ? Math.max(0, 1 - (x.get() / max) * 1.8) * (1 - confirmed.get()) : 1,\n    transform: [{ translateX: max > 0 ? (x.get() / max) * 24 : 0 }],\n  }));\n  const thanks = useAnimatedStyle(() => ({\n    opacity: confirmed.get(),\n    transform: [{ scale: 0.92 + confirmed.get() * 0.08 }],\n  }));\n  const light = useAnimatedStyle(() => ({\n    transform: [{ translateX: -160 + sweep.get() * (width + 320) }],\n  }));\n  const chevrons = useAnimatedStyle(() => ({ opacity: 1 - confirmed.get() }));\n  const check = useAnimatedStyle(() => ({\n    opacity: confirmed.get(),\n    transform: [{ scale: 0.6 + confirmed.get() * 0.4 }],\n  }));\n\n  const onAccessibilityAction = (e: AccessibilityActionEvent) => {\n    if (e.nativeEvent.actionName !== 'activate' || disabled || done || max <= 0) return;\n    x.set(withSpring(max, spring('snappy', reduced)));\n    confirmed.set(withTiming(1, timing('base', reduced)));\n    complete();\n  };\n\n  const shine = scheme === 'dark' ? colors.highlight : colors.text;\n\n  return (\n    <View\n      accessible\n      accessibilityRole=\"button\"\n      accessibilityLabel={done ? confirmedLabel : label}\n      accessibilityHint={done ? undefined : 'Double tap to confirm'}\n      accessibilityState={{ disabled: disabled || done }}\n      accessibilityActions={[{ name: 'activate' }]}\n      onAccessibilityAction={onAccessibilityAction}\n      onLayout={(e) => setWidth(e.nativeEvent.layout.width)}\n      style={[\n        styles.track,\n        {\n          borderRadius: radius.pill,\n          backgroundColor: colors.surfaceRaised,\n          borderColor: colors.border,\n          opacity: disabled ? 0.45 : 1,\n        },\n        style,\n      ]}\n    >\n      <Animated.View\n        pointerEvents=\"none\"\n        style={[\n          styles.light,\n          gradient(\n            `linear-gradient(100deg, ${withAlpha(shine, 0)} 0%, ${withAlpha(shine, 0.09)} 50%, ${withAlpha(shine, 0)} 100%)`,\n          ),\n          light,\n        ]}\n      />\n      <Animated.View\n        pointerEvents=\"none\"\n        style={[styles.trail, { backgroundColor: colors.ember, borderRadius: radius.pill }, trail]}\n      />\n      <Animated.View pointerEvents=\"none\" style={[styles.center, hint]}>\n        <Text\n          numberOfLines={1}\n          style={{\n            color: colors.textMuted,\n            fontSize: font.size.md,\n            fontWeight: font.weight.medium,\n          }}\n        >\n          {label}\n        </Text>\n      </Animated.View>\n      <Animated.View pointerEvents=\"none\" style={[styles.center, styles.full, thanks]}>\n        <Text\n          numberOfLines={1}\n          style={{\n            color: colors.onEmber,\n            fontSize: font.size.md,\n            fontWeight: font.weight.semibold,\n          }}\n        >\n          {confirmedLabel}\n        </Text>\n      </Animated.View>\n\n      <GestureDetector gesture={pan}>\n        <Animated.View\n          collapsable={false}\n          style={[styles.knob, { backgroundColor: colors.ember, boxShadow: shadow.md }, knob]}\n        >\n          <Animated.View style={[styles.glyph, chevrons]} pointerEvents=\"none\">\n            <Canvas style={StyleSheet.absoluteFill}>\n              <Path\n                path={CHEVRONS}\n                style=\"stroke\"\n                strokeWidth={2.2}\n                strokeCap=\"round\"\n                strokeJoin=\"round\"\n                color={colors.onEmber}\n              />\n            </Canvas>\n          </Animated.View>\n          <Animated.View style={[styles.glyph, check]} pointerEvents=\"none\">\n            <Canvas style={StyleSheet.absoluteFill}>\n              <Path\n                path={CHECK}\n                style=\"stroke\"\n                strokeWidth={2.4}\n                strokeCap=\"round\"\n                strokeJoin=\"round\"\n                color={colors.onEmber}\n              />\n            </Canvas>\n          </Animated.View>\n        </Animated.View>\n      </GestureDetector>\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  track: {\n    height: HEIGHT,\n    overflow: 'hidden',\n    justifyContent: 'center',\n    borderWidth: StyleSheet.hairlineWidth,\n  },\n  light: { position: 'absolute', top: 0, bottom: 0, left: 0, width: 160 },\n  trail: { position: 'absolute', left: INSET, top: INSET, bottom: INSET },\n  center: {\n    ...StyleSheet.absoluteFill,\n    alignItems: 'center',\n    justifyContent: 'center',\n    paddingLeft: KNOB,\n  },\n  full: { paddingLeft: 0 },\n  knob: {\n    position: 'absolute',\n    left: INSET,\n    top: INSET,\n    width: KNOB,\n    height: KNOB,\n    borderRadius: KNOB / 2,\n    alignItems: 'center',\n    justifyContent: 'center',\n  },\n  glyph: { position: 'absolute', width: 24, height: 24 },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "component",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "interaction",
    "web": "live",
    "perf": {
      "budget": "The knob follows the finger on the UI thread; the idle sweep is one repeating timing on a transform.",
      "measurements": [
        {
          "device": "Pixel 9 emulator, API 35",
          "kind": "emulator",
          "date": "2026-10-12",
          "build": "release",
          "frameMs": 18,
          "p90Ms": 32,
          "baselineMs": 20,
          "jankyPercent": 0.53,
          "atRest": "animating",
          "memoryMb": 159
        }
      ]
    },
    "a11y": {
      "reducedMotion": "No sweeping light; the knob still follows the finger and snaps without overshoot.",
      "screenReader": "A button named by the label; 'activate' confirms without dragging. After confirming it reads the confirmed label.",
      "touchTarget": "The knob is 56 pt; the track is 64 pt tall."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { SlideConfirm } from '@/components/kinetik/slide-confirm';\n\n<SlideConfirm label=\"Slide to send\" confirmedLabel=\"Sent\" onConfirm={send} />;\n```\n\nIt fills the width of its parent. Past `threshold` (80% of the track by default), or with a quick flick past halfway, the knob runs to the end and `onConfirm` fires once. It returns to the start after `resetAfter` ms; pass `null` to keep it confirmed.\n\n## When to use\n\n- Payments, transfers and other actions that should not happen by accident.\n- Lock screens and handoffs, where the gesture itself says \"I mean it\".\n\n## When not to use\n\n- Actions that need to be fast or repeated. A tap is kinder.\n- Inside horizontal scroll views or pagers; both want the sideways drag.\n\n## Notes\n\n- A tick plays as the knob crosses the threshold, and a success haptic on confirm.\n- Screen readers confirm with the activate action, no dragging needed.\n- The sweeping light stops once confirmed, while disabled and with reduced motion.\n"
  },
  "docs": "Prefer `npx kinetik-ui add slide-confirm`: it installs dependencies with expo install, so versions match your Expo SDK."
}
