{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"range-slider","type":"registry:component","title":"Range slider","description":"Two thumbs that glide, stop at each other, and show their value as you drag.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/range-slider.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useTransform,\n  type AnimationPlaybackControls,\n  type MotionValue,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// Track clicks and keys glide the thumb over; a spring so a drag that\n// starts on the track can keep retargeting it without restarting.\nconst GLIDE = { type: \"spring\", visualDuration: 0.2, bounce: 0 } as const;\n\ntype Thumb = 0 | 1;\ntype Drag = { thumb: Thumb; gliding: boolean; offset: number };\n\nconst clamp = (v: number, lo: number, hi: number) =>\n  Math.min(Math.max(v, lo), hi);\n\nconst usd = (v: number) => `$${v.toLocaleString(\"en-US\")}`;\n\nexport function RangeSlider({\n  label,\n  value,\n  onChange,\n  min = 0,\n  max = 100,\n  step = 1,\n  format = String,\n  className,\n}: {\n  label: string;\n  value: [number, number];\n  onChange: (value: [number, number]) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  format?: (value: number) => string;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const id = useId();\n  const [low, high] = value;\n  const span = max - min;\n  const toFraction = (v: number) => (v - min) / span;\n\n  const lowPos = useMotionValue(toFraction(low));\n  const highPos = useMotionValue(toFraction(high));\n  const positions = [lowPos, highPos] as const;\n  // The fill is one full-width bar, shifted and scaled, so it never lays out.\n  const fill = useTransform(\n    [lowPos, highPos],\n    ([a, b]: number[]) => `translateX(${a * 100}%) scaleX(${b - a})`,\n  );\n\n  const trackRef = useRef<HTMLDivElement>(null);\n  const thumbRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const drag = useRef<Drag | null>(null);\n  const glides = useRef<(AnimationPlaybackControls | undefined)[]>([]);\n  const [active, setActive] = useState<Thumb | null>(null);\n\n  useEffect(() => {\n    const sync = (thumb: Thumb, v: number) => {\n      const mv = thumb === 0 ? lowPos : highPos;\n      const target = (v - min) / span;\n      if (mv.get() === target) return;\n      const d = drag.current;\n      glides.current[thumb]?.stop();\n      if (reduceMotion || (d?.thumb === thumb && !d.gliding)) mv.jump(target);\n      else glides.current[thumb] = animate(mv, target, GLIDE);\n    };\n    sync(0, low);\n    sync(1, high);\n  }, [low, high, min, span, reduceMotion, lowPos, highPos]);\n\n  useEffect(() => {\n    const running = glides.current;\n    return () => running.forEach((g) => g?.stop());\n  }, []);\n\n  // Thumbs stop at each other rather than push. In a filter, pushing would\n  // quietly change the bound the user isn't touching.\n  const commit = (thumb: Thumb, v: number) => {\n    const next = Math.round((v - min) / step) * step + min;\n    const bounded =\n      thumb === 0 ? clamp(next, min, high - step) : clamp(next, low + step, max);\n    if (bounded === value[thumb]) return;\n    onChange(thumb === 0 ? [bounded, high] : [low, bounded]);\n  };\n\n  const fractionAt = (clientX: number) => {\n    const box = trackRef.current!.getBoundingClientRect();\n    return clamp((clientX - box.left) / box.width, 0, 1);\n  };\n\n  const onKeyDown = (thumb: Thumb) => (e: React.KeyboardEvent) => {\n    const big = step * 10;\n    const current = value[thumb];\n    const delta: Record<string, number> = {\n      ArrowRight: e.shiftKey ? big : step,\n      ArrowUp: e.shiftKey ? big : step,\n      ArrowLeft: e.shiftKey ? -big : -step,\n      ArrowDown: e.shiftKey ? -big : -step,\n      PageUp: big,\n      PageDown: -big,\n      Home: -Infinity,\n      End: Infinity,\n    };\n    if (!(e.key in delta)) return;\n    e.preventDefault();\n    commit(thumb, clamp(current + delta[e.key], min, max));\n  };\n\n  const end = () => {\n    drag.current = null;\n    setActive(null);\n  };\n\n  const summary = `${format(low)} to ${format(high)}`;\n\n  return (\n    <div className={cn(\"flex w-[min(440px,100%)] flex-col gap-3\", className)}>\n      <div className=\"flex items-baseline justify-between\">\n        <span id={`${id}-label`} className=\"text-sm font-medium text-foreground\">\n          {label}\n        </span>\n        <span className=\"text-sm text-muted-foreground tabular-nums\">{summary}</span>\n      </div>\n\n      {/* Tall enough for a 44px touch target, with room above for the tooltip. */}\n      <div\n        ref={trackRef}\n        role=\"group\"\n        aria-labelledby={`${id}-label`}\n        className=\"relative mt-8 h-11 cursor-pointer touch-none select-none\"\n        onPointerDown={(e) => {\n          if (e.button !== 0) return;\n          const f = fractionAt(e.clientX);\n          const onThumb = (e.target as HTMLElement).closest<HTMLElement>(\"[data-thumb]\");\n          let thumb: Thumb;\n          if (onThumb) thumb = Number(onThumb.dataset.thumb) as Thumb;\n          else {\n            const toLow = Math.abs(f - lowPos.get());\n            const toHigh = Math.abs(f - highPos.get());\n            // Stacked thumbs tie, so the side of the click decides.\n            thumb = toLow === toHigh ? (f < lowPos.get() ? 0 : 1) : toLow < toHigh ? 0 : 1;\n          }\n          e.preventDefault();\n          thumbRefs.current[thumb]?.focus({ preventScroll: true });\n          e.currentTarget.setPointerCapture(e.pointerId);\n          // Grabbing a thumb off-center keeps that offset, so it never jumps.\n          drag.current = {\n            thumb,\n            gliding: !onThumb,\n            offset: onThumb ? f - positions[thumb].get() : 0,\n          };\n          setActive(thumb);\n          if (!onThumb) commit(thumb, min + f * span);\n        }}\n        onPointerMove={(e) => {\n          const d = drag.current;\n          if (!d) return;\n          commit(d.thumb, min + (fractionAt(e.clientX) - d.offset) * span);\n        }}\n        onPointerUp={end}\n        onPointerCancel={end}\n        onLostPointerCapture={end}\n      >\n        <div className=\"absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-border\">\n          <motion.div\n            className=\"h-full origin-left bg-foreground\"\n            style={{ transform: fill }}\n          />\n        </div>\n        {([0, 1] as const).map((thumb) => (\n          <ThumbHandle\n            key={thumb}\n            ref={(el) => {\n              thumbRefs.current[thumb] = el;\n            }}\n            thumb={thumb}\n            position={positions[thumb]}\n            value={value[thumb]}\n            min={min}\n            max={max}\n            label={thumb === 0 ? `Minimum ${label.toLowerCase()}` : `Maximum ${label.toLowerCase()}`}\n            text={format(value[thumb])}\n            dragging={active === thumb}\n            // The active thumb sits on top; at rest, the one nearer an end\n            // yields, so stacked thumbs can always be pulled apart.\n            raised={active === null ? (thumb === 0) === low > (min + max) / 2 : active === thumb}\n            onKeyDown={onKeyDown(thumb)}\n          />\n        ))}\n      </div>\n\n      <div className=\"flex items-center gap-3\">\n        <ValueField\n          label=\"Min\"\n          name={`Minimum ${label.toLowerCase()}`}\n          value={low}\n          onCommit={(v) => commit(0, v)}\n        />\n        <span aria-hidden className=\"h-px w-3 shrink-0 bg-border\" />\n        <ValueField\n          label=\"Max\"\n          name={`Maximum ${label.toLowerCase()}`}\n          value={high}\n          onCommit={(v) => commit(1, v)}\n        />\n      </div>\n    </div>\n  );\n}\n\nfunction ThumbHandle({\n  ref,\n  thumb,\n  position,\n  value,\n  min,\n  max,\n  label,\n  text,\n  dragging,\n  raised,\n  onKeyDown,\n}: {\n  ref: React.Ref<HTMLDivElement>;\n  thumb: Thumb;\n  position: MotionValue<number>;\n  value: number;\n  min: number;\n  max: number;\n  label: string;\n  text: string;\n  dragging: boolean;\n  raised: boolean;\n  onKeyDown: (e: React.KeyboardEvent) => void;\n}) {\n  // A full-width layer moved by a percentage of the track, so the thumb\n  // rides on transform alone.\n  const transform = useTransform(position, (p) => `translateX(${p * 100}%)`);\n  return (\n    <motion.div\n      className={cn(\"pointer-events-none absolute inset-0\", raised && \"z-10\")}\n      style={{ transform }}\n    >\n      <div\n        ref={ref}\n        data-thumb={thumb}\n        role=\"slider\"\n        tabIndex={0}\n        aria-label={label}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-valuenow={value}\n        aria-valuetext={text}\n        onKeyDown={onKeyDown}\n        data-dragging={dragging || undefined}\n        className={cn(\n          \"group pointer-events-auto absolute top-1/2 left-0 size-5 -translate-x-1/2 -translate-y-1/2 touch-none rounded-full bg-background shadow-raised outline-hidden\",\n          \"transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground data-dragging:scale-[0.96] motion-reduce:transition-none\",\n          // A 44px hit area around the 20px knob.\n          \"after:absolute after:-inset-3 after:rounded-full\",\n        )}\n      >\n        <span className=\"absolute inset-[6px] rounded-full bg-foreground\" aria-hidden />\n        {/* Shown while dragging or keyboard-focused. In over 150ms, out in\n            100ms, so it never lingers behind the thumb. */}\n        <span\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute bottom-full left-1/2 mb-2.5 -translate-x-1/2 origin-bottom rounded-full bg-foreground px-2 py-1 text-xs font-medium whitespace-nowrap text-background tabular-nums\",\n            \"transition-[opacity,translate,scale] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-[opacity]\",\n            dragging\n              ? \"translate-y-0 scale-100 opacity-100 duration-150\"\n              : \"translate-y-1 scale-[0.97] opacity-0 duration-100 group-focus-visible:translate-y-0 group-focus-visible:scale-100 group-focus-visible:opacity-100 group-focus-visible:duration-150 motion-reduce:translate-y-0 motion-reduce:scale-100\",\n          )}\n        >\n          {text}\n        </span>\n      </div>\n    </motion.div>\n  );\n}\n\nfunction ValueField({\n  label,\n  name,\n  value,\n  onCommit,\n}: {\n  label: string;\n  name: string;\n  value: number;\n  onCommit: (value: number) => void;\n}) {\n  const id = useId();\n  // Null while not editing, so the field follows the slider until typed in.\n  const [draft, setDraft] = useState<string | null>(null);\n\n  const commit = () => {\n    if (draft === null) return;\n    const parsed = Number(draft.replace(/[^\\d.]/g, \"\"));\n    if (draft.trim() !== \"\" && Number.isFinite(parsed)) onCommit(parsed);\n    setDraft(null);\n  };\n\n  return (\n    <label\n      htmlFor={id}\n      className=\"flex h-10 flex-1 cursor-text items-center gap-1.5 rounded-lg bg-muted px-3 text-sm shadow-raised outline-offset-2 outline-foreground has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-solid\"\n    >\n      <span className=\"text-muted-foreground\">{label}</span>\n      <span className=\"ml-auto text-muted-foreground\" aria-hidden>\n        $\n      </span>\n      <input\n        id={id}\n        aria-label={name}\n        inputMode=\"numeric\"\n        autoComplete=\"off\"\n        value={draft ?? String(value)}\n        onChange={(e) => setDraft(e.target.value)}\n        onBlur={commit}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") commit();\n          if (e.key === \"Escape\") setDraft(null);\n        }}\n        className=\"w-16 min-w-0 bg-transparent text-right text-foreground tabular-nums outline-hidden\"\n      />\n    </label>\n  );\n}\n\nexport default function RangeSliderDemo() {\n  const [range, setRange] = useState<[number, number]>([200, 750]);\n  return (\n    <RangeSlider\n      label=\"Price\"\n      value={range}\n      onChange={setRange}\n      min={0}\n      max={1000}\n      step={10}\n      format={usd}\n    />\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["inputs"],"docs":"From ui lab: https://lab.xevrion.dev/lab/range-slider"}