{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "range-slider",
  "title": "Range slider",
  "description": "A two-thumb range slider over a histogram whose bars, inside the range, run red on the left to green on the right, with the minimum and maximum read out large beneath it; plus a single-value stick slider on the same ramp.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/items/range-slider/range-slider.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─── The ramp ─────────────────────────────────────────────────────────── */\n\n/**\n * Bad-to-good colour stops, by position along the track. Every colour is a\n * contract token, so the ramp follows the theme and the mode.\n */\nconst RAMP_STOPS: readonly { at: number; color: string }[] = [\n  { at: 0, color: \"var(--stop)\" },\n  { at: 0.3, color: \"var(--ramp-orange)\" },\n  { at: 0.55, color: \"var(--milestone-yellow)\" },\n  { at: 0.78, color: \"var(--go-pale)\" },\n  { at: 1, color: \"var(--go)\" },\n];\n\n/**\n * The colour of the bad-to-good ramp at `ratio` (0 = left, 1 = right), as a\n * CSS string. Between two stops it is a `color-mix()` the browser resolves,\n * so nothing here reads computed styles.\n */\nexport function rampColorAt(ratio: number): string {\n  const t = Number.isFinite(ratio) ? Math.min(1, Math.max(0, ratio)) : 0;\n  const upper = Math.max(1, RAMP_STOPS.findIndex((stop) => t <= stop.at));\n  const from = RAMP_STOPS[upper - 1];\n  const to = RAMP_STOPS[upper];\n  const share = Math.round(((t - from.at) / (to.at - from.at)) * 100);\n  if (share <= 0) return from.color;\n  if (share >= 100) return to.color;\n  return `color-mix(in oklab, ${to.color} ${share}%, ${from.color})`;\n}\n\n/* ─── Scales ───────────────────────────────────────────────────────────── */\n\n/**\n * The places a thumb can rest, numbered 0..`top`. Thumbs and keys work in\n * these whole-number positions; values only appear at the edges, so a step of\n * 0.1 never accumulates float noise.\n */\ninterface Scale {\n  /** The highest position. Always at least 1, so two thumbs fit. */\n  top: number;\n  valueAt(position: number): number;\n  /** The resting position nearest to any value. */\n  nearest(value: number): number;\n  /** Where a position sits along the track, 0-1. */\n  ratioAt(position: number): number;\n  /** The resting position nearest to a point along the track, 0-1. */\n  atRatio(ratio: number): number;\n}\n\nconst clamp = (n: number, low: number, high: number) => Math.min(high, Math.max(low, n));\n\n/** How many decimal places `n` is written with, exponent form included. */\nfunction decimalPlaces(n: number): number {\n  if (!Number.isFinite(n) || Number.isInteger(n)) return 0;\n  const [digits, exponent] = n.toExponential().split(\"e\");\n  const fraction = digits.split(\".\")[1]?.length ?? 0;\n  return Math.max(0, fraction - Number(exponent));\n}\n\n/**\n * Evenly spaced values from `min` by `step`. When `step` does not divide the\n * range, `max` is kept as one shorter final step so the range stays reachable.\n */\nfunction linearScale(min: number, max: number, step: number): Scale {\n  const size = Number.isFinite(step) && step > 0 ? step : 1;\n  const end = max > min ? max : min + size;\n  const places = Math.max(decimalPlaces(size), decimalPlaces(min));\n  const tidy = (n: number) => Number(n.toFixed(places));\n  const fullSteps = Math.floor((end - min) / size + 1e-9);\n  const shortTail = tidy(min + fullSteps * size) < end;\n  const top = Math.max(1, fullSteps + (shortTail ? 1 : 0));\n\n  const valueAt = (position: number) => {\n    if (position >= top) return end;\n    return tidy(min + Math.max(0, position) * size);\n  };\n\n  const nearest = (value: number) => {\n    if (Number.isNaN(value) || value <= min) return 0;\n    if (value >= end) return top;\n    const lastWhole = valueAt(fullSteps);\n    if (shortTail && value > lastWhole) {\n      // Between the last whole step and `max`: whichever is closer.\n      return value - lastWhole <= end - value ? fullSteps : top;\n    }\n    return clamp(Math.round((value - min) / size), 0, top);\n  };\n\n  return {\n    top,\n    valueAt,\n    nearest,\n    ratioAt: (position) => (valueAt(position) - min) / (end - min),\n    atRatio: (ratio) => nearest(min + clamp(ratio, 0, 1) * (end - min)),\n  };\n}\n\n/** A listed set of values, each an equal distance along the track. */\nfunction listedScale(stops: readonly number[]): Scale {\n  const top = stops.length - 1;\n  return {\n    top,\n    valueAt: (position) => stops[clamp(position, 0, top)],\n    nearest: (value) => {\n      let best = 0;\n      for (let i = 1; i <= top; i++) {\n        if (Math.abs(stops[i] - value) < Math.abs(stops[best] - value)) best = i;\n      }\n      return best;\n    },\n    ratioAt: (position) => position / top,\n    atRatio: (ratio) => Math.round(clamp(ratio, 0, 1) * top),\n  };\n}\n\n/* ─── Helpers ──────────────────────────────────────────────────────────── */\n\ntype Pair = [number, number];\n\n/** 0 is the minimum thumb, 1 the maximum. */\ntype Side = 0 | 1;\n\nconst SIDES: readonly Side[] = [0, 1];\n\n/**\n * Resting positions for any pair of values: ordered, snapped, and at least\n * one position apart.\n */\nfunction settle(scale: Scale, [a, b]: Pair): Pair {\n  const low = Math.min(scale.nearest(Math.min(a, b)), scale.top - 1);\n  const high = Math.max(scale.nearest(Math.max(a, b)), low + 1);\n  return [low, high];\n}\n\n/** `pair` with one thumb sent towards `target`, stopped short of the other. */\nfunction shift(pair: Pair, side: Side, target: number, top: number): Pair {\n  const [low, high] = pair;\n  return side === 0 ? [clamp(target, 0, high - 1), high] : [low, clamp(target, low + 1, top)];\n}\n\n/**\n * A ratio as a CSS percentage, rounded so the server and the browser print\n * the same string and hydration matches.\n */\nconst percent = (ratio: number) => `${Number((ratio * 100).toFixed(4))}%`;\n\nconst defaultFormat = (value: number) => value.toLocaleString();\n\n/** Ramp samples for the rail's gradient: every 5%. */\nconst RAIL_SAMPLES = 21;\n\n/* ─── Component ────────────────────────────────────────────────────────── */\n\nexport interface RangeSliderProps\n  extends Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"title\" | \"children\" | \"onChange\"> {\n  /** Lowest value of a linear scale. Default 0. Ignored when `stops` is given. */\n  min?: number;\n  /** Highest value of a linear scale. Default 100. Ignored when `stops` is given. */\n  max?: number;\n  /** Distance between resting values on a linear scale. Default 1. Ignored when `stops` is given. */\n  step?: number;\n  /**\n   * A non-linear scale: the only values a thumb can rest on, ascending, each\n   * an equal distance along the track. Values in and out are these numbers;\n   * one that is not listed settles on the nearest.\n   */\n  stops?: readonly number[];\n  /** The selected range, controlled. */\n  value?: [number, number];\n  /** The starting range, uncontrolled. Defaults to the whole scale. */\n  defaultValue?: [number, number];\n  /** Every change while dragging or keying. Not called when nothing moved. */\n  onValueChange?: (value: [number, number]) => void;\n  /** Once when a drag ends, and once per handled key press. */\n  onValueCommit?: (value: [number, number]) => void;\n  /** How a value reads in the readout and to screen readers. */\n  formatValue?: (value: number) => string;\n  /** Histogram bar heights, in any unit; drawn left to right behind the thumbs. */\n  data?: readonly number[];\n  /** Hover text for a histogram bar. */\n  barLabel?: (index: number) => string | undefined;\n  /** Draw the histogram when `data` is given. Default true; otherwise a thin rail. */\n  showHistogram?: boolean;\n  /** Height classes for the histogram area. Default \"h-36 sm:h-40\". */\n  histogramHeight?: string;\n  /** \"ramp\" colours the selection bad-to-good; \"primary\" uses one fill. */\n  colorScheme?: \"ramp\" | \"primary\";\n  /** A CSS colour for a position 0-1. Defaults to `rampColorAt`. */\n  colorAt?: (ratio: number) => string;\n  /** A heading above the slider. */\n  title?: React.ReactNode;\n  /** The minimum's readout heading and thumb name. Default \"Minimum\". */\n  minLabel?: string;\n  /** The maximum's readout heading and thumb name. Default \"Maximum\". */\n  maxLabel?: string;\n  /** A line under the minimum figure; a function receives the current value. */\n  minSubtitle?: React.ReactNode | ((value: number) => React.ReactNode);\n  /** A line under the maximum figure; a function receives the current value. */\n  maxSubtitle?: React.ReactNode | ((value: number) => React.ReactNode);\n  /** The two large figures under the track. Default true. */\n  showReadout?: boolean;\n  disabled?: boolean;\n}\n\n/** An in-progress pointer drag. Lives in a ref: it changes every pointermove. */\ninterface Drag {\n  pointerId: number;\n  side: Side;\n  /** Pointer distance from the thumb's centre when it was grabbed, in px. */\n  offset: number;\n  /** The latest positions, ahead of any re-render. */\n  pair: Pair;\n}\n\n/**\n * A two-thumb range slider, optionally over a histogram whose bars inside the\n * range take the ramp colour of where they stand, with both ends read out\n * large underneath.\n *\n * Press a thumb to drag it; press the track with a mouse or pen to bring the\n * nearer thumb there and keep dragging. On touch only the thumbs grab, so a\n * swipe across the bars still scrolls the page. Each thumb is a keyboard\n * slider: arrows, Page Up/Down, Home and End.\n */\nexport function RangeSlider({\n  min = 0,\n  max = 100,\n  step = 1,\n  stops,\n  value,\n  defaultValue,\n  onValueChange,\n  onValueCommit,\n  formatValue = defaultFormat,\n  data,\n  barLabel,\n  showHistogram = true,\n  histogramHeight = \"h-36 sm:h-40\",\n  colorScheme = \"ramp\",\n  colorAt = rampColorAt,\n  title,\n  minLabel = \"Minimum\",\n  maxLabel = \"Maximum\",\n  minSubtitle,\n  maxSubtitle,\n  showReadout = true,\n  disabled = false,\n  className,\n  ...rest\n}: RangeSliderProps) {\n  const scale = React.useMemo(\n    () => (stops && stops.length >= 2 ? listedScale(stops) : linearScale(min, max, step)),\n    [stops, min, max, step],\n  );\n\n  const [uncontrolled, setUncontrolled] = React.useState<Pair>(\n    () => defaultValue ?? [scale.valueAt(0), scale.valueAt(scale.top)],\n  );\n  const isControlled = value !== undefined;\n  const pair = settle(scale, isControlled ? value : uncontrolled);\n  const values: Pair = [scale.valueAt(pair[0]), scale.valueAt(pair[1])];\n  const ratios: Pair = [scale.ratioAt(pair[0]), scale.ratioAt(pair[1])];\n\n  const [dragging, setDragging] = React.useState<Side | null>(null);\n  const dragRef = React.useRef<Drag | null>(null);\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const thumbRefs = React.useRef<(HTMLButtonElement | null)[]>([null, null]);\n  const titleId = React.useId();\n\n  const ramp = colorScheme === \"ramp\";\n  const bars = showHistogram && data && data.length > 0 ? data : null;\n  const toValues = (positions: Pair): Pair => [scale.valueAt(positions[0]), scale.valueAt(positions[1])];\n\n  /** Report `next` if it differs from `previous`. Returns whether it did. */\n  function change(previous: Pair, next: Pair): boolean {\n    if (previous[0] === next[0] && previous[1] === next[1]) return false;\n    const nextValues = toValues(next);\n    if (!isControlled) setUncontrolled(nextValues);\n    onValueChange?.(nextValues);\n    return true;\n  }\n\n  /** The resting position under a pointer's x coordinate. */\n  function positionAt(clientX: number): number {\n    const box = trackRef.current?.getBoundingClientRect();\n    if (!box || box.width === 0) return 0;\n    return scale.atRatio((clientX - box.left) / box.width);\n  }\n\n  function handlePointerDown(event: React.PointerEvent<HTMLDivElement>) {\n    if (disabled || dragRef.current || !event.isPrimary) return;\n    if (event.pointerType === \"mouse\" && event.button !== 0) return;\n\n    const thumb = (event.target as Element).closest<HTMLButtonElement>(\"[data-thumb]\");\n    const onThumb = thumb !== null && event.currentTarget.contains(thumb);\n    // On touch the track is left to the page, so a swipe over it scrolls.\n    if (!onThumb && event.pointerType === \"touch\") return;\n\n    let side: Side;\n    let offset = 0;\n    let target: number | null = null;\n    if (onThumb) {\n      side = thumb.dataset.thumb === \"max\" ? 1 : 0;\n      const knob = thumb.getBoundingClientRect();\n      offset = event.clientX - (knob.left + knob.width / 2);\n    } else {\n      target = positionAt(event.clientX);\n      const ratio = scale.ratioAt(target);\n      // The nearer thumb answers; a tie goes to the minimum.\n      side = Math.abs(ratio - ratios[0]) <= Math.abs(ratio - ratios[1]) ? 0 : 1;\n    }\n\n    event.preventDefault();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    thumbRefs.current[side]?.focus({ preventScroll: true });\n\n    const drag: Drag = { pointerId: event.pointerId, side, offset, pair };\n    if (target !== null) {\n      const next = shift(pair, side, target, scale.top);\n      if (change(pair, next)) drag.pair = next;\n    }\n    dragRef.current = drag;\n    setDragging(side);\n  }\n\n  function handlePointerMove(event: React.PointerEvent<HTMLDivElement>) {\n    const drag = dragRef.current;\n    if (!drag || drag.pointerId !== event.pointerId || disabled) return;\n    const next = shift(drag.pair, drag.side, positionAt(event.clientX - drag.offset), scale.top);\n    if (change(drag.pair, next)) drag.pair = next;\n  }\n\n  /** Ends a drag on release, cancel, or lost capture; commits exactly once. */\n  function handlePointerEnd(event: React.PointerEvent<HTMLDivElement>) {\n    const drag = dragRef.current;\n    if (!drag || drag.pointerId !== event.pointerId) return;\n    dragRef.current = null;\n    setDragging(null);\n    onValueCommit?.(toValues(drag.pair));\n  }\n\n  function handleKeyDown(side: Side, event: React.KeyboardEvent<HTMLButtonElement>) {\n    if (disabled) return;\n    const big = Math.max(1, Math.round(scale.top / 10));\n    const here = pair[side];\n    let target: number;\n    switch (event.key) {\n      case \"ArrowLeft\":\n      case \"ArrowDown\":\n        target = here - 1;\n        break;\n      case \"ArrowRight\":\n      case \"ArrowUp\":\n        target = here + 1;\n        break;\n      case \"PageDown\":\n        target = here - big;\n        break;\n      case \"PageUp\":\n        target = here + big;\n        break;\n      case \"Home\":\n        target = side === 0 ? 0 : pair[0] + 1;\n        break;\n      case \"End\":\n        target = side === 1 ? scale.top : pair[1] - 1;\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n    const next = shift(pair, side, target, scale.top);\n    change(pair, next);\n    onValueCommit?.(toValues(next));\n  }\n\n  const railGradient =\n    !bars && ramp\n      ? `linear-gradient(to right, ${Array.from({ length: RAIL_SAMPLES }, (_, i) => {\n          const at = i / (RAIL_SAMPLES - 1);\n          return `${colorAt(at)} ${percent(at)}`;\n        }).join(\", \")})`\n      : undefined;\n\n  const tallest = bars ? Math.max(0, ...bars) : 0;\n  const subtitles = [minSubtitle, maxSubtitle];\n  const labels = [minLabel, maxLabel];\n\n  return (\n    <div\n      data-slot=\"range-slider\"\n      data-disabled={disabled || undefined}\n      role=\"group\"\n      aria-labelledby={title ? titleId : undefined}\n      className={cn(\"@container w-full px-4 font-body\", disabled && \"opacity-50\", className)}\n      {...rest}\n    >\n      {title ? (\n        <div id={titleId} className=\"mb-6 text-center text-lg font-medium text-ink\">\n          {title}\n        </div>\n      ) : null}\n\n      <div\n        ref={trackRef}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={handlePointerEnd}\n        onPointerCancel={handlePointerEnd}\n        onLostPointerCapture={handlePointerEnd}\n        className={cn(\n          \"relative w-full select-none\",\n          bars ? histogramHeight : \"h-10\",\n          disabled ? \"cursor-default\" : dragging !== null ? \"cursor-grabbing\" : \"cursor-pointer\",\n        )}\n      >\n        {bars ? (\n          <div aria-hidden=\"true\" className=\"absolute inset-0 flex items-end gap-px\">\n            {bars.map((height, index) => {\n              const at = bars.length > 1 ? index / (bars.length - 1) : 0;\n              // A hair of tolerance: `at` and the thumb ratios are divided differently.\n              const selected = at >= ratios[0] - 1e-9 && at <= ratios[1] + 1e-9;\n              const share = tallest > 0 ? Math.max(0, height) / tallest : 0;\n              return (\n                <div\n                  key={index}\n                  title={barLabel?.(index)}\n                  className={cn(\n                    \"flex-1 rounded-t-sm transition-[background-color,height] duration-200 ease-out motion-reduce:transition-none\",\n                    !selected ? \"bg-paper-sunk\" : !ramp && \"bg-primary\",\n                  )}\n                  style={{\n                    height: percent(Math.max(0.08, share)),\n                    backgroundColor: selected && ramp ? colorAt(at) : undefined,\n                  }}\n                />\n              );\n            })}\n          </div>\n        ) : (\n          <div\n            aria-hidden=\"true\"\n            className={cn(\n              \"absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full\",\n              !ramp && \"bg-primary\",\n            )}\n            style={{ backgroundImage: railGradient }}\n          >\n            <div className=\"absolute inset-y-0 left-0 bg-paper-sunk\" style={{ width: percent(ratios[0]) }} />\n            <div className=\"absolute inset-y-0 right-0 bg-paper-sunk\" style={{ left: percent(ratios[1]) }} />\n          </div>\n        )}\n\n        {SIDES.map((side) => {\n          const active = dragging === side;\n          // Two thumbs one step apart overlap; whichever has room to move sits on top.\n          const raised = side === 0 && ratios[0] > 0.5;\n          return (\n            <button\n              key={side}\n              ref={(node) => {\n                thumbRefs.current[side] = node;\n              }}\n              type=\"button\"\n              role=\"slider\"\n              data-thumb={side === 0 ? \"min\" : \"max\"}\n              disabled={disabled}\n              aria-label={labels[side]}\n              aria-orientation=\"horizontal\"\n              aria-valuemin={side === 0 ? scale.valueAt(0) : scale.valueAt(pair[0] + 1)}\n              aria-valuemax={side === 0 ? scale.valueAt(pair[1] - 1) : scale.valueAt(scale.top)}\n              aria-valuenow={values[side]}\n              aria-valuetext={formatValue(values[side])}\n              onKeyDown={(event) => handleKeyDown(side, event)}\n              className={cn(\n                \"absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 touch-none rounded-full border-2 bg-slider-handle shadow-sm outline-none\",\n                \"transition-transform duration-150 ease-out motion-reduce:transition-none\",\n                \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card\",\n                // A 44px target around the 20px knob.\n                \"after:absolute after:top-1/2 after:left-1/2 after:size-11 after:-translate-x-1/2 after:-translate-y-1/2 after:rounded-full after:content-['']\",\n                !ramp && \"border-primary\",\n                disabled ? \"cursor-default\" : \"cursor-grab active:cursor-grabbing\",\n                active\n                  ? \"z-20 scale-125 cursor-grabbing ring-4 ring-primary/20\"\n                  : cn(raised ? \"z-[11]\" : \"z-10\", !disabled && \"hover:scale-110\"),\n              )}\n              style={{\n                left: percent(ratios[side]),\n                borderColor: ramp ? colorAt(ratios[side]) : undefined,\n              }}\n            />\n          );\n        })}\n      </div>\n\n      {showReadout ? (\n        <div className=\"mt-6 grid grid-cols-2 gap-4\">\n          {SIDES.map((side) => {\n            const subtitle = subtitles[side];\n            const line = typeof subtitle === \"function\" ? subtitle(values[side]) : subtitle;\n            return (\n              <div key={side} className=\"min-w-0 text-center\">\n                <div className=\"text-[15px] text-dim @md:text-lg\">{labels[side]}</div>\n                <div className=\"mt-1 truncate text-[34px] leading-tight font-bold text-ink tabular-nums @sm:text-[44px] @xl:text-[56px]\">\n                  {formatValue(values[side])}\n                </div>\n                {line !== undefined && line !== null && typeof line !== \"boolean\" ? (\n                  <div className=\"mt-0.5 text-[15px] text-dim @md:text-lg\">{line}</div>\n                ) : null}\n              </div>\n            );\n          })}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/items/range-slider/stick-slider.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState, type CSSProperties } from \"react\";\nimport { useReducedMotionConfig } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * The same bad-to-good ramp as `RangeSlider`, kept in this file so the stick\n * slider installs and runs on its own. Positions are mixed between the\n * contract's ramp and state tokens, so they follow the theme and the mode.\n */\nconst RAMP: readonly (readonly [number, string])[] = [\n  [0, \"var(--stop)\"],\n  [0.3, \"var(--ramp-orange)\"],\n  [0.55, \"var(--milestone-yellow)\"],\n  [0.78, \"var(--go-pale)\"],\n  [1, \"var(--go)\"],\n];\n\nfunction stickRampColorAt(ratio: number): string {\n  const t = Math.max(0, Math.min(1, ratio));\n  for (let i = 0; i < RAMP.length - 1; i++) {\n    const [t0, c0] = RAMP[i];\n    const [t1, c1] = RAMP[i + 1];\n    if (t <= t1) {\n      const local = Math.round(((t - t0) / (t1 - t0)) * 100);\n      if (local <= 0) return c0;\n      if (local >= 100) return c1;\n      return `color-mix(in oklab, ${c1} ${local}%, ${c0})`;\n    }\n  }\n  return RAMP[RAMP.length - 1][1];\n}\n\nexport interface StickSliderProps {\n  id?: string;\n  /** Form field name: the slider is a real `<input type=\"range\">`, so it submits. */\n  name?: string;\n  value: number;\n  onValueChange: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  disabled?: boolean;\n  /**\n   * Shows the real colour at full strength with no handle and nothing to drag.\n   * Not `disabled`, which greys the row out and hides the thing being read.\n   */\n  readOnly?: boolean;\n  className?: string;\n  \"aria-label\"?: string;\n  \"aria-describedby\"?: string;\n  \"aria-valuetext\"?: string;\n  /** Colour for a position along the track, 0-1. Defaults to the ramp, red to green. */\n  colorAt?: (ratio: number) => string;\n  /** Distance between stick centres in px. */\n  pitch?: number;\n  stickWidth?: number;\n  stickHeight?: number;\n  /**\n   * How fast the liquid catches the handle, as a fraction of the remaining gap\n   * per frame (default 0.1). Lower is slower and more viscous.\n   */\n  gooChase?: number;\n}\n\nconst HANDLE_SIZE = 18;\nconst HANDLE_GAP = 5;\n\n/**\n * A single-value slider drawn as a row of rounded sticks.\n *\n * The sticks are decoration over a real range input stretched invisibly across\n * them, so dragging, clicking the track, arrow keys, touch and screen readers\n * all behave like a native slider. Every filled stick takes the ramp's colour\n * at the current position, and the stroke transitions, so dragging right fades\n * the whole fill from red through amber into green. The round handle sits just\n * past the last filled stick and stretches into a liquid streak as it is\n * dragged, settling back into a circle.\n */\nexport function StickSlider({\n  id,\n  name,\n  value,\n  onValueChange,\n  min = 0,\n  max = 100,\n  step = 1,\n  disabled = false,\n  readOnly = false,\n  className,\n  \"aria-label\": ariaLabel,\n  \"aria-describedby\": ariaDescribedBy,\n  \"aria-valuetext\": ariaValueText,\n  colorAt = stickRampColorAt,\n  pitch = 8.5,\n  stickWidth = 3,\n  stickHeight = 18,\n  gooChase = 0.1,\n}: StickSliderProps) {\n  const fallbackId = useId();\n  const reduceMotion = useReducedMotionConfig();\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(0);\n  // Where the liquid has got to, as opposed to where the handle is. It chases\n  // the handle a frame at a time and the capsule is drawn spanning the two.\n  // Written straight to the node: it changes every frame of a drag.\n  const trailElRef = useRef<HTMLSpanElement>(null);\n  const trailXRef = useRef<number | null>(null);\n  const frameRef = useRef<number | null>(null);\n\n  useEffect(() => {\n    const node = containerRef.current;\n    if (!node) return;\n    // ResizeObserver reports the first size on observe, so no initial read.\n    const observer = new ResizeObserver(([entry]) => {\n      if (entry && entry.contentRect.width > 0) setWidth(entry.contentRect.width);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const range = max - min;\n  const ratio = range > 0 ? Math.max(0, Math.min(1, (value - min) / range)) : 0;\n\n  // The sticks leave room for the handle at the right end, so at 100% it still\n  // lands inside the row. Read-only has no handle and takes the space back.\n  const handleSpace = readOnly ? 0 : HANDLE_GAP + HANDLE_SIZE;\n\n  const effectiveWidth = width > 0 ? width : 600;\n  const trackStart = stickWidth / 2;\n  const available = Math.max(1, effectiveWidth - stickWidth - handleSpace);\n  const intervals = Math.max(12, Math.round(available / pitch));\n  const totalTicks = intervals + 1;\n  const spacing = available / intervals;\n  const activeCount = ratio === 0 ? 0 : Math.max(1, Math.round(ratio * totalTicks));\n\n  const top = 3;\n  const svgHeight = stickHeight + 6;\n  const fill = colorAt(ratio);\n  const handleX =\n    activeCount === 0\n      ? HANDLE_SIZE / 2\n      : trackStart + (activeCount - 1) * spacing + stickWidth / 2 + HANDLE_GAP + HANDLE_SIZE / 2;\n\n  useEffect(() => {\n    if (readOnly) return;\n\n    // The capsule spans from the handle back to where the liquid has got to.\n    // It thins slightly as it stretches, and at rest it is a circle on the handle.\n    const paint = (x: number) => {\n      const node = trailElRef.current;\n      if (!node) return;\n      const lag = Math.abs(handleX - x);\n      const squash = 1 - (Math.min(lag, HANDLE_SIZE * 2) / (HANDLE_SIZE * 2)) * 0.18;\n      node.style.left = `${Math.min(handleX, x) - HANDLE_SIZE / 2}px`;\n      node.style.width = `${lag + HANDLE_SIZE}px`;\n      node.style.height = `${HANDLE_SIZE * squash}px`;\n    };\n\n    // Reduced motion, and the first paint, put the liquid straight on the handle.\n    if (reduceMotion || trailXRef.current === null) {\n      trailXRef.current = handleX;\n      paint(handleX);\n      return;\n    }\n\n    const tick = () => {\n      const current = trailXRef.current ?? handleX;\n      const distance = handleX - current;\n      if (Math.abs(distance) < 0.4) {\n        trailXRef.current = handleX;\n        paint(handleX);\n        frameRef.current = null;\n        return;\n      }\n      trailXRef.current = current + distance * gooChase;\n      paint(trailXRef.current);\n      frameRef.current = requestAnimationFrame(tick);\n    };\n    if (frameRef.current === null) frameRef.current = requestAnimationFrame(tick);\n\n    return () => {\n      if (frameRef.current !== null) {\n        cancelAnimationFrame(frameRef.current);\n        frameRef.current = null;\n      }\n    };\n  }, [handleX, gooChase, readOnly, reduceMotion]);\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\n        \"group relative w-full rounded-inset has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-lead/30 has-[:focus-visible]:ring-offset-4 has-[:focus-visible]:ring-offset-card\",\n        disabled && !readOnly && \"opacity-50\",\n        className,\n      )}\n    >\n      <svg aria-hidden=\"true\" className=\"block w-full overflow-visible\" height={svgHeight}>\n        {Array.from({ length: totalTicks }, (_, index) => {\n          const isActive = index < activeCount;\n          const x = trackStart + index * spacing;\n          return (\n            <line\n              key={index}\n              x1={x}\n              x2={x}\n              y1={top}\n              y2={top + stickHeight}\n              // `style`, not `stroke={...}`: a color-mix() or var() colour is CSS,\n              // which a presentation attribute does not parse.\n              style={{ stroke: isActive ? fill : \"var(--rule-soft)\" }}\n              strokeWidth={stickWidth}\n              strokeLinecap=\"round\"\n              className=\"transition-[stroke] duration-300 ease-out motion-reduce:transition-none\"\n            />\n          );\n        })}\n      </svg>\n\n      {/* One capsule pinned at the handle and stretched back toward where it was\n          dragged from. Geometry is owned by the effect above, not by JSX, so a\n          re-render mid-drag does not snap it back to a circle. */}\n      {!readOnly && (\n        <span\n          ref={trailElRef}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute top-1/2 left-0 size-[18px] -translate-y-1/2 rounded-full bg-slider-handle shadow-sm ring-1 ring-rule-soft\"\n        />\n      )}\n\n      <input\n        id={id ?? fallbackId}\n        name={name}\n        type=\"range\"\n        min={min}\n        max={max}\n        step={step}\n        value={value}\n        disabled={disabled || readOnly}\n        onChange={(event) => onValueChange(Number(event.target.value))}\n        aria-label={ariaLabel}\n        aria-describedby={ariaDescribedBy}\n        aria-valuetext={ariaValueText}\n        style={{ \"--thumb-size\": `${HANDLE_SIZE}px` } as CSSProperties}\n        className=\"absolute inset-0 z-10 h-full w-full cursor-grab appearance-none opacity-0 active:cursor-grabbing disabled:cursor-default [&::-moz-range-thumb]:size-[var(--thumb-size)] [&::-moz-range-thumb]:border-0 [&::-webkit-slider-thumb]:size-[var(--thumb-size)] [&::-webkit-slider-thumb]:appearance-none\"\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "kind": "widget",
    "whenToUse": "Reach for it when a filter is a numeric range where low-to-high also reads bad-to-good or small-to-large (income, budget, size), especially with a distribution worth showing; pass `stops` for a non-linear scale. Not for picking one value from a short list (use a select or segmented control), and not where red/green would imply a judgement the data does not make (use colorScheme=\"primary\").",
    "origin": {
      "project": "180Connect",
      "path": "src/components/ui/range-slider.tsx (+ src/components/ui/stick-slider.tsx); used in src/app/(app)/admin/charity-commission/filter-builder.tsx"
    },
    "credits": []
  },
  "type": "registry:component"
}
