{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slider-detents",
  "type": "registry:ui",
  "title": "Slider detents",
  "description": "Snaps to meaningful detents while preserving continuous input.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "forms-input"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/slider-detents/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "light",
    "signature": "Continuous drag settles at meaningful detents",
    "sceneFamily": "product-mono",
    "motionRole": "ui",
    "primaryState": "Slider settled on a semantic detent",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/slider-detents.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/slider-detents.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport {\n  motion,\n  useMotionTemplate,\n  useReducedMotion,\n  useSpring,\n} from \"motion/react\";\n\nconst CARRIAGE = { stiffness: 520, damping: 34, mass: 0.45 } as const;\n\nconst GRAB = { type: \"spring\", stiffness: 700, damping: 46, mass: 0.5 } as const;\nconst CROSSFADE = {\n  type: \"spring\",\n  stiffness: 260,\n  damping: 34,\n  mass: 0.8,\n} as const;\nconst INSTANT = { duration: 0 } as const;\nconst THUMB = 18;\n\nconst plain = (value: number) => String(value);\n\nfunction tidy(value: number) {\n  return Math.round(value * 1e6) / 1e6;\n}\n\nexport type SliderDetent = { value: number; label?: string };\n\nconst NONE: readonly (number | SliderDetent)[] = [];\n\nexport type UseSliderDetentsOptions = {\n  value: number;\n  onValueChange: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  detents?: readonly (number | SliderDetent)[];\n  pull?: number;\n  thumbSize?: number;\n  disabled?: boolean;\n  haptic?: boolean;\n  format?: (value: number) => string;\n  label?: string;\n  labelledBy?: string;\n};\n\nexport function useSliderDetents({\n  value,\n  onValueChange,\n  min = 0,\n  max = 100,\n  step = 1,\n  detents = NONE,\n  pull,\n  thumbSize = THUMB,\n  disabled = false,\n  haptic = true,\n  format = plain,\n  label,\n  labelledBy,\n}: UseSliderDetentsOptions) {\n  const trackRef = useRef<HTMLDivElement>(null);\n  const [dragging, setDragging] = useState(false);\n\n  const list = useMemo<SliderDetent[]>(\n    () => detents.map((d) => (typeof d === \"number\" ? { value: d } : d)),\n    [detents],\n  );\n\n  const range = max - min;\n  const grab = pull ?? range * 0.045;\n\n  const emit = useRef(onValueChange);\n  emit.current = onValueChange;\n  const emitted = useRef(value);\n  emitted.current = value;\n\n  const activeDetent = useMemo(\n    () => list.findIndex((d) => tidy(d.value) === tidy(value)),\n    [list, value],\n  );\n\n  const marked = useRef(activeDetent);\n  const held = useRef(false);\n\n  const commit = useCallback(\n    (next: number) => {\n      const settled = Math.min(max, Math.max(min, tidy(next)));\n      const index = list.findIndex((d) => tidy(d.value) === settled);\n      if (index !== marked.current) {\n        marked.current = index;\n        if (haptic && index >= 0) navigator.vibrate?.(6);\n      }\n      if (settled !== emitted.current) {\n        emitted.current = settled;\n        emit.current(settled);\n      }\n    },\n    [haptic, list, max, min],\n  );\n\n  const capture = useCallback(\n    (clientX: number) => {\n      const el = trackRef.current;\n      if (!el || range <= 0) return null;\n      const rect = el.getBoundingClientRect();\n      const travel = rect.width - thumbSize;\n      if (travel <= 0) return null;\n\n      const ratio = (clientX - rect.left - thumbSize / 2) / travel;\n      const raw = Math.min(max, Math.max(min, min + ratio * range));\n\n      let index = -1;\n      let nearest = grab;\n      for (let i = 0; i < list.length; i += 1) {\n        const distance = Math.abs(raw - list[i].value);\n        if (distance <= nearest) {\n          nearest = distance;\n          index = i;\n        }\n      }\n      if (index >= 0) return list[index].value;\n      return min + Math.round((raw - min) / step) * step;\n    },\n    [grab, list, max, min, range, step, thumbSize],\n  );\n\n  const release = useCallback(() => {\n    if (!held.current) return;\n    held.current = false;\n    setDragging(false);\n  }, []);\n\n  const toDetent = useCallback(\n    (direction: number) => {\n      const sorted = list.map((d) => d.value).toSorted((a, b) => a - b);\n      const forward = sorted.find((d) => d > value + 1e-6);\n      const backward = sorted.findLast((d) => d < value - 1e-6);\n      const target = direction > 0 ? forward : backward;\n      commit(target ?? (direction > 0 ? max : min));\n    },\n    [commit, list, max, min, value],\n  );\n\n  useEffect(() => {\n    window.addEventListener(\"blur\", release);\n    return () => window.removeEventListener(\"blur\", release);\n  }, [release]);\n\n  const detentLabel = list[activeDetent]?.label;\n  const valueText = detentLabel\n    ? `${format(value)}, ${detentLabel}`\n    : format(value);\n\n  const percent = range > 0 ? Math.min(1, Math.max(0, (value - min) / range)) : 0;\n\n  const trackProps = {\n    role: \"slider\" as const,\n    tabIndex: 0,\n    \"aria-orientation\": \"horizontal\" as const,\n    \"aria-valuemin\": min,\n    \"aria-valuemax\": max,\n    \"aria-valuenow\": value,\n    \"aria-valuetext\": valueText,\n    \"aria-disabled\": disabled || undefined,\n    \"aria-label\": labelledBy ? undefined : label,\n    \"aria-labelledby\": labelledBy,\n    style: { touchAction: \"none\" as const },\n    onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => {\n      if (disabled) return;\n      if (e.pointerType === \"mouse\" && e.button !== 0) return;\n      e.currentTarget.setPointerCapture?.(e.pointerId);\n      e.currentTarget.focus({ preventScroll: true });\n      held.current = true;\n      setDragging(true);\n      const next = capture(e.clientX);\n      if (next !== null) commit(next);\n    },\n    onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {\n      if (!held.current) return;\n      const next = capture(e.clientX);\n      if (next !== null) commit(next);\n    },\n    onPointerUp: release,\n    onPointerCancel: release,\n    onLostPointerCapture: release,\n    onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => {\n      if (disabled) return;\n      const forward = e.key === \"ArrowRight\" || e.key === \"ArrowUp\";\n      const back = e.key === \"ArrowLeft\" || e.key === \"ArrowDown\";\n\n      if (forward || back) {\n        const direction = forward ? 1 : -1;\n        if (e.shiftKey) toDetent(direction);\n        else commit(value + direction * step);\n      } else if (e.key === \"PageUp\") {\n        toDetent(1);\n      } else if (e.key === \"PageDown\") {\n        toDetent(-1);\n      } else if (e.key === \"Home\") {\n        commit(min);\n      } else if (e.key === \"End\") {\n        commit(max);\n      } else {\n        return;\n      }\n      e.preventDefault();\n    },\n  };\n\n  return {\n    trackRef,\n    trackProps,\n    detents: list,\n    activeDetent,\n    percent,\n    dragging,\n    valueText,\n  };\n}\n\nexport type SliderDetentsProps = {\n  value: number;\n  onValueChange: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  detents?: readonly (number | SliderDetent)[];\n  pull?: number;\n  label?: string;\n  format?: (value: number) => string;\n  disabled?: boolean;\n  haptic?: boolean;\n  className?: string;\n};\n\nexport function SliderDetents({\n  value,\n  onValueChange,\n  min = 0,\n  max = 100,\n  step = 1,\n  detents = NONE,\n  pull,\n  label = \"Value\",\n  format = plain,\n  disabled = false,\n  haptic = true,\n  className = \"\",\n}: SliderDetentsProps) {\n  const labelId = useId();\n  const reduced = useReducedMotion();\n\n  const {\n    trackRef,\n    trackProps,\n    detents: list,\n    activeDetent,\n    percent,\n    dragging,\n  } = useSliderDetents({\n    value,\n    onValueChange,\n    min,\n    max,\n    step,\n    detents,\n    pull,\n    disabled,\n    haptic,\n    format,\n    labelledBy: labelId,\n  });\n\n  const carriage = useSpring(percent * 100, CARRIAGE);\n  const offset = useMotionTemplate`${carriage}%`;\n\n  useEffect(() => {\n    const target = percent * 100;\n    if (reduced) carriage.jump(target);\n    else carriage.set(target);\n  }, [carriage, percent, reduced]);\n\n  const widest = useMemo(() => {\n    const options = [\n      format(min),\n      format(max),\n      ...list.map((d) =>\n        d.label ? `${format(d.value)} · ${d.label}` : format(d.value),\n      ),\n    ];\n    return options.reduce((a, b) => (b.length > a.length ? b : a), \"\");\n  }, [format, list, max, min]);\n\n  const suffix = list[activeDetent]?.label ?? \"\";\n\n  const lastLabel = useRef(suffix);\n  if (suffix) lastLabel.current = suffix;\n\n  const span = max - min;\n\n  return (\n    <div className={`w-full select-none ${className}`}>\n      <div className=\"mb-2.5 flex items-baseline justify-between gap-3\">\n        <span\n          id={labelId}\n          className=\"text-[12.5px] text-stone-500 dark:text-stone-400\"\n        >\n          {label}\n        </span>\n        <span className=\"grid justify-items-start\">\n          <span\n            aria-hidden\n            className=\"invisible col-start-1 row-start-1 whitespace-pre font-mono text-[11px] tabular-nums\"\n          >\n            {widest}\n          </span>\n          <span\n            aria-hidden\n            className=\"col-start-1 row-start-1 whitespace-pre font-mono text-[11px] tabular-nums text-stone-700 dark:text-stone-200\"\n          >\n            {format(value)}\n            <motion.span\n              initial={false}\n              animate={{ opacity: suffix ? 1 : 0 }}\n              transition={reduced ? INSTANT : CROSSFADE}\n              className=\"text-stone-500 dark:text-stone-400\"\n            >\n              {lastLabel.current ? ` · ${lastLabel.current}` : \"\"}\n            </motion.span>\n          </span>\n        </span>\n      </div>\n      <div\n        ref={trackRef}\n        {...trackProps}\n        className={`relative h-9 w-full rounded-[9px] outline-none focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF] ${\n          disabled\n            ? \"pointer-events-none opacity-50\"\n            : dragging\n              ? \"cursor-grabbing\"\n              : \"cursor-grab\"\n        }`}\n      >\n        <div className=\"pointer-events-none absolute inset-x-0 top-[9px] h-[10px] overflow-hidden rounded-[5px] bg-stone-200 dark:bg-white/15\">\n          <div\n            className=\"absolute inset-y-0\"\n            style={{ left: THUMB / 2, right: THUMB / 2 }}\n          >\n            <motion.div\n              className=\"absolute inset-y-0 left-0 right-0\"\n              style={{ x: offset }}\n            >\n              <div className=\"absolute inset-y-0 right-full w-[2000px] bg-stone-800 dark:bg-stone-100\" />\n            </motion.div>\n          </div>\n        </div>\n        <div\n          className=\"pointer-events-none absolute inset-y-0\"\n          style={{ left: THUMB / 2, right: THUMB / 2 }}\n        >\n\n          {list.map((d) => (\n            <span\n              key={String(d.value)}\n              aria-hidden\n              className=\"absolute top-[26px] block h-[5px] w-[2px] -translate-x-1/2 bg-stone-800/35 dark:bg-stone-100/35\"\n              style={{\n                left: span > 0 ? `${((d.value - min) / span) * 100}%` : \"0%\",\n              }}\n            />\n          ))}\n        </div>\n        <div\n          className=\"pointer-events-none absolute inset-y-0\"\n          style={{ left: THUMB / 2, right: THUMB / 2 }}\n        >\n          <motion.div\n            className=\"absolute inset-y-0 left-0 right-0\"\n            style={{ x: offset }}\n          >\n            <motion.div\n              className=\"absolute top-[4px] h-[20px] w-[18px] rounded-[6px] border-2 border-white bg-stone-800 dark:border-stone-900 dark:bg-stone-100\"\n              style={{ marginLeft: -THUMB / 2 }}\n              initial={false}\n              animate={{ scale: dragging ? 1.08 : 1 }}\n              transition={reduced ? INSTANT : GRAB}\n            />\n          </motion.div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
    }
  ]
}
