{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "detent-slider",
  "type": "registry:ui",
  "title": "Detent Slider",
  "description": "Named stops you can feel: resists near each one, springs into place.",
  "categories": [
    "sliders"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/detent-slider.tsx",
      "content": "\"use client\";\nimport { Slider as BaseSlider } from \"@base-ui/react/slider\";\nimport { AnimatePresence, animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform } from \"motion/react\";\nimport { useEffect, useState, type ComponentProps, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type DetentStop = { value: string; label: ReactNode; description?: ReactNode };\n\nexport type DetentSliderProps = Omit<\n  ComponentProps<typeof BaseSlider.Root>,\n  \"value\" | \"defaultValue\" | \"onValueChange\" | \"onValueCommitted\" | \"children\" | \"className\" | \"render\" | \"min\" | \"max\" | \"step\" | \"largeStep\" | \"format\"\n> & {\n  /** The named positions, in order. Two or more. */\n  stops: DetentStop[];\n  value?: string;\n  defaultValue?: string;\n  /** Called when the thumb settles on a different stop, not on every frame of a drag. */\n  onValueChange?: (value: string) => void;\n  /** Visible label. Without one, pass aria-label. */\n  label?: ReactNode;\n  /** Unlit ruler ticks between neighboring stops. */\n  ticksBetween?: number;\n  className?: string;\n};\n\n// Stops live at whole numbers; the drag moves through the gaps at this resolution.\nconst FINE = 0.001;\n// The settle: fast, with a pixel or two of overshoot so the stop feels like it clicks in.\nconst settle = { type: \"spring\", stiffness: 640, damping: 31, mass: 0.6 } as const;\n\n// Near a stop the thumb lags the pointer, then catches up as you leave: a detent\n// you can feel. Offset from the nearest stop is eased by a power curve that meets\n// the pointer again at the midpoint, so the motion never jumps.\nfunction resist(raw: number) {\n  const k = Math.round(raw);\n  const o = raw - k;\n  return k + Math.sign(o) * 0.5 * Math.pow(Math.abs(o) / 0.5, 1.6);\n}\n\nconst spoken = (s?: DetentStop) => (s ? (typeof s.label === \"string\" ? s.label : s.value) : \"\");\n\nexport function DetentSlider({\n  stops,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  label,\n  ticksBetween = 3,\n  disabled,\n  className,\n  \"aria-label\": ariaLabel,\n  ...rest\n}: DetentSliderProps) {\n  const last = stops.length - 1;\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue: defaultValue ?? stops[0].value, onChange: onValueChange });\n  const index = Math.max(\n    0,\n    stops.findIndex((s) => s.value === value),\n  );\n  const reduce = useReducedMotion();\n\n  // raw: where the pointer says the thumb is (Base UI's value). pos: where the thumb is drawn.\n  const [raw, setRaw] = useState(index);\n  const [dir, setDir] = useState(1);\n  const [prevIndex, setPrevIndex] = useState(index);\n  const pos = useMotionValue(index);\n  const left = useTransform(pos, (v) => `${(v / last) * 100}%`);\n\n  // A controlled value that changes from outside settles the thumb onto it.\n  if (prevIndex !== index) {\n    setPrevIndex(index);\n    if (Math.round(raw) !== index) setRaw(index);\n  }\n  useEffect(() => {\n    if (Math.abs(pos.get() - index) < 0.001 || pos.isAnimating()) return;\n    if (reduce) pos.jump(index);\n    else animate(pos, index, settle);\n  }, [index, pos, reduce]);\n\n  const per = ticksBetween + 1;\n  const [lit, setLit] = useState(index * per);\n  useMotionValueEvent(pos, \"change\", (v) => setLit(Math.floor(v * per + 0.02)));\n\n  const nearest = Math.min(last, Math.max(0, Math.round(raw)));\n  const settleTo = (i: number, instant = false) => {\n    setRaw(i);\n    setDir(i >= index ? 1 : -1);\n    if (instant || reduce) pos.jump(i);\n    else animate(pos, i, settle);\n    setValue(stops[i].value);\n  };\n\n  return (\n    <BaseSlider.Root\n      value={raw}\n      min={0}\n      max={last}\n      step={FINE}\n      disabled={disabled}\n      onValueChange={(next, details) => {\n        const n = next as number;\n        if (details.reason === \"keyboard\") {\n          // Keys move a whole stop at a time, and instantly.\n          const to = n <= 0 ? 0 : n >= last ? last : Math.min(last, Math.max(0, nearest + Math.sign(n - raw)));\n          settleTo(to, true);\n          return;\n        }\n        setDir(Math.round(n) >= nearest ? 1 : -1);\n        setRaw(n);\n        if (details.reason === \"track-press\" && !reduce) animate(pos, resist(n), settle);\n        else pos.jump(resist(n));\n      }}\n      onValueCommitted={(v, details) => {\n        if (details.reason !== \"keyboard\") settleTo(Math.min(last, Math.max(0, Math.round(v as number))));\n      }}\n      data-stop={stops[nearest].value}\n      className={cn(\"group/detent flex w-full flex-col gap-2.5 data-disabled:opacity-50\", className)}\n      {...rest}\n    >\n      <div className=\"flex items-baseline justify-between gap-3\">\n        {label != null && <BaseSlider.Label className=\"min-w-0 truncate text-[12.5px] font-medium tracking-[-0.005em] text-fg\">{label}</BaseSlider.Label>}\n        <Swap reduce={!!reduce} dir={dir} active={nearest} className=\"ml-auto text-right text-[12.5px] text-fg-2\">\n          {stops.map((s) => s.label)}\n        </Swap>\n      </div>\n\n      <div>\n        <BaseSlider.Control className=\"group/control relative flex h-6 cursor-pointer touch-none select-none items-center group-data-disabled/detent:pointer-events-none\">\n          <BaseSlider.Track className=\"relative h-1.5 w-full rounded-full bg-fg/10 transition-colors duration-150 group-hover/control:bg-fg/14\">\n            <motion.div aria-hidden className=\"absolute inset-y-0 left-0 rounded-full bg-fg\" style={{ width: left }} />\n            {/* Base UI's thumb is the invisible hit target and focus stop; the drawn thumb follows pos. */}\n            <BaseSlider.Thumb\n              aria-label={ariaLabel}\n              getAriaValueText={(_, v) => spoken(stops[Math.round(v)])}\n              className=\"size-4 outline-none before:absolute before:-inset-3.5 before:content-['']\"\n            />\n            <motion.span\n              aria-hidden\n              className={cn(\n                \"pointer-events-none absolute top-1/2 z-[3] size-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-fg shadow-[var(--shadow)]\",\n                \"outline-offset-2 group-has-focus-visible/detent:outline-1 group-has-focus-visible/detent:outline-solid group-has-focus-visible/detent:outline-fg-3\",\n                \"transition-[scale,box-shadow] duration-150 ease-out-expo motion-reduce:transition-none\",\n                \"group-hover/control:scale-110 group-data-dragging/detent:scale-125 group-data-dragging/detent:ring-4 group-data-dragging/detent:ring-fg/10\",\n              )}\n              style={{ left }}\n            />\n          </BaseSlider.Track>\n        </BaseSlider.Control>\n\n        {/* The ruler: every tick the thumb has passed is lit, stops brighter than the ones between. */}\n        <div aria-hidden className=\"relative mt-1.5 h-2\">\n          {Array.from({ length: last * per + 1 }, (_, t) => {\n            const major = t % per === 0;\n            return (\n              <span\n                key={t}\n                data-lit={t <= lit || undefined}\n                className={cn(\n                  \"absolute top-0 w-px -translate-x-1/2 origin-top rounded-full transition-[background-color,scale] duration-150\",\n                  major ? \"h-2 bg-fg/20 data-lit:bg-fg\" : \"h-1 bg-fg/15 data-lit:bg-fg-2\",\n                  \"scale-y-75 data-lit:scale-y-100\",\n                )}\n                style={{ left: `${(t / (last * per)) * 100}%` }}\n              />\n            );\n          })}\n        </div>\n\n        {/* Stop names. Pointer shortcuts; the thumb covers them from the keyboard. */}\n        <div aria-hidden className=\"relative mt-1 h-4\">\n          {stops.map((s, i) => (\n            <button\n              key={s.value}\n              type=\"button\"\n              tabIndex={-1}\n              disabled={disabled}\n              data-current={i === nearest || undefined}\n              onClick={() => settleTo(i)}\n              className={cn(\n                \"absolute top-0 whitespace-nowrap rounded-sm px-1 text-[11px] leading-4 text-fg-3\",\n                \"transition-[color,scale] duration-150 hover:text-fg-2 active:scale-95 data-current:text-fg disabled:pointer-events-none\",\n                i === 0 ? \"-ml-1\" : i === last ? \"ml-1 -translate-x-full\" : \"-translate-x-1/2\",\n              )}\n              style={{ left: `${(i / last) * 100}%` }}\n            >\n              {s.label}\n            </button>\n          ))}\n        </div>\n      </div>\n\n      {stops.some((s) => s.description != null) && (\n        <Swap reduce={!!reduce} dir={dir} active={nearest} wrap className=\"text-[12px] leading-[1.45] text-fg-3\">\n          {stops.map((s) => s.description ?? \"\")}\n        </Swap>\n      )}\n    </BaseSlider.Root>\n  );\n}\n\n// Content that changes with the stop, inside a box sized to the largest option so\n// nothing around it moves. The new one slides in from the side the thumb is heading.\nfunction Swap({\n  children,\n  active,\n  dir,\n  reduce,\n  wrap,\n  className,\n}: {\n  children: ReactNode[];\n  active: number;\n  dir: number;\n  reduce: boolean;\n  wrap?: boolean;\n  className?: string;\n}) {\n  return (\n    <span className={cn(\"relative grid\", wrap ? \"\" : \"justify-items-end whitespace-nowrap\", className)}>\n      {children.map((c, i) => (\n        <span key={i} aria-hidden className=\"invisible col-start-1 row-start-1\">\n          {c}\n        </span>\n      ))}\n      <AnimatePresence initial={false} custom={dir}>\n        <motion.span\n          key={active}\n          custom={dir}\n          className=\"col-start-1 row-start-1\"\n          variants={{\n            enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * 6, filter: \"blur(2px)\" }),\n            center: { opacity: 1, x: 0, filter: \"blur(0px)\" },\n            exit: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * -6, filter: \"blur(2px)\" }),\n          }}\n          initial=\"enter\"\n          animate=\"center\"\n          exit=\"exit\"\n          transition={{ duration: reduce ? 0.12 : 0.18, ease: ease.out }}\n        >\n          {children[active]}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/detent-slider"
}
