{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"motion-slider","type":"registry:component","title":"Slider (Motion)","description":"Slider with motion: a rubber-band stretch past the ends, a strain from the keyboard, and the value on odometer wheels in a bubble that leans as you drag.","dependencies":["motion","radix-ui"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/motion-field.json","https://dashral.com/r/motion-odometer.json","https://dashral.com/r/motion-reduced.json","https://dashral.com/r/motion-tokens.json","https://dashral.com/r/field.json","https://dashral.com/r/slider.json"],"files":[{"path":"registry/dashral/motion/slider.tsx","type":"registry:file","target":"components/dashral/motion/slider.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { Slider as SliderPrimitive } from \"radix-ui\"\nimport { AnimatePresence, animate, motion, useMotionValue, useSpring, useTransform } from \"motion/react\"\nimport { FieldWrapper, splitFieldProps, useFieldControl, type FieldOwnProps } from \"../field\"\nimport type { SliderProps as KitSliderProps } from \"../slider\"\nimport { useLocale } from \"../lib/locale\"\nimport { cn } from \"../lib/utils\"\n\nimport { Odometer } from \"../lib/motion-odometer\"\nimport { motionMessages } from \"../lib/motion-field\"\nimport { useReducedMotion } from \"../lib/motion-reduced\"\nimport { duration, ease, spring } from \"../lib/motion-tokens\"\n\nexport type SliderProps = KitSliderProps & {\n  /** A bubble over the thumb with its value on odometer wheels: while it's dragged or focused (\"active\"), always, or never. */\n  valueLabel?: \"active\" | \"always\" | false\n  /** How the bubble writes the value, e.g. `{ style: \"currency\", currency: \"USD\" }`. */\n  formatOptions?: Intl.NumberFormatOptions\n}\n\ntype ControlColor = NonNullable<KitSliderProps[\"color\"]>\n\n// Track, filled range, and the thumb's border and halo, as the kit's Slider has them. Every class is written out in full\n// so Tailwind finds it.\nconst colors: Record<ControlColor, { track: string; range: string; thumb: string }> = {\n  primary: { track: \"bg-primary/15\", range: \"bg-primary\", thumb: \"border-primary hover:ring-primary/20 focus-visible:ring-primary/30\" },\n  neutral: { track: \"bg-foreground/10\", range: \"bg-foreground\", thumb: \"border-foreground hover:ring-foreground/15 focus-visible:ring-foreground/25\" },\n  info: { track: \"bg-info/15\", range: \"bg-info\", thumb: \"border-info hover:ring-info/20 focus-visible:ring-info/30\" },\n  success: { track: \"bg-success/15\", range: \"bg-success\", thumb: \"border-success hover:ring-success/20 focus-visible:ring-success/30\" },\n  warning: { track: \"bg-warning/20\", range: \"bg-warning\", thumb: \"border-warning hover:ring-warning/25 focus-visible:ring-warning/35\" },\n  destructive: { track: \"bg-destructive/15\", range: \"bg-destructive\", thumb: \"border-destructive hover:ring-destructive/20 focus-visible:ring-destructive/30\" },\n  highlight: { track: \"bg-highlight/15\", range: \"bg-highlight\", thumb: \"border-highlight hover:ring-highlight/20 focus-visible:ring-highlight/30\" },\n}\n\n/** Past an end, a drag gives less and less, and never more than `most` pixels (a rubber band). */\nconst rubber = (distance: number, most = 14) => Math.sign(distance) * most * (1 - 1 / (Math.abs(distance) / most / 2 + 1))\n\n/** A light buzz on phones that have one. */\nconst buzz = (pattern: number | number[]) => {\n  if (typeof navigator !== \"undefined\" && \"vibrate\" in navigator) navigator.vibrate(pattern)\n}\n\n/**\n * The kit's Slider with motion: the same props, range and colors, and more. Held, the thumb grows; dragged past an\n * end, the track stretches after your finger like a rubber band and snaps back on release; pushed past an end with\n * the keyboard, the thumb strains and springs home. A bubble over the thumb shows the value on odometer wheels and\n * leans the way you drag, more the faster you go. Phones buzz at the ends (and on each step of a coarse slider).\n * With reduced motion nothing stretches, leans or strains.\n */\nexport function Slider(props: SliderProps) {\n  const [field, control] = splitFieldProps(props)\n  return (\n    <FieldWrapper {...field} group renderMessages={motionMessages}>\n      <SliderControl {...control} />\n    </FieldWrapper>\n  )\n}\n\nfunction SliderControl({\n  className,\n  classNames,\n  color = \"primary\",\n  thumbLabels,\n  defaultValue,\n  value: valueProp,\n  onValueChange,\n  min = 0,\n  max = 100,\n  step = 1,\n  orientation = \"horizontal\",\n  valueLabel = \"active\",\n  formatOptions,\n  ...rest\n}: Omit<SliderProps, keyof FieldOwnProps>) {\n  const { \"aria-label\": ariaLabel, \"aria-labelledby\": labelledBy, \"aria-describedby\": describedBy, \"aria-invalid\": _invalid, ...props } =\n    useFieldControl(rest)\n  const reduced = useReducedMotion()\n  const { locale } = useLocale()\n  const format = React.useMemo(() => new Intl.NumberFormat(locale, formatOptions), [locale, formatOptions])\n  const [own, setOwn] = React.useState(defaultValue ?? [min])\n  const values = valueProp ?? own\n  const tone = colors[color]\n  const vertical = orientation === \"vertical\"\n  const coarse = (max - min) / step <= 24\n\n  const root = React.useRef<HTMLSpanElement>(null)\n  const [active, setActive] = React.useState<number | null>(null)\n  const [dragging, setDragging] = React.useState(false)\n  const [focused, setFocused] = React.useState<number | null>(null)\n  const touch = React.useRef(false)\n  // How far the dragged end is pulled past the track, and which end.\n  const stretch = useMotionValue(0)\n  const [pulled, setPulled] = React.useState<-1 | 1>(1)\n  const trackScale = useTransform(stretch, (px) => {\n    const node = root.current\n    const size = node ? (vertical ? node.offsetHeight : node.offsetWidth) : 0\n    return size ? 1 + Math.abs(px) / size : 1\n  })\n  // A push past an end with the keyboard.\n  const strainOffset = useMotionValue(0)\n  // The active thumb follows both: the stretch while dragged past an end, the strain after a key.\n  const shift = useTransform(() => stretch.get() + strainOffset.get())\n  // The bubble leans the way the value moves: a target that each change sets and that settles back to upright.\n  const lean = useMotionValue(0)\n  const tilt = useSpring(lean, { stiffness: 300, damping: 18 })\n  const leanTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined)\n  const lastChange = React.useRef({ at: 0, value: values[0] ?? min })\n  React.useEffect(() => () => clearTimeout(leanTimer.current), [])\n\n  const rtl = () => (root.current ? getComputedStyle(root.current).direction === \"rtl\" : false)\n\n  function change(next: number[]) {\n    const index = next.findIndex((v, i) => v !== values[i])\n    if (index >= 0) {\n      const v = next[index]!\n      // Lean by speed: value units per millisecond, scaled to the track and capped.\n      const now = performance.now()\n      const last = lastChange.current\n      const speed = (v - last.value) / Math.max(16, now - last.at) / (max - min)\n      lastChange.current = { at: now, value: v }\n      if (!reduced) {\n        lean.set(Math.max(-14, Math.min(14, -speed * 4000 * (rtl() ? -1 : 1))))\n        clearTimeout(leanTimer.current)\n        leanTimer.current = setTimeout(() => lean.set(0), 90)\n      }\n      if (touch.current && (v === min || v === max)) buzz(12)\n      else if (touch.current && coarse) buzz(3)\n    }\n    if (valueProp === undefined) setOwn(next)\n    onValueChange?.(next)\n  }\n\n  /** Pushed past an end with a key: the thumb strains that way and springs back. */\n  function strain(direction: -1 | 1) {\n    if (reduced) return\n    animate(strainOffset, 0, { type: \"spring\", stiffness: 700, damping: 16, velocity: direction * 160 })\n  }\n\n  function pointerMove(event: React.PointerEvent<HTMLSpanElement>) {\n    if (!dragging || reduced || active === null) return\n    const node = root.current\n    if (!node) return\n    const rect = node.getBoundingClientRect()\n    const value = values[active]\n    let over = 0\n    if (vertical) {\n      // Up is more on a vertical slider.\n      if (value === max && event.clientY < rect.top) over = event.clientY - rect.top\n      else if (value === min && event.clientY > rect.bottom) over = event.clientY - rect.bottom\n    } else {\n      const start = rtl() ? rect.right : rect.left\n      const end = rtl() ? rect.left : rect.right\n      const forward = (rtl() ? -1 : 1) * (event.clientX - end)\n      const backward = (rtl() ? -1 : 1) * (event.clientX - start)\n      if (value === max && forward > 0) over = event.clientX - end\n      else if (value === min && backward < 0) over = event.clientX - start\n    }\n    if (over) setPulled(over > 0 ? 1 : -1)\n    stretch.set(rubber(over))\n  }\n\n  function release() {\n    if (!dragging) return\n    setDragging(false)\n    setActive(null)\n    animate(stretch, 0, reduced ? { duration: 0 } : { type: \"spring\", stiffness: 500, damping: 14 })\n  }\n\n  // The track grows from the end that stays put, so it reaches after the pulled end.\n  const origin = vertical ? (pulled > 0 ? \"bottom\" : \"top\") : pulled > 0 ? \"left\" : \"right\"\n\n  return (\n    <SliderPrimitive.Root\n      ref={root}\n      data-slot=\"slider\"\n      value={values}\n      onValueChange={change}\n      min={min}\n      max={max}\n      step={step}\n      orientation={orientation}\n      className={cn(\n        \"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col\",\n        className\n      )}\n      onPointerDown={(event) => {\n        touch.current = event.pointerType === \"touch\"\n        setDragging(true)\n        // The thumb this press moves is the nearest one, as Radix picks it. Set here, not on focus: a thumb still focused\n        // from the last drag doesn't get focus again, and its bubble wouldn't show.\n        const box = event.currentTarget.getBoundingClientRect()\n        const share = vertical ? (box.bottom - event.clientY) / box.height : rtl() ? (box.right - event.clientX) / box.width : (event.clientX - box.left) / box.width\n        const at = min + Math.min(1, Math.max(0, share)) * (max - min)\n        let nearest = 0\n        values.forEach((v, i) => {\n          if (Math.abs(v - at) < Math.abs((values[nearest] ?? v) - at)) nearest = i\n        })\n        setActive(nearest)\n      }}\n      onPointerMove={pointerMove}\n      onPointerUp={release}\n      onPointerCancel={release}\n      onLostPointerCapture={release}\n      {...props}\n    >\n      <SliderPrimitive.Track data-slot=\"slider-track\" asChild>\n        <motion.span\n          className={cn(\n            \"relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5\",\n            tone.track,\n            classNames?.track\n          )}\n          style={{ [vertical ? \"scaleY\" : \"scaleX\"]: trackScale, transformOrigin: origin }}\n        >\n          <SliderPrimitive.Range\n            data-slot=\"slider-range\"\n            className={cn(\"absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full\", tone.range, classNames?.range)}\n          />\n        </motion.span>\n      </SliderPrimitive.Track>\n      {values.map((v, index) => {\n        const showLabel = valueLabel === \"always\" || (valueLabel === \"active\" && ((dragging && active === index) || focused === index))\n        return (\n          <SliderPrimitive.Thumb\n            key={index}\n            data-slot=\"slider-thumb\"\n            asChild\n            // The thumbs are what's focused, so they carry the name: thumbLabels for a range, else the label or aria-label.\n            aria-label={thumbLabels?.[index] ?? (labelledBy ? undefined : ariaLabel)}\n            aria-labelledby={thumbLabels?.[index] ? undefined : labelledBy}\n            aria-describedby={describedBy}\n            onFocus={(event) => {\n              setActive(index)\n              // From the keyboard, the bubble stays while it's focused; from a drag, only while dragging.\n              if (event.currentTarget.matches(\":focus-visible\")) setFocused(index)\n            }}\n            onBlur={() => {\n              setFocused(null)\n              setActive(null)\n            }}\n            onKeyDown={(event) => {\n              const up = vertical ? [\"ArrowUp\", \"PageUp\", \"End\"] : rtl() ? [\"ArrowLeft\", \"PageUp\", \"End\"] : [\"ArrowRight\", \"PageUp\", \"End\", \"ArrowUp\"]\n              const down = vertical ? [\"ArrowDown\", \"PageDown\", \"Home\"] : rtl() ? [\"ArrowRight\", \"PageDown\", \"Home\"] : [\"ArrowLeft\", \"PageDown\", \"Home\", \"ArrowDown\"]\n              touch.current = false\n              if (v === max && up.includes(event.key)) strain(vertical ? -1 : rtl() ? -1 : 1)\n              else if (v === min && down.includes(event.key)) strain(vertical ? 1 : rtl() ? 1 : -1)\n            }}\n          >\n            <motion.span\n              className={cn(\n                \"relative block size-4 shrink-0 rounded-full border bg-background shadow-sm transition-[color,box-shadow] duration-(--duration-fast) hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50\",\n                tone.thumb,\n                classNames?.thumb\n              )}\n              style={{ [vertical ? \"y\" : \"x\"]: active === index ? shift : 0 }}\n              animate={{ scale: dragging && active === index && !reduced ? 1.25 : 1 }}\n              transition={spring.press}\n            >\n              <AnimatePresence>\n                {showLabel && (\n                  <motion.span\n                    aria-hidden\n                    className={cn(\n                      \"pointer-events-none absolute z-10 flex origin-bottom items-center rounded-md bg-foreground px-1.5 py-0.5 text-xs font-medium whitespace-nowrap text-background tabular-nums shadow-md\",\n                      vertical ? \"start-full ms-2.5 top-1/2 -translate-y-1/2 origin-left\" : \"bottom-full left-1/2 mb-2.5 -translate-x-1/2\"\n                    )}\n                    style={{ rotate: vertical ? 0 : tilt }}\n                    initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.6, y: 4 }}\n                    animate={{ opacity: 1, scale: 1, y: 0 }}\n                    exit={{ opacity: 0, scale: reduced ? 1 : 0.8, transition: { duration: duration.fast, ease: ease.exit } }}\n                    transition={reduced ? { duration: duration.fast } : spring.appear}\n                  >\n                    <Odometer value={v} format={format} />\n                  </motion.span>\n                )}\n              </AnimatePresence>\n            </motion.span>\n          </SliderPrimitive.Thumb>\n        )\n      })}\n    </SliderPrimitive.Root>\n  )\n}\n"}],"cssVars":{"theme":{"color-info":"var(--info)","color-info-foreground":"var(--info-foreground)","color-info-text":"var(--info-text)","color-success":"var(--success)","color-success-foreground":"var(--success-foreground)","color-success-text":"var(--success-text)","color-warning":"var(--warning)","color-warning-foreground":"var(--warning-foreground)","color-warning-text":"var(--warning-text)","color-highlight":"var(--highlight)","color-highlight-foreground":"var(--highlight-foreground)","color-highlight-text":"var(--highlight-text)","ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"info":"oklch(0.5 0.134 242.749)","info-foreground":"oklch(0.985 0 0)","info-text":"oklch(0.443 0.11 240.79)","success":"oklch(0.508 0.118 165.612)","success-foreground":"oklch(0.985 0 0)","success-text":"oklch(0.432 0.095 166.913)","warning":"#f59200","warning-foreground":"oklch(0.3 0.07 60)","warning-text":"#964e00","highlight":"#b8008c","highlight-foreground":"oklch(0.985 0 0)","highlight-text":"#a3007b","duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{"info":"oklch(0.685 0.169 237.323)","info-foreground":"oklch(0.2 0 0)","info-text":"oklch(0.746 0.16 232.661)","success":"oklch(0.696 0.17 162.48)","success-foreground":"oklch(0.2 0.04 160)","success-text":"oklch(0.765 0.177 163.223)","warning":"#f59200","warning-foreground":"oklch(0.25 0.06 60)","warning-text":"#ffba42","highlight":"#ff2ecf","highlight-foreground":"oklch(0.2 0 0)","highlight-text":"#ff75df"}}}