{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "elastic-slider",
  "type": "registry:ui",
  "title": "Elastic Slider",
  "description": "A budget slider drawn as a ruler of ticks. The ticks swell in a smooth wave that follows your cursor, fill with the accent up to the value, and stretch apart like an accordion when you pull past either end, then spring back. The big number rolls with Number Flow, and a minus/plus pill steps it by a fixed amount.",
  "dependencies": [
    "gsap",
    "@gsap/react",
    "@number-flow/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/elastic-slider.tsx",
      "type": "registry:component",
      "target": "components/tweenui/elastic-slider.tsx",
      "content": "'use client';\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type ComponentPropsWithoutRef,\n  type PointerEvent as ReactPointerEvent,\n} from 'react';\nimport { useGSAP } from '@gsap/react';\nimport NumberFlow, { type Format } from '@number-flow/react';\nimport gsap from 'gsap';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst MAX_STRETCH = 28;\nconst WAVE_WIDTH = 30;\nconst LIFT = { idle: 0.6, hover: 1.1, drag: 1.7 };\n\nconst STEP_BUTTON =\n  'grid size-9 cursor-pointer place-items-center transition-colors duration-200 hover:bg-current/10 focus-visible:bg-current/10 focus-visible:outline-none disabled:cursor-not-allowed disabled:hover:bg-transparent';\n\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\n\nconst rubberBand = (overdrag: number) =>\n  Math.sign(overdrag) * MAX_STRETCH * (1 - Math.exp(-Math.abs(overdrag) / (MAX_STRETCH * 2.5)));\n\nconst decimalsOf = (step: number) => {\n  const [, fraction = ''] = String(step).split('.');\n  return fraction.length;\n};\n\nconst MinusIcon = () => (\n  <svg viewBox=\"0 0 24 24\" className=\"size-4\" fill=\"none\" aria-hidden=\"true\">\n    <path d=\"M5 12h14\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" />\n  </svg>\n);\n\nconst PlusIcon = () => (\n  <svg viewBox=\"0 0 24 24\" className=\"size-4\" fill=\"none\" aria-hidden=\"true\">\n    <path d=\"M12 5v14M5 12h14\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" />\n  </svg>\n);\n\nexport interface ElasticSliderProps extends Omit<\n  ComponentPropsWithoutRef<'div'>,\n  'defaultValue' | 'onChange'\n> {\n  label: string;\n  min?: number;\n  max?: number;\n  step?: number;\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  format?: Format;\n  prefix?: string;\n  suffix?: string;\n  buttonStep?: number;\n  ticks?: number;\n  disabled?: boolean;\n}\n\nconst ElasticSlider = ({\n  label,\n  min = 0,\n  max = 100,\n  step = 1,\n  value: controlledValue,\n  defaultValue = 50,\n  onValueChange,\n  format,\n  prefix,\n  suffix,\n  buttonStep,\n  ticks = 41,\n  disabled = false,\n  className,\n  ...props\n}: ElasticSliderProps) => {\n  const inputId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const railRef = useRef<HTMLDivElement>(null);\n  const tickRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const indicatorRef = useRef<HTMLSpanElement>(null);\n  const decreaseRef = useRef<HTMLSpanElement>(null);\n  const increaseRef = useRef<HTMLSpanElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const motion = useRef({ progress: 0, stretch: 0, focus: 0, lift: LIFT.idle });\n  const moveTo = useRef<((progress: number) => void) | null>(null);\n  const focusTo = useRef<((x: number) => void) | null>(null);\n  const liftTo = useRef<((lift: number) => void) | null>(null);\n  const hoverX = useRef<number | null>(null);\n\n  const [innerValue, setInnerValue] = useState(defaultValue);\n  const [dragging, setDragging] = useState(false);\n  const [reduced, setReduced] = useState(false);\n\n  const value = clamp(controlledValue ?? innerValue, min, max);\n  const span = max - min || 1;\n  const progress = (value - min) / span;\n  const numberFormat = { useGrouping: true, ...format };\n  const formatter = new Intl.NumberFormat(undefined, numberFormat);\n  const formatText = (number: number) =>\n    `${prefix ?? ''}${formatter.format(number)}${suffix ?? ''}`;\n\n  const render = useCallback(() => {\n    const rail = railRef.current;\n    if (!rail) return;\n    const width = rail.offsetWidth;\n    const { progress: current, stretch, focus, lift } = motion.current;\n    const offset = (t: number) => (stretch > 0 ? stretch * t : stretch * (1 - t));\n    const count = tickRefs.current.length;\n\n    tickRefs.current.forEach((tick, index) => {\n      if (!tick) return;\n      const t = count > 1 ? index / (count - 1) : 0;\n      const base = t * width;\n      const wave = Math.exp(-(((base - focus) / WAVE_WIDTH) ** 2));\n      gsap.set(tick, { x: base + offset(t), scaleY: 1 + lift * wave });\n      tick.toggleAttribute('data-on', t <= current + 0.0001);\n    });\n\n    gsap.set(indicatorRef.current, {\n      x: current * width + offset(current),\n      scaleY: 1 + (lift - LIFT.idle) * 0.12,\n    });\n  }, []);\n\n  useGSAP(\n    () => {\n      const isReduced = prefersReducedMotion();\n      setReduced(isReduced);\n      const width = railRef.current?.offsetWidth ?? 0;\n      motion.current.progress = progress;\n      motion.current.focus = progress * width;\n      motion.current.lift = isReduced ? 0 : LIFT.idle;\n      moveTo.current = gsap.quickTo(motion.current, 'progress', {\n        duration: isReduced ? 0 : 0.4,\n        ease: 'power3.out',\n        onUpdate: render,\n      });\n      focusTo.current = gsap.quickTo(motion.current, 'focus', {\n        duration: isReduced ? 0 : 0.5,\n        ease: 'power3.out',\n        onUpdate: render,\n      });\n      liftTo.current = gsap.quickTo(motion.current, 'lift', {\n        duration: isReduced ? 0 : 0.45,\n        ease: 'power2.out',\n        onUpdate: render,\n      });\n      render();\n\n      const rail = railRef.current;\n      if (!rail || typeof ResizeObserver === 'undefined') return;\n      const observer = new ResizeObserver(render);\n      observer.observe(rail);\n      return () => observer.disconnect();\n    },\n    { scope: rootRef, dependencies: [ticks] }\n  );\n\n  useEffect(() => {\n    moveTo.current?.(progress);\n    if (hoverX.current === null) focusTo.current?.(progress * (railRef.current?.offsetWidth ?? 0));\n  }, [progress]);\n\n  const setLift = (state: keyof typeof LIFT) => {\n    if (!reduced) liftTo.current?.(LIFT[state]);\n  };\n\n  const commit = (next: number) => {\n    const snapped = clamp(\n      Number((Math.round((next - min) / step) * step + min).toFixed(decimalsOf(step))),\n      min,\n      max\n    );\n    if (snapped === value) return;\n    if (controlledValue === undefined) setInnerValue(snapped);\n    onValueChange?.(snapped);\n  };\n\n  const stretchTo = (amount: number, release = false) => {\n    if (reduced) return;\n    gsap.to(motion.current, {\n      stretch: amount,\n      duration: release ? 0.9 : 0.2,\n      ease: release ? 'elastic.out(1, 0.32)' : 'power3.out',\n      overwrite: 'auto',\n      onUpdate: render,\n    });\n  };\n\n  const bump = (direction: number) => {\n    if (reduced) return;\n    gsap.to(motion.current, {\n      stretch: direction * 14,\n      duration: 0.14,\n      ease: 'power2.out',\n      overwrite: 'auto',\n      onUpdate: render,\n      onComplete: () => stretchTo(0, true),\n    });\n  };\n\n  const nudge = (direction: number) => {\n    if (disabled || !buttonStep) return;\n    if (!reduced) {\n      gsap.fromTo(\n        direction < 0 ? decreaseRef.current : increaseRef.current,\n        { scale: 0.6 },\n        { scale: 1, duration: 0.5, ease: 'back.out(3)', overwrite: true }\n      );\n    }\n    const atEdge = direction < 0 ? value <= min : value >= max;\n    if (atEdge) bump(direction);\n    else commit(value + direction * buttonStep);\n  };\n\n  const track = (clientX: number) => {\n    const rail = railRef.current;\n    if (!rail) return;\n    const rect = rail.getBoundingClientRect();\n    const overdrag =\n      clientX < rect.left ? clientX - rect.left : clientX > rect.right ? clientX - rect.right : 0;\n    commit(min + clamp((clientX - rect.left) / (rect.width || 1), 0, 1) * span);\n    stretchTo(rubberBand(overdrag));\n    hoverX.current = clamp(clientX - rect.left, 0, rect.width);\n    focusTo.current?.(hoverX.current);\n  };\n\n  const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (disabled || (event.pointerType === 'mouse' && event.button !== 0)) return;\n    event.preventDefault();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    inputRef.current?.focus({ preventScroll: true });\n    setDragging(true);\n    setLift('drag');\n    track(event.clientX);\n  };\n\n  const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (dragging) {\n      track(event.clientX);\n      return;\n    }\n    if (disabled || event.pointerType !== 'mouse') return;\n    const rect = railRef.current?.getBoundingClientRect();\n    if (!rect) return;\n    hoverX.current = clamp(event.clientX - rect.left, 0, rect.width);\n    focusTo.current?.(hoverX.current);\n    setLift('hover');\n  };\n\n  const settle = () => {\n    hoverX.current = null;\n    focusTo.current?.(progress * (railRef.current?.offsetWidth ?? 0));\n    setLift('idle');\n  };\n\n  const handlePointerEnd = () => {\n    if (!dragging) return;\n    setDragging(false);\n    stretchTo(0, true);\n    settle();\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      data-elastic-slider\n      data-dragging={dragging || undefined}\n      className={cn(\n        'w-full max-w-md touch-none select-none [--elastic-accent:#045f64] motion-reduce:transition-none dark:[--elastic-accent:#c6f56f]',\n        disabled && 'opacity-50',\n        className\n      )}\n      {...props}\n    >\n      <div className=\"flex items-end justify-between gap-4\">\n        <div className=\"min-w-0\">\n          <label htmlFor={inputId} className=\"block text-xs tracking-wide uppercase opacity-60\">\n            {label}\n          </label>\n          <NumberFlow\n            aria-hidden=\"true\"\n            value={value}\n            format={numberFormat}\n            prefix={prefix}\n            suffix={suffix}\n            animated={!reduced}\n            willChange\n            className=\"mt-1 block text-4xl font-medium tracking-tight tabular-nums\"\n          />\n        </div>\n\n        {buttonStep && (\n          <div className=\"flex shrink-0 overflow-hidden rounded-full border border-current/15\">\n            <button\n              type=\"button\"\n              disabled={disabled}\n              aria-label={`Decrease ${label} by ${formatText(buttonStep)}`}\n              onClick={() => nudge(-1)}\n              className={STEP_BUTTON}\n            >\n              <span ref={decreaseRef} className=\"flex\">\n                <MinusIcon />\n              </span>\n            </button>\n            <span aria-hidden=\"true\" className=\"w-px bg-current/15\" />\n            <button\n              type=\"button\"\n              disabled={disabled}\n              aria-label={`Increase ${label} by ${formatText(buttonStep)}`}\n              onClick={() => nudge(1)}\n              className={STEP_BUTTON}\n            >\n              <span ref={increaseRef} className=\"flex\">\n                <PlusIcon />\n              </span>\n            </button>\n          </div>\n        )}\n      </div>\n\n      <div\n        className={cn(\n          'relative mt-5 h-14',\n          disabled ? 'cursor-not-allowed' : dragging ? 'cursor-grabbing' : 'cursor-grab'\n        )}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerLeave={() => !dragging && settle()}\n        onPointerUp={handlePointerEnd}\n        onPointerCancel={handlePointerEnd}\n        onLostPointerCapture={handlePointerEnd}\n      >\n        <input\n          ref={inputRef}\n          id={inputId}\n          type=\"range\"\n          min={min}\n          max={max}\n          step={step}\n          value={value}\n          disabled={disabled}\n          aria-valuetext={formatText(value)}\n          onChange={(event) => commit(Number(event.target.value))}\n          className=\"peer sr-only\"\n        />\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute -inset-x-3 -inset-y-1 rounded-xl outline-2 outline-transparent peer-focus-visible:outline-current\"\n        />\n        <div ref={railRef} aria-hidden=\"true\" className=\"absolute inset-0\">\n          {Array.from({ length: ticks }, (_, index) => (\n            <span\n              key={index}\n              ref={(node) => {\n                tickRefs.current[index] = node;\n              }}\n              className={cn(\n                'absolute top-1/2 left-0 -ml-px w-0.5 rounded-full bg-current/20 transition-colors duration-200 will-change-transform data-on:bg-(--elastic-accent)',\n                index % 5 === 0 ? '-mt-2.5 h-5' : '-mt-1.5 h-3'\n              )}\n            />\n          ))}\n          <span\n            ref={indicatorRef}\n            className=\"absolute top-1/2 left-0 -mt-6 -ml-[3px] h-12 w-1.5 rounded-full bg-(--elastic-accent) shadow-[0_0_0_3px_var(--color-white)] will-change-transform dark:shadow-[0_0_0_3px_#12161f]\"\n          />\n        </div>\n      </div>\n\n      <div aria-hidden=\"true\" className=\"mt-1 flex justify-between text-xs tabular-nums opacity-50\">\n        <span>{formatText(min)}</span>\n        <span>{formatText(max)}</span>\n      </div>\n    </div>\n  );\n};\n\nexport default ElasticSlider;\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/component/elastic-slider"
}