{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "range-slider",
  "type": "registry:ui",
  "title": "Range Slider",
  "description": "Two thumbs whose labels merge when close, synced with typed min and max.",
  "categories": [
    "sliders"
  ],
  "dependencies": [
    "@base-ui/react"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/range-slider.tsx",
      "content": "\"use client\";\nimport { NumberField } from \"@base-ui/react/number-field\";\nimport { Slider as BaseSlider } from \"@base-ui/react/slider\";\nimport { useEffect, useId, useMemo, useRef, useState, type ComponentProps, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Range = [number, number];\n\nexport type RangeSliderProps = Omit<\n  ComponentProps<typeof BaseSlider.Root>,\n  \"value\" | \"defaultValue\" | \"onValueChange\" | \"onValueCommitted\" | \"children\" | \"className\" | \"render\" | \"largeStep\" | \"minStepsBetweenValues\"\n> & {\n  value?: Range;\n  defaultValue?: Range;\n  onValueChange?: (value: Range) => void;\n  /** Once per gesture: a drag released, a key pressed, the track pressed or an input committed. */\n  onValueCommitted?: (value: Range) => void;\n  /** Visible label for the whole range. Without one, pass aria-label. */\n  label?: ReactNode;\n  /** The smallest gap the two ends may have, in value units. Defaults to one step. */\n  minDistance?: number;\n  largeStep?: number;\n  /** Min and max fields under the track that stay in sync with the thumbs. */\n  inputs?: boolean;\n  /** Names for the two ends: the field labels and each thumb's accessible name. */\n  endLabels?: [string, string];\n  /** Text for the floating labels and aria-valuetext. Falls back to Intl.NumberFormat with format and locale. */\n  formatValue?: (value: number) => string;\n  className?: string;\n};\n\nconst pct = (v: number, min: number, max: number) => ((v - min) / (max - min)) * 100;\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\nconst snap = (v: number, step: number, min: number) => Number((Math.round((v - min) / step) * step + min).toFixed(10));\n\n/** Pixel widths of the track and the three floating labels, kept current by one observer. */\nfunction useWidths() {\n  const track = useRef<HTMLDivElement>(null);\n  const a = useRef<HTMLSpanElement>(null);\n  const b = useRef<HTMLSpanElement>(null);\n  const both = useRef<HTMLSpanElement>(null);\n  const [w, setW] = useState({ track: 0, a: 0, b: 0, both: 0 });\n  useEffect(() => {\n    const els = [track.current, a.current, b.current, both.current];\n    if (els.some((el) => !el)) return;\n    // Border-box widths, read in the observer callback so render stays pure.\n    const ro = new ResizeObserver(() => setW({ track: els[0]!.offsetWidth, a: els[1]!.offsetWidth, b: els[2]!.offsetWidth, both: els[3]!.offsetWidth }));\n    els.forEach((el) => ro.observe(el!));\n    return () => ro.disconnect();\n  }, []);\n  return { trackRef: track, aRef: a, bRef: b, bothRef: both, w };\n}\n\nexport function RangeSlider({\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onValueCommitted,\n  label,\n  min = 0,\n  max = 100,\n  step = 1,\n  largeStep,\n  minDistance,\n  inputs = true,\n  endLabels = [\"Minimum\", \"Maximum\"],\n  formatValue,\n  format,\n  locale,\n  disabled,\n  className,\n  \"aria-label\": ariaLabel,\n  ...rest\n}: RangeSliderProps) {\n  const [value, setValue] = useControllableState<Range>({ value: valueProp, defaultValue: defaultValue ?? [min, max], onChange: onValueChange });\n  const [motion, setMotion] = useState<\"glide\" | \"instant\">(\"glide\");\n  const [focused, setFocused] = useState<number | null>(null);\n  const { trackRef, aRef, bRef, bothRef, w } = useWidths();\n  const id = useId();\n  const gap = Math.max(minDistance ?? step, step);\n\n  const fmt = useMemo(() => {\n    if (formatValue) return formatValue;\n    const nf = new Intl.NumberFormat(locale, format);\n    return (v: number) => nf.format(v);\n  }, [formatValue, format, locale]);\n\n  const [lo, hi] = value;\n  const pa = pct(lo, min, max);\n  const pb = pct(hi, min, max);\n\n  // Once the two labels would touch, they give way to a single \"a – b\" label.\n  const measured = w.track > 0;\n  const merged = measured && ((pb - pa) / 100) * w.track < (w.a + w.b) / 2 + 8;\n  // Labels follow their thumb but never hang past the ends of the track.\n  const place = (p: number, width: number) => clamp((p / 100) * w.track, width / 2, w.track - width / 2) - width / 2;\n  const at = (p: number, width: number) => (measured ? { left: place(p, width) } : { left: `${p}%`, translate: \"-50% 0\" });\n\n  const commitEnd = (i: 0 | 1, typed: number | null) => {\n    if (typed == null || Number.isNaN(typed)) return;\n    const v = snap(typed, step, min);\n    const next: Range = i === 0 ? [clamp(v, min, hi - gap), hi] : [lo, clamp(v, lo + gap, max)];\n    setMotion(\"glide\");\n    setValue(next);\n    onValueCommitted?.(next);\n  };\n\n  return (\n    <div className={cn(\"group/range flex w-full flex-col gap-2\", disabled && \"opacity-50\", className)}>\n      <BaseSlider.Root\n        value={value}\n        min={min}\n        max={max}\n        step={step}\n        largeStep={largeStep ?? step * 10}\n        minStepsBetweenValues={Math.round(gap / step)}\n        thumbCollisionBehavior=\"none\"\n        format={format}\n        locale={locale}\n        disabled={disabled}\n        onValueChange={(next, details) => {\n          setMotion(details.reason === \"track-press\" ? \"glide\" : \"instant\");\n          setValue(next as Range);\n        }}\n        onValueCommitted={(next, details) => {\n          if (details.reason === \"drag\") setMotion(\"glide\");\n          onValueCommitted?.(next as Range);\n        }}\n        data-motion={motion}\n        className=\"group/slider flex flex-col gap-1.5\"\n        {...rest}\n      >\n        {label != null && <BaseSlider.Label className=\"text-[12.5px] font-medium tracking-[-0.005em] text-fg\">{label}</BaseSlider.Label>}\n\n        {/* Floating value labels. Decorative: each thumb announces its own value. */}\n        <div aria-hidden className=\"relative h-4 text-[12px] leading-4 tabular\">\n          {([0, 1] as const).map((i) => (\n            <span\n              key={i}\n              ref={i === 0 ? aRef : bRef}\n              suppressHydrationWarning\n              data-active={focused === i || undefined}\n              className={cn(\n                \"absolute top-0 whitespace-nowrap text-fg-2 data-active:text-fg\",\n                \"transition-[opacity,translate,color] duration-150 ease-out-expo\",\n                \"group-data-[motion=glide]/slider:transition-[opacity,translate,color,left]\",\n                merged && \"opacity-0\",\n              )}\n              style={{ ...at(i === 0 ? pa : pb, i === 0 ? w.a : w.b), ...(merged && measured ? { translate: `${i === 0 ? 6 : -6}px 0` } : null) }}\n            >\n              {fmt(value[i])}\n            </span>\n          ))}\n          <span\n            ref={bothRef}\n            suppressHydrationWarning\n            data-active={focused != null || undefined}\n            className={cn(\n              \"absolute top-0 whitespace-nowrap text-fg-2 data-active:text-fg\",\n              \"transition-[opacity,scale,color] duration-150 ease-out-expo motion-reduce:scale-100\",\n              \"group-data-[motion=glide]/slider:transition-[opacity,scale,color,left]\",\n              merged ? \"scale-100 opacity-100\" : \"pointer-events-none scale-95 opacity-0\",\n            )}\n            style={at((pa + pb) / 2, w.both)}\n          >\n            {fmt(lo)} – {fmt(hi)}\n          </span>\n        </div>\n\n        <BaseSlider.Control className=\"group/control relative flex h-6 cursor-pointer touch-none select-none items-center group-data-disabled/slider:pointer-events-none\">\n          <BaseSlider.Track ref={trackRef} className=\"relative h-1 w-full rounded-full bg-fg/10 transition-colors duration-150 group-hover/control:bg-fg/14\">\n            <div\n              aria-hidden\n              className=\"absolute inset-y-0 rounded-full bg-fg group-data-[motion=glide]/slider:transition-[left,width] group-data-[motion=glide]/slider:duration-200 group-data-[motion=glide]/slider:ease-out-expo\"\n              style={{ left: `${pa}%`, width: `${pb - pa}%` }}\n            />\n            {([0, 1] as const).map((i) => (\n              <BaseSlider.Thumb\n                key={i}\n                index={i}\n                aria-label={ariaLabel ? `${ariaLabel}, ${endLabels[i].toLowerCase()}` : endLabels[i]}\n                getAriaValueText={(_, v) => fmt(v)}\n                onFocus={() => setFocused(i)}\n                onBlur={() => setFocused((f) => (f === i ? null : f))}\n                className={(state) =>\n                  cn(\n                    \"group/thumb size-4 outline-none\",\n                    \"before:absolute before:-inset-3.5 before:content-['']\",\n                    \"group-data-[motion=glide]/slider:transition-[inset-inline-start] group-data-[motion=glide]/slider:duration-200 group-data-[motion=glide]/slider:ease-out-expo\",\n                    state.dragging && state.activeThumbIndex === i && \"grabbed\",\n                  )\n                }\n              >\n                <span\n                  aria-hidden\n                  className={cn(\n                    \"absolute inset-0 rounded-full bg-fg shadow-[var(--shadow)]\",\n                    \"outline-offset-2 group-has-focus-visible/thumb:outline-1 group-has-focus-visible/thumb:outline-solid group-has-focus-visible/thumb:outline-fg-3\",\n                    \"transition-[scale,box-shadow] duration-150 ease-out-expo motion-reduce:transition-none\",\n                    \"group-hover/thumb:scale-110 group-[.grabbed]/thumb:scale-125 group-[.grabbed]/thumb:ring-4 group-[.grabbed]/thumb:ring-fg/10\",\n                  )}\n                />\n              </BaseSlider.Thumb>\n            ))}\n          </BaseSlider.Track>\n        </BaseSlider.Control>\n      </BaseSlider.Root>\n\n      {inputs && (\n        <div className=\"mt-1.5 flex items-end gap-2\">\n          {([0, 1] as const).map((i) => (\n            <RangeInput\n              key={i}\n              id={`${id}-${i}`}\n              label={endLabels[i]}\n              value={value[i]}\n              min={i === 0 ? min : lo + gap}\n              max={i === 0 ? hi - gap : max}\n              step={step}\n              format={format}\n              locale={locale}\n              disabled={disabled}\n              onCommit={(v) => commitEnd(i, v)}\n              separator={i === 1}\n            />\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n\n// One end as a text field. Typing keeps a local draft so a half-typed number\n// never drags the thumb around; the range only changes on Enter, blur, arrows or wheel.\nfunction RangeInput({\n  id,\n  label,\n  value,\n  onCommit,\n  separator,\n  ...field\n}: {\n  id: string;\n  label: string;\n  value: number;\n  onCommit: (v: number | null) => void;\n  separator: boolean;\n  min: number;\n  max: number;\n  step: number;\n  format?: Intl.NumberFormatOptions;\n  locale?: Intl.LocalesArgument;\n  disabled?: boolean;\n}) {\n  const [draft, setDraft] = useState<number | null | undefined>(undefined);\n  return (\n    <>\n      {separator && (\n        <span aria-hidden className=\"mb-2 text-fg-4\">\n          –\n        </span>\n      )}\n      <NumberField.Root\n        id={id}\n        value={draft === undefined ? value : draft}\n        onValueChange={(v) => setDraft(v)}\n        onValueCommitted={(v) => {\n          setDraft(undefined);\n          onCommit(v);\n        }}\n        allowWheelScrub\n        className=\"flex min-w-0 flex-1 flex-col gap-1.5\"\n        {...field}\n      >\n        <label htmlFor={id} className=\"text-[11px] text-fg-3\">\n          {label}\n        </label>\n        <NumberField.Group\n          className={cn(\n            \"flex h-8 items-center rounded-lg border border-line-2 bg-raised shadow-[var(--shadow)]\",\n            \"transition-[border-color,box-shadow] duration-150 hover:border-fg-4\",\n            \"focus-within:border-fg-4 focus-within:ring-2 focus-within:ring-fg/10\",\n          )}\n        >\n          <NumberField.Input\n            suppressHydrationWarning\n            className=\"h-full w-full min-w-0 bg-transparent px-2.5 text-base text-fg tabular outline-none sm:text-[13px]\"\n          />\n        </NumberField.Group>\n      </NumberField.Root>\n    </>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/range-slider"
}
