{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "elastic-slider",
  "type": "registry:item",
  "title": "Elastic Slider",
  "description": "A slider that stretches like rubber past its ends and springs back, with a value bubble.",
  "categories": [
    "interaction"
  ],
  "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/elastic-slider.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/elastic-slider.tsx",
      "content": "import { useEffect, useState, type ReactNode } 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, { useAnimatedStyle, useSharedValue, withSpring } 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';\nimport { minTouchTarget } from '../../lib/kinetik/tokens/tokens';\n\nexport type ElasticSliderProps = {\n  value: number;\n  /** Called as the stepped value changes while dragging. */\n  onValueChange?: (value: number) => void;\n  /** Called once when the finger lifts. */\n  onSlidingComplete?: (value: number) => void;\n  /** Defaults to 0. */\n  min?: number;\n  /** Defaults to 1. */\n  max?: number;\n  /** Snap interval. Defaults to 1/100 of the range. Detents tick when `haptics` is on. */\n  step?: number;\n  /** Accessible name. Shown above the track when `showLabel` is true. */\n  label: string;\n  /** Show the label and current value above the track. Defaults to true. */\n  showLabel?: boolean;\n  /** Formats the value for the label, bubble and screen readers. */\n  formatValue?: (value: number) => string;\n  /** Icon before the track, e.g. a quiet speaker. Nudged outward when the track is pulled past its start. */\n  leading?: ReactNode;\n  /** Icon after the track, e.g. a loud speaker. Nudged outward when the track is pulled past its end. */\n  trailing?: ReactNode;\n  /** Fill colour. Defaults to the theme ember. */\n  color?: string;\n  disabled?: boolean;\n  /** Ticks on each step (when steps are coarse) and at the ends. Defaults to true. */\n  haptics?: boolean;\n  style?: StyleProp<ViewStyle>;\n};\n\n/** Track thickness at rest and while held. */\nconst TRACK = 8;\nconst TRACK_ACTIVE = 16;\n\n/** Rubber-band resistance: the further past the end, the harder it pulls back. */\nfunction rubber(distance: number, dimension: number) {\n  'worklet';\n  const d = Math.abs(distance);\n  const resisted = (d * dimension * 0.55) / (dimension + d * 0.55);\n  return Math.sign(distance) * resisted;\n}\n\nconst decimalsOf = (n: number) => {\n  const s = String(n);\n  return s.includes('.') ? s.split('.')[1]!.length : 0;\n};\n\n/**\n * A slider in the style of the iOS media controls: a slim track that swells\n * while held and fills from the left, with no thumb to aim for. Pulling past\n * either end stretches it like rubber, nudges the icon on that side, and it\n * springs back on release.\n */\nexport function ElasticSlider({\n  value,\n  onValueChange,\n  onSlidingComplete,\n  min = 0,\n  max = 1,\n  step,\n  label,\n  showLabel = true,\n  formatValue,\n  leading,\n  trailing,\n  color,\n  disabled = false,\n  haptics = true,\n  style,\n}: ElasticSliderProps) {\n  const { colors, font, space, radius } = useKinetikTheme();\n  const reduced = useReduceMotion();\n  const haptic = useHaptic(haptics);\n  const fill = color ?? colors.ember;\n  const range = max - min || 1;\n  const stepSize = step ?? range / 100;\n  const decimals = Math.min(6, decimalsOf(stepSize));\n  // Only tick on detents that are coarse enough to feel; continuous sliders tick at the ends.\n  const tickSteps = range / stepSize <= 40;\n  const format = formatValue ?? ((v: number) => v.toFixed(decimals));\n\n  const width = useSharedValue(0);\n  const progress = useSharedValue((value - min) / range);\n  const stretch = useSharedValue(0);\n  const active = useSharedValue(0);\n  const committed = useSharedValue(value);\n  const dragging = useSharedValue(false);\n  // Value under the finger while dragging; otherwise the controlled `value` is shown.\n  const [dragValue, setDragValue] = useState<number | null>(null);\n  const bubble = dragValue ?? value;\n\n  // Follow external value changes when the user is not dragging.\n  useEffect(() => {\n    if (dragging.get()) return;\n    const p = (Math.min(max, Math.max(min, value)) - min) / range;\n    progress.set(reduced ? p : withSpring(p, spring('smooth')));\n    committed.set(value);\n  }, [value, min, max, range, reduced, progress, committed, dragging]);\n\n  const emit = (v: number, edge: boolean) => {\n    setDragValue(v);\n    onValueChange?.(v);\n    if (edge || tickSteps) haptic('tick');\n  };\n  const complete = (v: number) => {\n    setDragValue(null);\n    onSlidingComplete?.(v);\n  };\n\n  const moveTo = (x: number) => {\n    'worklet';\n    const w = width.get();\n    if (w === 0) return;\n    const raw = x / w;\n    const clamped = Math.max(0, Math.min(1, raw));\n    progress.set(clamped);\n    // Past the ends, stretch with resistance instead of moving the thumb.\n    const over = (raw - clamped) * w;\n    stretch.set(reduced ? 0 : rubber(over, w * 0.35));\n    const stepped = Math.round((min + clamped * range) / stepSize) * stepSize;\n    const v = Math.max(min, Math.min(max, Number(stepped.toFixed(decimals))));\n    if (v !== committed.get()) {\n      committed.set(v);\n      scheduleOnRN(emit, v, v === min || v === max);\n    }\n  };\n\n  const release = () => {\n    'worklet';\n    dragging.set(false);\n    active.set(withSpring(0, spring('smooth', reduced)));\n    stretch.set(withSpring(0, spring('bouncy', reduced)));\n    // Settle the thumb exactly on the committed step.\n    progress.set(withSpring((committed.get() - min) / range, spring('snappy', reduced)));\n    scheduleOnRN(complete, committed.get());\n  };\n\n  const pan = Gesture.Pan()\n    .withTestId('elastic-slider')\n    .enabled(!disabled)\n    .activeOffsetX([-4, 4])\n    .failOffsetY([-14, 14])\n    .onBegin(() => {\n      dragging.set(true);\n      active.set(withSpring(1, spring('snappy', reduced)));\n    })\n    .onStart((e) => moveTo(e.x))\n    .onUpdate((e) => moveTo(e.x))\n    .onFinalize(() => release());\n\n  const tap = Gesture.Tap()\n    .withTestId('elastic-slider-tap')\n    .enabled(!disabled)\n    .onEnd((e) => {\n      moveTo(e.x);\n      release();\n    });\n\n  const trackStyle = useAnimatedStyle(() => {\n    const w = width.get();\n    const st = stretch.get();\n    const grow = w === 0 ? 1 : (w + Math.abs(st)) / w;\n    return {\n      height: TRACK + active.get() * (TRACK_ACTIVE - TRACK),\n      // Stretched rubber gets longer and a little thinner.\n      transform: [\n        { translateX: st / 2 },\n        { scaleX: grow },\n        { scaleY: 1 - Math.min(0.35, (grow - 1) * 1.6) },\n      ],\n    };\n  });\n  const fillStyle = useAnimatedStyle(() => ({ width: `${progress.get() * 100}%` }));\n  // The whole control leans in a touch while held.\n  const rowStyle = useAnimatedStyle(() => ({ transform: [{ scale: 1 + active.get() * 0.02 }] }));\n  const leadingStyle = useAnimatedStyle(() => {\n    const st = Math.min(0, stretch.get());\n    return {\n      opacity: 0.6 + active.get() * 0.4,\n      transform: [{ translateX: st * 0.6 }, { scale: 1 + Math.min(0.25, -st / 160) }],\n    };\n  });\n  const trailingStyle = useAnimatedStyle(() => {\n    const st = Math.max(0, stretch.get());\n    return {\n      opacity: 0.6 + active.get() * 0.4,\n      transform: [{ translateX: st * 0.6 }, { scale: 1 + Math.min(0.25, st / 160) }],\n    };\n  });\n\n  const onAccessibilityAction = (e: AccessibilityActionEvent) => {\n    if (disabled) return;\n    const delta = Math.max(stepSize, range / 20);\n    const next =\n      e.nativeEvent.actionName === 'increment' ? committed.get() + delta : committed.get() - delta;\n    const v = Number(\n      (Math.round(Math.max(min, Math.min(max, next)) / stepSize) * stepSize).toFixed(decimals),\n    );\n    committed.set(v);\n    progress.set(withSpring((v - min) / range, spring('snappy', reduced)));\n    onValueChange?.(v);\n    onSlidingComplete?.(v);\n  };\n\n  return (\n    <View style={[{ gap: space[2], opacity: disabled ? 0.45 : 1 }, style]}>\n      {showLabel && (\n        <View style={styles.header}>\n          <Text\n            style={{ color: colors.text, fontSize: font.size.sm, fontWeight: font.weight.medium }}\n          >\n            {label}\n          </Text>\n          <Text\n            style={{\n              color: colors.textMuted,\n              fontSize: font.size.sm,\n              fontVariant: ['tabular-nums'],\n            }}\n          >\n            {format(bubble)}\n          </Text>\n        </View>\n      )}\n      <Animated.View style={[styles.row, { gap: space[3] }, rowStyle]}>\n        {leading ? (\n          <Animated.View style={[styles.icon, leadingStyle]} pointerEvents=\"none\">\n            {leading}\n          </Animated.View>\n        ) : null}\n        <GestureDetector gesture={Gesture.Race(pan, tap)}>\n          <View\n            collapsable={false}\n            accessible\n            accessibilityRole=\"adjustable\"\n            accessibilityLabel={label}\n            accessibilityValue={{ min, max, now: bubble, text: format(bubble) }}\n            accessibilityState={{ disabled }}\n            accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}\n            onAccessibilityAction={onAccessibilityAction}\n            onLayout={(e) => width.set(e.nativeEvent.layout.width)}\n            style={[styles.hit, { height: minTouchTarget }]}\n          >\n            <Animated.View\n              style={[\n                styles.track,\n                { backgroundColor: colors.track, borderRadius: radius.pill },\n                trackStyle,\n              ]}\n            >\n              <Animated.View style={[styles.fill, { backgroundColor: fill }, fillStyle]} />\n            </Animated.View>\n          </View>\n        </GestureDetector>\n        {trailing ? (\n          <Animated.View style={[styles.icon, trailingStyle]} pointerEvents=\"none\">\n            {trailing}\n          </Animated.View>\n        ) : null}\n      </Animated.View>\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline' },\n  row: { flexDirection: 'row', alignItems: 'center' },\n  icon: { minWidth: 20, alignItems: 'center', justifyContent: 'center' },\n  hit: { flex: 1, justifyContent: 'center' },\n  track: { overflow: 'hidden' },\n  fill: { height: '100%' },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "component",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "interaction",
    "web": "live",
    "perf": {
      "budget": "Dragging updates shared values on the UI thread; React re-renders only when the stepped value changes.",
      "measurements": [
        {
          "device": "Pixel 9 emulator, API 35",
          "kind": "emulator",
          "date": "2026-10-12",
          "build": "release",
          "frameMs": 17,
          "p90Ms": 23,
          "baselineMs": 20,
          "jankyPercent": 2.22,
          "atRest": "idle",
          "memoryMb": 153
        }
      ]
    },
    "a11y": {
      "reducedMotion": "No rubber-band stretch; the thumb still follows the finger and the value bubble appears without travel.",
      "screenReader": "Adjustable role with min, max, current value and formatted text; increment and decrement move by one step (at least 5% of the range).",
      "touchTarget": "At least 44 pt tall across the full width."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { ElasticSlider } from '@/components/kinetik/elastic-slider';\n\nconst [volume, setVolume] = useState(64);\n\n<ElasticSlider\n  label=\"Volume\"\n  value={volume}\n  onValueChange={setVolume}\n  min={0}\n  max={100}\n  step={1}\n  formatValue={(v) => `${v}%`}\n/>;\n```\n\nThe slider is controlled: keep `value` in state and update it from `onValueChange`. Use `onSlidingComplete` for work that should happen once, such as saving.\n\n## When to use\n\n- Continuous settings where the exact number matters less than the feel: volume, brightness, intensity.\n- Ranges with a few meaningful steps; with 40 steps or fewer each detent ticks under the finger.\n\n## When not to use\n\n- Precise numeric entry. Pair it with a text field or use a stepper.\n- Ranges in a horizontally scrolling container; both want the horizontal drag.\n\n## Notes\n\n- Pulling past either end stretches the track with rubber-band resistance and it springs back on release.\n- A tap on the track jumps to that point.\n- Vertical drags are left to the parent scroll view.\n"
  },
  "docs": "Prefer `npx kinetik-ui add elastic-slider`: it installs dependencies with expo install, so versions match your Expo SDK."
}
