{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"slider","type":"registry:component","title":"Slider","description":"One value or a range: a press throws the nearest thumb there, a drag tracks the finger, and the value rises out of the thumb.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/slider.tsx","type":"registry:component","content":"// Hairline UI · slider · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { EASE_OUT, EASE_THROW, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * SLIDER: one value or a range\n *\n *   click    a press on the track throws the nearest thumb there\n *            (slight overshoot), the fill following it\n *   drag     then it tracks the finger 1:1, snapped to `step`;\n *            two thumbs never cross\n *   bubble   the value rises out of the thumb while you drag or\n *            move it by keyboard, and sinks back after\n *   keys     arrows step, Page Up/Down step ten, Home/End jump\n *\n * Vertical page scroll still works when a drag starts on it.\n * ───────────────────────────────────────────────────────── */\n\nexport interface SliderProps {\n  /** One number, or two for a range. */\n  value?: number | number[];\n  defaultValue?: number | number[];\n  onValueChange?: (value: number[]) => void;\n  /** Called once when a drag or key press ends. */\n  onValueCommit?: (value: number[]) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  /** How the bubble and screen readers say a value. */\n  format?: (value: number) => string;\n  /** Accessible name; for a range, the thumbs get \"… minimum\" and \"… maximum\". */\n  label: string;\n  disabled?: boolean;\n  className?: string;\n}\n\nconst toArray = (v: number | number[] | undefined, fallback: number[]) => (v === undefined ? fallback : Array.isArray(v) ? v : [v]);\n\nexport function Slider({\n  value,\n  defaultValue,\n  onValueChange,\n  onValueCommit,\n  min = 0,\n  max = 100,\n  step = 1,\n  format = (v) => String(v),\n  label,\n  disabled,\n  className = \"\",\n}: SliderProps) {\n  const reduced = useInteractionReducedMotion();\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const [own, setOwn] = React.useState(() => toArray(defaultValue, [min]));\n  const values = toArray(value, own);\n  const [active, setActive] = React.useState<number | null>(null);\n  const [mode, setMode] = React.useState<\"rest\" | \"throw\" | \"drag\">(\"rest\");\n  const [bubble, setBubble] = React.useState<number | null>(null);\n  const latest = React.useRef(values);\n  React.useLayoutEffect(() => {\n    latest.current = values;\n  });\n\n  React.useEffect(() => {\n    if (bubble === null || active !== null) return;\n    const timer = window.setTimeout(() => setBubble(null), 700);\n    return () => window.clearTimeout(timer);\n  }, [bubble, active]);\n\n  const snap = (v: number) => {\n    const stepped = Math.round((v - min) / step) * step + min;\n    return Math.min(max, Math.max(min, Number(stepped.toFixed(6))));\n  };\n  const pct = (v: number) => ((v - min) / (max - min)) * 100;\n\n  const commit = (index: number, raw: number) => {\n    const next = [...latest.current];\n    const lo = index > 0 ? next[index - 1] : min;\n    const hi = index < next.length - 1 ? next[index + 1] : max;\n    next[index] = Math.min(hi, Math.max(lo, snap(raw)));\n    if (next[index] === latest.current[index]) return latest.current;\n    if (value === undefined) setOwn(next);\n    onValueChange?.(next);\n    latest.current = next;\n    return next;\n  };\n\n  const fromPointer = (clientX: number) => {\n    const rect = trackRef.current!.getBoundingClientRect();\n    return min + ((clientX - rect.left) / rect.width) * (max - min);\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (disabled || e.button !== 0) return;\n    const raw = fromPointer(e.clientX);\n    // The nearest thumb takes the press; on a tie, the one that can move that way.\n    let index = 0;\n    values.forEach((v, i) => {\n      if (Math.abs(v - raw) < Math.abs(values[index] - raw) || (Math.abs(v - raw) === Math.abs(values[index] - raw) && raw > v)) index = i;\n    });\n    e.currentTarget.setPointerCapture(e.pointerId);\n    setActive(index);\n    setBubble(index);\n    setMode(\"throw\");\n    commit(index, raw);\n    trackRef.current?.parentElement?.querySelectorAll<HTMLElement>(\"[role=slider]\")[index]?.focus({ preventScroll: true });\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (active === null) return;\n    if (mode !== \"drag\") setMode(\"drag\");\n    commit(active, fromPointer(e.clientX));\n  };\n\n  const end = () => {\n    if (active === null) return;\n    setActive(null);\n    setMode(\"rest\");\n    onValueCommit?.(latest.current);\n  };\n\n  const onKeyDown = (index: number) => (e: React.KeyboardEvent<HTMLSpanElement>) => {\n    if (disabled) return;\n    const big = step * 10;\n    const deltas: Record<string, number> = { ArrowRight: step, ArrowUp: step, ArrowLeft: -step, ArrowDown: -step, PageUp: big, PageDown: -big };\n    let target: number | null = null;\n    if (e.key in deltas) target = values[index] + deltas[e.key];\n    else if (e.key === \"Home\") target = min;\n    else if (e.key === \"End\") target = max;\n    if (target === null) return;\n    e.preventDefault();\n    setMode(\"throw\");\n    setBubble(index);\n    onValueCommit?.(commit(index, target));\n  };\n\n  const motion = reduced || mode === \"drag\" ? \"none\" : `left 420ms ${EASE_THROW}, right 420ms ${EASE_THROW}`;\n  const range = values.length > 1;\n  const lo = range ? pct(values[0]) : 0;\n  const hi = pct(values[values.length - 1]);\n\n  return (\n    <div className={`relative w-full select-none py-2 ${disabled ? \"opacity-50\" : \"\"} ${className}`}>\n      <div\n        ref={trackRef}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={end}\n        onPointerCancel={end}\n        onLostPointerCapture={end}\n        className={`relative h-5 [touch-action:pan-y] ${disabled ? \"\" : \"cursor-pointer\"}`}\n      >\n        <div className=\"absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-muted shadow-[inset_0_0_0_1px_var(--border)]\">\n          <div className=\"absolute inset-y-0 rounded-full bg-primary\" style={{ left: `${lo}%`, right: `${100 - hi}%`, transition: motion }} />\n        </div>\n      </div>\n      {values.map((v, i) => (\n        <span\n          key={i}\n          role=\"slider\"\n          tabIndex={disabled ? -1 : 0}\n          aria-label={range ? `${label} ${i === 0 ? \"minimum\" : \"maximum\"}` : label}\n          aria-valuemin={min}\n          aria-valuemax={max}\n          aria-valuenow={v}\n          aria-valuetext={format(v)}\n          aria-disabled={disabled || undefined}\n          onKeyDown={onKeyDown(i)}\n          onFocus={() => setBubble(i)}\n          onBlur={() => setBubble((b) => (b === i ? null : b))}\n          className=\"group/thumb pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full outline-none\"\n          style={{ left: `${pct(v)}%`, transition: motion }}\n        >\n          <span\n            className={`absolute inset-0 rounded-full bg-background shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_18%,var(--border))] transition-[scale,box-shadow] duration-200 group-focus-visible/thumb:shadow-[0_0_0_1px_var(--ring),0_0_0_4px_color-mix(in_oklab,var(--ring)_20%,transparent)] ${\n              active === i && !reduced ? \"scale-[1.15]\" : \"\"\n            }`}\n          />\n          <span\n            aria-hidden=\"true\"\n            className=\"absolute bottom-[calc(100%_+_8px)] left-1/2 whitespace-nowrap rounded-full bg-foreground px-2 py-0.5 text-[11.5px] font-medium tabular-nums text-background\"\n            style={{\n              opacity: bubble === i ? 1 : 0,\n              transform: `translateX(-50%) ${bubble === i ? \"none\" : \"translateY(4px) scale(0.85)\"}`,\n              filter: bubble === i ? \"none\" : \"blur(3px)\",\n              transition: reduced ? \"none\" : `opacity 200ms ${EASE_OUT}, transform 320ms ${EASE_OUT}, filter 200ms ${EASE_OUT}`,\n            }}\n          >\n            {format(v)}\n          </span>\n        </span>\n      ))}\n    </div>\n  );\n}\n\nexport default Slider;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/slider. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}