{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "price-range",
  "title": "Price Range",
  "description": "A two-handle budget slider with springy handles, sliding labels and lit-up ticks.",
  "dependencies": [
    "motion",
    "class-variance-authority"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/clover-ui/price-range.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\n\nconst priceRangeVariants = cva(\"w-full max-w-md px-6 select-none\", {\n  variants: {\n    color: {\n      default: \"text-primary\",\n      indigo: \"text-indigo-500\",\n      blue: \"text-blue-500\",\n      emerald: \"text-emerald-500\",\n      rose: \"text-rose-500\",\n      amber: \"text-amber-500\",\n    },\n  },\n  defaultVariants: {\n    color: \"default\",\n  },\n});\n\nconst knobVariants = cva(\n  [\n    \"absolute top-8 -translate-x-1/2 -translate-y-1/2 outline-none\",\n    \"focus-visible:ring-ring focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-2\",\n    \"before:absolute before:-inset-2.5 before:content-['']\",\n    \"disabled:after:bg-background/30 disabled:after:pointer-events-none disabled:after:absolute disabled:after:rounded-[inherit] disabled:after:content-['']\",\n  ],\n  {\n    variants: {\n      knob: {\n        circle:\n          \"bg-background dark:bg-foreground ring-border size-5 rounded-full shadow-md ring-1 shadow-black/15 disabled:after:inset-0\",\n        ring: \"bg-background size-5 rounded-full border-[3px] border-current shadow-md shadow-black/15 disabled:after:-inset-[3px]\",\n        pill: \"bg-background dark:bg-foreground ring-border h-6 w-3 rounded-full shadow-md ring-1 shadow-black/15 disabled:after:inset-0\",\n        line: \"h-5 w-1 rounded-full bg-current disabled:after:inset-0\",\n      },\n    },\n    defaultVariants: {\n      knob: \"circle\",\n    },\n  },\n);\n\nexport type PriceRangeKnob = NonNullable<VariantProps<typeof knobVariants>[\"knob\"]>;\n\nexport type PriceRangeColor = NonNullable<VariantProps<typeof priceRangeVariants>[\"color\"]>;\n\nexport type PriceRangeValue = [number, number];\n\nexport interface PriceRangeProps extends Omit<\n  React.ComponentProps<\"div\">,\n  \"defaultValue\" | \"onChange\" | \"color\" | \"children\"\n> {\n  value?: PriceRangeValue;\n  defaultValue?: PriceRangeValue;\n  onValueChange?: (value: PriceRangeValue) => void;\n  min?: number;\n  max?: number;\n  currency?: string;\n  formatValue?: (value: number) => string;\n  ticks?: number;\n  showTicks?: boolean;\n  color?: PriceRangeColor;\n  knob?: PriceRangeKnob;\n  disabled?: boolean;\n}\n\nconst spring = { stiffness: 520, damping: 42, mass: 0.5 };\n\nfunction clamp(value: number, low: number, high: number) {\n  return Math.min(high, Math.max(low, value));\n}\n\nfunction niceStep(span: number) {\n  const rough = span / 200;\n  const magnitude = 10 ** Math.floor(Math.log10(rough));\n  const ratio = rough / magnitude;\n  const nice = ratio <= 1 ? 1 : ratio <= 2 ? 2 : ratio <= 5 ? 5 : 10;\n  return nice * magnitude;\n}\n\nconst PriceRange = ({\n  value,\n  defaultValue,\n  onValueChange,\n  min = 0,\n  max = 200000,\n  currency,\n  formatValue,\n  ticks = 25,\n  showTicks = true,\n  color,\n  knob = \"circle\",\n  disabled = false,\n  className,\n  ...props\n}: PriceRangeProps) => {\n  const reduceMotion = useReducedMotion();\n  const areaRef = React.useRef<HTMLDivElement>(null);\n  const thumbRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const [internal, setInternal] = React.useState<PriceRangeValue>(\n    defaultValue ?? [min + (max - min) * 0.15, min + (max - min) * 0.6],\n  );\n  const [active, setActive] = React.useState<0 | 1>(1);\n  const [dragging, setDragging] = React.useState(false);\n  const draggingRef = React.useRef(false);\n  const followRef = React.useRef(false);\n\n  const span = Math.max(max - min, Number.EPSILON);\n  const step = niceStep(span);\n  const decimals = Math.max(0, -Math.floor(Math.log10(step)));\n  const raw = value ?? internal;\n  const low = clamp(Math.min(raw[0], raw[1]), min, max);\n  const high = clamp(Math.max(raw[0], raw[1]), min, max);\n  const lowPct = ((low - min) / span) * 100;\n  const highPct = ((high - min) / span) * 100;\n\n  const format = React.useMemo(() => {\n    if (formatValue) return formatValue;\n    const formatter = new Intl.NumberFormat(\"en\", {\n      notation: \"compact\",\n      maximumFractionDigits: 1,\n      ...(currency ? { style: \"currency\" as const, currency } : {}),\n    });\n    return (n: number) => formatter.format(n);\n  }, [formatValue, currency]);\n\n  const lowSpring = useSpring(lowPct, spring);\n  const highSpring = useSpring(highPct, spring);\n\n  React.useEffect(() => {\n    if (followRef.current) return;\n    if (reduceMotion) {\n      lowSpring.jump(lowPct);\n      highSpring.jump(highPct);\n    } else {\n      lowSpring.set(lowPct);\n      highSpring.set(highPct);\n    }\n  }, [lowPct, highPct, reduceMotion, lowSpring, highSpring]);\n\n  const lowLeft = useTransform(lowSpring, (v) => `${v}%`);\n  const highLeft = useTransform(highSpring, (v) => `${v}%`);\n  const fillWidth = useTransform(() => `${Math.max(0, highSpring.get() - lowSpring.get())}%`);\n  const closeness = useTransform(() => clamp(1 - (highSpring.get() - lowSpring.get()) / 16, 0, 1));\n  const lowLabelX = useTransform(closeness, (c) => `${-50 - 50 * c}%`);\n  const highLabelX = useTransform(closeness, (c) => `${-50 + 50 * c}%`);\n\n  const commit = (next: PriceRangeValue) => {\n    if (next[0] === low && next[1] === high) return;\n    if (value === undefined) setInternal(next);\n    onValueChange?.(next);\n  };\n\n  const toPct = (n: number) => ((n - min) / span) * 100;\n\n  const moveThumb = (index: 0 | 1, target: number) => {\n    const bounded = index === 0 ? clamp(target, min, high) : clamp(target, low, max);\n    const snapped = Number((Math.round((bounded - min) / step) * step + min).toFixed(decimals));\n    const next: PriceRangeValue =\n      index === 0 ? [clamp(snapped, min, high), high] : [low, clamp(snapped, low, max)];\n    if (followRef.current) (index === 0 ? lowSpring : highSpring).jump(toPct(bounded));\n    commit(next);\n  };\n\n  const valueAt = (clientX: number) => {\n    const rect = areaRef.current?.getBoundingClientRect();\n    if (!rect || rect.width === 0) return low;\n    return min + clamp((clientX - rect.left) / rect.width, 0, 1) * span;\n  };\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (disabled || event.button !== 0) return;\n    event.preventDefault();\n    const target = valueAt(event.clientX);\n    const index: 0 | 1 =\n      low === high\n        ? target <= low\n          ? 0\n          : 1\n        : Math.abs(target - low) <= Math.abs(target - high)\n          ? 0\n          : 1;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    followRef.current = false;\n    draggingRef.current = true;\n    setActive(index);\n    setDragging(true);\n    thumbRefs.current[index]?.focus({ preventScroll: true });\n    moveThumb(index, target);\n  };\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (!draggingRef.current) return;\n    followRef.current = true;\n    moveThumb(active, valueAt(event.clientX));\n  };\n\n  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {\n    draggingRef.current = false;\n    if (followRef.current) {\n      followRef.current = false;\n      const settle = reduceMotion ? \"jump\" : \"set\";\n      lowSpring[settle](lowPct);\n      highSpring[settle](highPct);\n    }\n    setDragging(false);\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId);\n    }\n  };\n\n  const handleKeyDown = (index: 0 | 1) => (event: React.KeyboardEvent<HTMLButtonElement>) => {\n    const current = index === 0 ? low : high;\n    const big = Math.max(step, Math.round(span / 10 / step) * step);\n    const moves: Record<string, number> = {\n      ArrowRight: current + step,\n      ArrowUp: current + step,\n      ArrowLeft: current - step,\n      ArrowDown: current - step,\n      PageUp: current + big,\n      PageDown: current - big,\n      Home: min,\n      End: max,\n    };\n    if (!(event.key in moves)) return;\n    event.preventDefault();\n    setActive(index);\n    moveThumb(index, moves[event.key]);\n  };\n\n  const thumbs = [\n    { value: low, left: lowLeft, labelX: lowLabelX },\n    { value: high, left: highLeft, labelX: highLabelX },\n  ] as const;\n\n  const tickCount = Math.max(2, Math.round(ticks));\n\n  return (\n    <div\n      role=\"group\"\n      aria-disabled={disabled || undefined}\n      className={cn(priceRangeVariants({ color }), className)}\n      {...props}\n    >\n      <div\n        ref={areaRef}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        className={cn(\n          \"relative h-16 touch-none\",\n          disabled ? \"pointer-events-none\" : \"cursor-pointer\",\n        )}\n      >\n        {thumbs.map((thumb, index) => (\n          <motion.span\n            key={`label-${index}`}\n            aria-hidden\n            className=\"text-foreground pointer-events-none absolute top-0 text-xs font-medium whitespace-nowrap tabular-nums\"\n            style={{ left: thumb.left, x: thumb.labelX }}\n            animate={{ y: dragging && active === index ? -3 : 0 }}\n            transition={reduceMotion ? { duration: 0 } : { type: \"spring\", ...spring }}\n          >\n            {format(thumb.value)}\n          </motion.span>\n        ))}\n\n        <div className=\"bg-foreground/10 absolute inset-x-0 top-[29px] h-1.5 rounded-full\">\n          <motion.div\n            className=\"absolute inset-y-0 rounded-full bg-current\"\n            style={{ left: lowLeft, width: fillWidth }}\n          />\n        </div>\n\n        {thumbs.map((thumb, index) => (\n          <motion.button\n            key={`thumb-${index}`}\n            ref={(node) => {\n              thumbRefs.current[index] = node;\n            }}\n            type=\"button\"\n            role=\"slider\"\n            disabled={disabled}\n            aria-label={index === 0 ? \"Minimum price\" : \"Maximum price\"}\n            aria-valuemin={index === 0 ? min : low}\n            aria-valuemax={index === 0 ? high : max}\n            aria-valuenow={thumb.value}\n            aria-valuetext={format(thumb.value)}\n            onKeyDown={handleKeyDown(index as 0 | 1)}\n            onFocus={() => setActive(index as 0 | 1)}\n            className={cn(knobVariants({ knob }), active === index ? \"z-20\" : \"z-10\")}\n            style={{ left: thumb.left }}\n            animate={{ scale: dragging && active === index ? 1.2 : 1 }}\n            transition={reduceMotion ? { duration: 0 } : { type: \"spring\", ...spring }}\n          />\n        ))}\n\n        {showTicks && (\n          <div aria-hidden className=\"absolute inset-x-0 bottom-1 flex items-start justify-between\">\n            {Array.from({ length: tickCount }, (_, i) => {\n              const pct = (i / (tickCount - 1)) * 100;\n              const inside = pct >= lowPct - 0.001 && pct <= highPct + 0.001;\n              return (\n                <span\n                  key={i}\n                  className={cn(\n                    \"w-px rounded-full transition-[height,background-color] duration-300 motion-reduce:transition-none\",\n                    inside ? \"bg-muted-foreground h-2.5\" : \"bg-muted-foreground/35 h-2\",\n                  )}\n                />\n              );\n            })}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n};\n\nPriceRange.displayName = \"PriceRange\";\n\nexport { PriceRange, priceRangeVariants, knobVariants };\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}