{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "elastic-slider",
  "type": "registry:ui",
  "title": "Elastic Slider",
  "description": "A range slider whose track rubber-bands past either end and springs back on release.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/elastic-slider.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface ElasticSliderProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\" | \"children\"> {\n  /** Visible label and accessible name of the slider. */\n  label: string;\n  /** Hide the label row visually (it stays available to screen readers). */\n  hideLabel?: boolean;\n  /** Current value (controlled). */\n  value?: number;\n  /** Initial value (uncontrolled). */\n  defaultValue?: number;\n  /** Called on every value change. */\n  onValueChange?: (value: number) => void;\n  /** Called when a drag ends or a key changes the value. */\n  onValueCommit?: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  /** Turns a value into text for the bubble, label row and aria-valuetext. */\n  formatValue?: (value: number) => string;\n  /** Maximum distance in px the track rubber-bands past either end. */\n  elasticity?: number;\n  /** Spring that snaps the stretched track back. */\n  spring?: Spring;\n  /** When the value bubble above the thumb is shown. */\n  bubble?: \"always\" | \"active\" | \"off\";\n  /** Fill, thumb ring and focus colour. */\n  accent?: string;\n  /** Icon at the start of the track (e.g. volume low). */\n  startIcon?: React.ReactNode;\n  /** Icon at the end of the track (e.g. volume high). */\n  endIcon?: React.ReactNode;\n  disabled?: boolean;\n  /** Submits the value with a form under this name. */\n  name?: string;\n}\n\nconst clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));\n\n/** Thumb radius in px: the thumb travels inside the track so it never overhangs the ends. */\nconst THUMB_R = 10;\n\nfunction decimalsOf(step: number) {\n  const s = String(step);\n  return s.includes(\".\") ? s.length - s.indexOf(\".\") - 1 : 0;\n}\n\n/** Soft limit: grows 1:1 at first, then flattens toward `limit`. */\nfunction rubberBand(distance: number, limit: number) {\n  if (limit <= 0) return 0;\n  return limit * (1 - Math.exp(-distance / (limit * 1.6)));\n}\n\n/**\n * A range slider whose track stretches like rubber when dragged past either end and springs\n * back on release. Implements the WAI-ARIA slider pattern on the thumb; the stretch is driven by\n * motion values, so only stepped value changes re-render.\n */\nexport function ElasticSlider({\n  label,\n  hideLabel = false,\n  value: valueProp,\n  defaultValue = 50,\n  onValueChange,\n  onValueCommit,\n  min = 0,\n  max = 100,\n  step = 1,\n  formatValue = (v) => String(v),\n  elasticity = 28,\n  spring = { stiffness: 420, damping: 14, mass: 0.6 },\n  bubble = \"active\",\n  accent = \"#FF7448\",\n  startIcon,\n  endIcon,\n  disabled = false,\n  name,\n  className = \"\",\n  style,\n  ...props\n}: ElasticSliderProps) {\n  const labelId = React.useId();\n  const reduceMotion = useReducedMotion();\n  const [internal, setInternal] = React.useState(() => clamp(defaultValue, min, max));\n  const value = clamp(valueProp ?? internal, min, max);\n  const [pressed, setPressed] = React.useState(false);\n  const [keyboardFocus, setKeyboardFocus] = React.useState(false);\n\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const thumbRef = React.useRef<HTMLDivElement>(null);\n  const dragging = React.useRef(false);\n  const latest = React.useRef(value);\n\n  // Signed overshoot in px: negative past the start, positive past the end.\n  const overflow = useMotionValue(0);\n  const trackWidth = useMotionValue(1);\n  const stretchX = useTransform([overflow, trackWidth], ([o, w]: number[]) => 1 + Math.abs(o) / w);\n  // The band thins slightly as it stretches, like real elastic.\n  const stretchY = useTransform([overflow, trackWidth], ([o, w]: number[]) => 1 - Math.min((Math.abs(o) / w) * 2.2, 0.35));\n  const originX = useTransform(overflow, (o) => (o < 0 ? 1 : 0));\n  const startIconX = useTransform(overflow, (o) => Math.min(o, 0));\n  const endIconX = useTransform(overflow, (o) => Math.max(o, 0));\n  const startIconScale = useTransform(overflow, (o) => 1 + Math.max(-o, 0) / 90);\n  const endIconScale = useTransform(overflow, (o) => 1 + Math.max(o, 0) / 90);\n\n  const range = max - min || 1;\n  const fraction = (value - min) / range;\n  // Thumb centre (and fill end) inset by the thumb radius on both sides.\n  const thumbLeft = `calc(${THUMB_R}px + (100% - ${THUMB_R * 2}px) * ${fraction})`;\n  const text = formatValue(value);\n\n  const snap = (raw: number) => {\n    const stepped = Math.round((raw - min) / step) * step + min;\n    return clamp(Number(stepped.toFixed(decimalsOf(step))), min, max);\n  };\n\n  const commitValue = (next: number) => {\n    latest.current = next;\n    if (next === value) return;\n    if (valueProp === undefined) setInternal(next);\n    onValueChange?.(next);\n  };\n\n  const springBack = () => {\n    animate(overflow, 0, reduceMotion ? { duration: 0 } : { type: \"spring\", ...spring });\n  };\n\n  /** Maps a pointer x position to a value, and any distance past the ends to rubber-band stretch. */\n  const updateFromPointer = (clientX: number) => {\n    const rect = trackRef.current?.getBoundingClientRect();\n    if (!rect || rect.width === 0) return;\n    trackWidth.set(rect.width);\n    const ratio = (clientX - rect.left - THUMB_R) / Math.max(rect.width - THUMB_R * 2, 1);\n    commitValue(snap(min + clamp(ratio, 0, 1) * range));\n    if (reduceMotion) return;\n    if (clientX < rect.left) overflow.set(-rubberBand(rect.left - clientX, elasticity));\n    else if (clientX > rect.right) overflow.set(rubberBand(clientX - rect.right, elasticity));\n    else overflow.set(0);\n  };\n\n  const endDrag = () => {\n    if (!dragging.current) return;\n    dragging.current = false;\n    setPressed(false);\n    springBack();\n    onValueCommit?.(latest.current);\n  };\n\n  /** A small bump when the keyboard pushes against an end. */\n  const bump = (direction: 1 | -1) => {\n    if (reduceMotion || elasticity <= 0) return;\n    const width = trackRef.current?.offsetWidth;\n    if (width) trackWidth.set(width);\n    animate(overflow, direction * Math.min(elasticity, 10), { duration: 0.08, ease: \"easeOut\" }).then(springBack);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (disabled) return;\n    const big = Math.max(step, snap(min + range / 10) - min);\n    let next: number;\n    switch (event.key) {\n      case \"ArrowRight\":\n      case \"ArrowUp\":\n        next = value + step;\n        break;\n      case \"ArrowLeft\":\n      case \"ArrowDown\":\n        next = value - step;\n        break;\n      case \"PageUp\":\n        next = value + big;\n        break;\n      case \"PageDown\":\n        next = value - big;\n        break;\n      case \"Home\":\n        next = min;\n        break;\n      case \"End\":\n        next = max;\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n    if ((next > max && value === max) || (next < min && value === min)) {\n      bump(next > max ? 1 : -1);\n      return;\n    }\n    const snapped = snap(next);\n    commitValue(snapped);\n    onValueCommit?.(snapped);\n  };\n\n  const showBubble = bubble === \"always\" || (bubble === \"active\" && (pressed || keyboardFocus));\n\n  return (\n    <div\n      className={`group/es flex w-full select-none flex-col gap-2.5 ${disabled ? \"opacity-50\" : \"\"} ${className}`}\n      style={{ [\"--es-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <div className={hideLabel ? \"sr-only\" : \"flex items-baseline justify-between gap-3\"}>\n        <span id={labelId} className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n          {label}\n        </span>\n        <span aria-hidden=\"true\" className=\"font-mono text-xs tabular-nums text-zinc-900 dark:text-zinc-100\">\n          {text}\n        </span>\n      </div>\n\n      {/* Extra room above the track keeps the bubble clear of the label row. */}\n      <div className={`flex items-center gap-3 ${bubble === \"off\" ? \"\" : \"mt-3.5\"}`}>\n        {startIcon && (\n          <motion.span\n            aria-hidden=\"true\"\n            className=\"inline-flex shrink-0 text-zinc-500 dark:text-zinc-400 [&_svg]:size-4\"\n            style={{ x: startIconX, scale: startIconScale }}\n          >\n            {startIcon}\n          </motion.span>\n        )}\n\n        <div\n          ref={trackRef}\n          className={`relative h-8 flex-1 touch-pan-y ${disabled ? \"cursor-not-allowed\" : \"cursor-pointer\"}`}\n          onPointerDown={(event) => {\n            if (disabled || event.button !== 0) return;\n            // Keep text from being selected and move focus to the thumb ourselves.\n            event.preventDefault();\n            event.currentTarget.setPointerCapture(event.pointerId);\n            thumbRef.current?.focus({ preventScroll: true });\n            dragging.current = true;\n            setPressed(true);\n            updateFromPointer(event.clientX);\n          }}\n          onPointerMove={(event) => {\n            if (dragging.current) updateFromPointer(event.clientX);\n          }}\n          onPointerUp={endDrag}\n          onPointerCancel={endDrag}\n          onLostPointerCapture={endDrag}\n        >\n          {/* The band: stretches from the opposite end when pulled past min or max. */}\n          <motion.div\n            aria-hidden=\"true\"\n            className=\"absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-zinc-900/10 transition-[height] duration-200 group-hover/es:h-2 dark:bg-white/10\"\n            style={{ scaleX: stretchX, scaleY: stretchY, originX }}\n          >\n            <div className=\"absolute inset-y-0 left-0 rounded-full bg-[var(--es-accent)]\" style={{ width: thumbLeft }} />\n          </motion.div>\n\n          <motion.div className=\"pointer-events-none absolute top-1/2\" style={{ left: thumbLeft, x: overflow }}>\n            <motion.div\n              aria-hidden=\"true\"\n              className=\"absolute bottom-full left-0 mb-4 -translate-x-1/2 rounded-md bg-zinc-900 px-2 py-1 font-mono text-[11px] tabular-nums text-white shadow-sm dark:bg-white dark:text-zinc-900\"\n              initial={false}\n              animate={showBubble ? { opacity: 1, y: 0, scale: 1 } : { opacity: 0, y: 4, scale: 0.9 }}\n              transition={reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 500, damping: 30 }}\n            >\n              {text}\n            </motion.div>\n            <motion.div\n              ref={thumbRef}\n              role=\"slider\"\n              tabIndex={disabled ? -1 : 0}\n              aria-labelledby={labelId}\n              aria-valuemin={min}\n              aria-valuemax={max}\n              aria-valuenow={value}\n              aria-valuetext={text}\n              aria-orientation=\"horizontal\"\n              aria-disabled={disabled || undefined}\n              onKeyDown={handleKeyDown}\n              onFocus={(event) => setKeyboardFocus(event.currentTarget.matches(\":focus-visible\"))}\n              onBlur={() => setKeyboardFocus(false)}\n              className=\"pointer-events-auto absolute left-0 top-0 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-zinc-900/15 bg-white shadow-[0_1px_3px_rgb(0_0_0/0.18)] outline-none ring-[var(--es-accent)] ring-offset-2 ring-offset-white focus-visible:ring-2 dark:border-white/20 dark:bg-zinc-100 dark:ring-offset-[#131619]\"\n              initial={false}\n              animate={{ scale: pressed && !reduceMotion ? 1.25 : 1 }}\n              transition={{ type: \"spring\", stiffness: 520, damping: 26 }}\n            >\n              <span aria-hidden=\"true\" className=\"absolute inset-[5px] rounded-full bg-[var(--es-accent)]\" />\n            </motion.div>\n          </motion.div>\n        </div>\n\n        {endIcon && (\n          <motion.span\n            aria-hidden=\"true\"\n            className=\"inline-flex shrink-0 text-zinc-500 dark:text-zinc-400 [&_svg]:size-4\"\n            style={{ x: endIconX, scale: endIconScale }}\n          >\n            {endIcon}\n          </motion.span>\n        )}\n      </div>\n\n      {name !== undefined && <input type=\"hidden\" name={name} value={value} />}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/elastic-slider"
}
