{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "jelly-slider",
  "title": "Jelly slider",
  "description": "A slider whose soft thumb stretches with the speed you drag it and wobbles back into shape.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/manniche/jelly-slider/jelly-slider.tsx",
      "content": "import { motion, useMotionValue, useSpring, useTransform, useVelocity } from 'motion/react'\r\nimport { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react'\r\nimport { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'\r\nimport { cn } from '@/lib/utils'\r\n\r\nexport type JellySliderProps = {\r\n  value?: number\r\n  defaultValue?: number\r\n  onValueChange?: (value: number) => void\r\n  min?: number\r\n  max?: number\r\n  step?: number\r\n  /** Read by screen readers. */\r\n  label: string\r\n  /** Turns the value into text for screen readers, e.g. “40 %”. */\r\n  format?: (value: number) => string\r\n  className?: string\r\n}\r\n\r\n/**\r\n * A slider with a soft thumb: it stretches in the direction you drag it, squashes to keep its volume,\r\n * and wobbles back into shape when you let go.\r\n */\r\nexport function JellySlider({ value, defaultValue = 50, onValueChange, min = 0, max = 100, step = 1, label, format, className }: JellySliderProps) {\r\n  const [inner, setInner] = useState(defaultValue)\r\n  const current = value ?? inner\r\n  const track = useRef<HTMLDivElement>(null)\r\n  const reduce = useReducedMotion()\r\n\r\n  const pct = (v: number) => ((v - min) / (max - min)) * 100\r\n  const target = useMotionValue(pct(current))\r\n  const pos = useSpring(target, { stiffness: 600, damping: 45 })\r\n  const speed = useVelocity(pos) // percent per second\r\n  // Faster means longer and flatter. Volume stays the same, so the squash is 1 / stretch.\r\n  const stretch = useSpring(\r\n    useTransform(speed, (v) => (reduce ? 1 : 1 + Math.min(0.7, Math.abs(v) / 350))),\r\n    { stiffness: 420, damping: 11 },\r\n  )\r\n  const squash = useTransform(stretch, (s) => 1 / s)\r\n  const fill = useTransform(pos, (p) => p / 100)\r\n  const width = useMotionValue(0)\r\n  const x = useTransform(() => (pos.get() / 100) * width.get())\r\n\r\n  // The thumb moves in px, measured from the track, so nothing sticks out past it while it travels.\r\n  useEffect(() => {\r\n    const el = track.current!\r\n    const ro = new ResizeObserver(() => width.set(el.clientWidth))\r\n    ro.observe(el)\r\n    return () => ro.disconnect()\r\n  }, [width])\r\n\r\n  useEffect(() => {\r\n    target.set(pct(current))\r\n    if (reduce) pos.jump(pct(current))\r\n  })\r\n\r\n  const set = (raw: number) => {\r\n    const snapped = Math.round((raw - min) / step) * step + min\r\n    const next = Math.min(max, Math.max(min, Number(snapped.toFixed(10))))\r\n    if (next === current) return\r\n    if (value === undefined) setInner(next)\r\n    onValueChange?.(next)\r\n  }\r\n\r\n  const fromPointer = (e: PointerEvent) => {\r\n    const r = track.current!.getBoundingClientRect()\r\n    set(min + ((e.clientX - r.left) / r.width) * (max - min))\r\n  }\r\n\r\n  const onKey = (e: KeyboardEvent) => {\r\n    const big = Math.max(step, (max - min) / 10)\r\n    const moves: Record<string, number> = {\r\n      ArrowRight: step,\r\n      ArrowUp: step,\r\n      ArrowLeft: -step,\r\n      ArrowDown: -step,\r\n      PageUp: big,\r\n      PageDown: -big,\r\n      Home: min - current,\r\n      End: max - current,\r\n    }\r\n    if (!(e.key in moves)) return\r\n    e.preventDefault()\r\n    set(current + moves[e.key])\r\n  }\r\n\r\n  return (\r\n    <div\r\n      role=\"slider\"\r\n      tabIndex={0}\r\n      aria-label={label}\r\n      aria-valuemin={min}\r\n      aria-valuemax={max}\r\n      aria-valuenow={current}\r\n      aria-valuetext={format?.(current)}\r\n      aria-orientation=\"horizontal\"\r\n      onKeyDown={onKey}\r\n      onPointerDown={(e) => {\r\n        e.currentTarget.setPointerCapture(e.pointerId)\r\n        fromPointer(e)\r\n      }}\r\n      onPointerMove={(e) => e.currentTarget.hasPointerCapture(e.pointerId) && fromPointer(e)}\r\n      className={cn('group relative flex h-11 w-full cursor-grab touch-none items-center rounded-full px-3.5 outline-none select-none active:cursor-grabbing', className)}\r\n    >\r\n      <div ref={track} className=\"relative h-2.5 w-full rounded-full bg-muted\">\r\n        <motion.span className=\"absolute inset-0 origin-left rounded-full bg-primary\" style={{ scaleX: fill }} />\r\n        <motion.span\r\n          aria-hidden\r\n          className=\"absolute top-1/2 left-0 -mt-3.5 -ml-3.5 size-7 rounded-full border bg-background shadow-[0_2px_8px_-2px_rgb(0_0_0/0.35)] ring-ring/50 transition-shadow duration-150 group-focus-visible:ring-4\"\r\n          style={{ x, scaleX: stretch, scaleY: squash }}\r\n        />\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n",
      "type": "registry:component"
    },
    {
      "path": "registry/manniche/hooks/use-reduced-motion.ts",
      "content": "import { useSyncExternalStore } from 'react'\n\nconst QUERY = '(prefers-reduced-motion: reduce)'\n\nfunction subscribe(onChange: () => void) {\n  const mq = window.matchMedia(QUERY)\n  mq.addEventListener('change', onChange)\n  return () => mq.removeEventListener('change', onChange)\n}\n\n/** True when the visitor has asked the system for less motion. */\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(QUERY).matches,\n    () => false,\n  )\n}\n",
      "type": "registry:hook"
    }
  ],
  "categories": [
    "controls"
  ],
  "type": "registry:component"
}