{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"range-slider-bubble","type":"registry:component","title":"Range Slider Bubble Slider","description":"Grab the thumb and a value bubble pops out of it. The bubble tilts and squashes with how fast you drag, then settles upright.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/range-slider-bubble.tsx","type":"registry:component","target":"@components/motion/range-slider-bubble.tsx","content":"\"use client\";\n// beui.dev/components/motion/range-slider\n\nimport {\n  AnimatePresence,\n  motion,\n  useMotionTemplate,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n  useVelocity,\n} from \"motion/react\";\nimport { useEffect } from \"react\";\n\nimport { SPRING_GLIDE, SPRING_PANEL, SPRING_PRESS } from \"@/lib/ease\";\nimport { type SliderOptions, useSlider } from \"@/lib/hooks/use-slider\";\nimport { cn } from \"@/lib/utils\";\n\n// Loose enough that the bubble keeps leaning a beat after the pointer stops.\nconst SPRING_TILT = { stiffness: 260, damping: 22, mass: 0.4 } as const;\n/** Drag speed (px/s of track percent) that maxes out lean and squash. */\nconst FULL_TILT = 320;\n\nexport interface BubbleSliderProps extends SliderOptions {\n  /** Formats the value shown in the bubble. */\n  format?: (value: number) => string;\n  className?: string;\n}\n\n/**\n * Slider with a value bubble that pops out of the thumb on grab and reacts to\n * how fast you drag: it leans into the direction of travel and squashes along\n * the way, then settles upright when you let go.\n */\nexport function BubbleSlider({ format, className, ...options }: BubbleSliderProps) {\n  const reduce = useReducedMotion();\n  // A bare number needs no valueText — it would only repeat aria-valuenow.\n  const { percent, current, dragging, trackProps, sliderProps } = useSlider({\n    ...options,\n    formatValueText: options.formatValueText ?? format,\n  });\n  // The value is already snapped to the step — rounding here would only make\n  // the bubble disagree with aria-valuenow on a fractional scale.\n  const readout = format ? format(current) : current;\n\n  const target = useMotionValue(percent);\n  useEffect(() => {\n    target.set(percent);\n  }, [percent, target]);\n  const smooth = useSpring(target, SPRING_GLIDE);\n  const pos = reduce ? target : smooth;\n  const left = useMotionTemplate`${pos}%`;\n\n  // One spring drives the whole reaction: lean is signed, squash reads its\n  // magnitude. Two springs off the same velocity would just run twice.\n  const velocity = useVelocity(pos);\n  const lean = useSpring(\n    useTransform(velocity, [-FULL_TILT, 0, FULL_TILT], [1, 0, -1], { clamp: true }),\n    SPRING_TILT,\n  );\n  const tilt = useTransform(lean, (v) => v * 16);\n  const squash = useTransform(lean, (v) => 1 + Math.abs(v) * 0.18);\n  const stretch = useTransform(lean, (v) => 1 - Math.abs(v) * 0.12);\n\n  return (\n    <div\n      className={cn(\n        // px/pb leave room for the thumb, the bubble and the 48px hit area to\n        // overhang the 8px track without escaping the component's own box\n        \"relative flex h-20 w-full items-end px-5 pb-5\",\n        options.disabled ? \"pointer-events-none opacity-50\" : undefined,\n        className,\n      )}\n    >\n      <div\n        {...trackProps}\n        className={cn(\n          \"relative h-2 w-full touch-none select-none rounded-full bg-muted\",\n          options.disabled ? undefined : \"cursor-grab active:cursor-grabbing\",\n        )}\n      >\n        <motion.div\n          className=\"absolute inset-y-0 left-0 rounded-full bg-foreground\"\n          style={{ width: left }}\n        />\n\n        {/* thumb — overhangs the track by half its width at both ends, which the\n            wrapper's padding leaves room for */}\n        <motion.div\n          className=\"absolute top-1/2 size-5 rounded-full border-2 border-foreground bg-background shadow-sm\"\n          style={{ left, x: \"-50%\", y: \"-50%\" }}\n          animate={reduce ? undefined : { scale: dragging ? 1.25 : 1 }}\n          transition={SPRING_PRESS}\n        />\n\n        {/* bubble — anchored to the thumb, leaning with drag velocity */}\n        <motion.div\n          className=\"pointer-events-none absolute bottom-6\"\n          style={{ left, x: \"-50%\" }}\n        >\n          <AnimatePresence>\n            {dragging ? (\n              <motion.div\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, y: 10 }}\n                animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}\n                exit={\n                  reduce\n                    ? { opacity: 0, transition: { duration: 0.12 } }\n                    : { opacity: 0, scale: 0.5, y: 8, transition: { duration: 0.12 } }\n                }\n                transition={reduce ? { duration: 0.12 } : SPRING_PANEL}\n                style={\n                  reduce\n                    ? undefined\n                    : { rotate: tilt, scaleX: squash, scaleY: stretch, originY: 1 }\n                }\n                className=\"relative rounded-xl bg-foreground px-2.5 py-1 text-sm font-medium tabular-nums text-background shadow-md\"\n              >\n                {readout}\n                <span className=\"absolute -bottom-1 left-1/2 size-2.5 -translate-x-1/2 rotate-45 rounded-[3px] bg-foreground\" />\n              </motion.div>\n            ) : null}\n          </AnimatePresence>\n        </motion.div>\n\n        {/* 8px of track is not a touch target — pad the hit area out to 48px */}\n        <button\n          type=\"button\"\n          {...sliderProps}\n          className=\"absolute -inset-y-5 inset-x-0 touch-none rounded-full outline-none ring-foreground/30 focus-visible:ring-4\"\n        />\n      </div>\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/hooks/use-slider.ts","type":"registry:hook","target":"@lib/hooks/use-slider.ts","content":"\"use client\";\n\nimport { type KeyboardEvent, type PointerEvent, useCallback, useRef, useState } from \"react\";\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\n/** Nearest legal value on [min, max] for the given step. max counts as a\n * candidate when the step does not divide the range, so a pointer near the end\n * does not snap back onto the last whole step. */\nexport function snapSliderValue(next: number, min: number, max: number, step: number): number {\n  // Neither case has a grid to walk. An empty range has exactly one legal\n  // point, and a non-positive step only needs a clamp, which also keeps the\n  // division below away from zero.\n  if (!(max > min)) return min;\n  if (!(step > 0)) return clamp(next, min, max);\n  const whole = Math.floor(Number(((max - min) / step).toFixed(6)));\n  const lastWhole = Number((min + whole * step).toFixed(6));\n  const toGrid = clamp(Math.round((next - min) / step) * step + min, min, lastWhole);\n  const snapped =\n    lastWhole < max && Math.abs(next - max) <= Math.abs(next - toGrid) ? max : toGrid;\n  return Number(snapped.toFixed(6));\n}\n\nexport interface SliderOptions {\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  disabled?: boolean;\n  \"aria-label\"?: string;\n  /** Announced instead of the raw number — pass one when the value carries a\n   * unit or a suffix (\"72.5 kg\", \"35%\"); a bare number needs no valueText. */\n  formatValueText?: (value: number) => string;\n}\n\n/**\n * Shared value + input plumbing for slider designs: controlled/uncontrolled\n * value, step snapping, pointer-capture drag along a track and arrow-key\n * control. Visuals and motion live in the component; this only owns the number.\n */\nexport function useSlider({\n  value,\n  defaultValue = 0,\n  onValueChange,\n  min = 0,\n  max = 100,\n  step = 1,\n  disabled = false,\n  \"aria-label\": ariaLabel,\n  formatValueText,\n}: SliderOptions) {\n  const trackRef = useRef<HTMLDivElement>(null);\n  const sliderEl = useRef<HTMLElement | null>(null);\n  // The state drives visuals. Move reads this ref instead, so the first\n  // pointermove after pointerdown does not have to wait on a re-render.\n  const draggingRef = useRef(false);\n  const [internal, setInternal] = useState(defaultValue);\n  const [dragging, setDragging] = useState(false);\n  const controlled = value !== undefined;\n  // Collapse inverted or empty ranges and non-positive steps here, so that\n  // percent, ticks and the keyboard maths never divide by zero or walk a\n  // NaN grid.\n  const lo = min;\n  const hi = max > min ? max : min;\n  const stride = step > 0 ? step : 1;\n  const current = clamp(controlled ? value : internal, lo, hi);\n  const percent = hi > lo ? ((current - lo) / (hi - lo)) * 100 : 0;\n\n  const commit = useCallback(\n    (next: number) => {\n      const clean = snapSliderValue(next, lo, hi, stride);\n      if (!controlled) setInternal(clean);\n      onValueChange?.(clean);\n    },\n    [controlled, onValueChange, lo, hi, stride],\n  );\n\n  const commitFromX = useCallback(\n    (clientX: number) => {\n      const rect = trackRef.current?.getBoundingClientRect();\n      if (!rect || rect.width === 0) return;\n      const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);\n      commit(lo + ratio * (hi - lo));\n    },\n    [commit, lo, hi],\n  );\n\n  const onPointerDown = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (disabled) return;\n      // optional: test DOMs and older browsers omit pointer capture\n      event.currentTarget.setPointerCapture?.(event.pointerId);\n      draggingRef.current = true;\n      setDragging(true);\n      // A click on the track should land keyboard focus on the handle.\n      sliderEl.current?.focus({ preventScroll: true });\n      commitFromX(event.clientX);\n    },\n    [disabled, commitFromX],\n  );\n\n  const onPointerMove = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (!draggingRef.current || disabled) return;\n      commitFromX(event.clientX);\n    },\n    [disabled, commitFromX],\n  );\n\n  const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {\n    // Releasing without capture throws. The other pointer hooks guard it the\n    // same way.\n    if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId);\n    }\n    draggingRef.current = false;\n    setDragging(false);\n  }, []);\n\n  const onKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLElement>) => {\n      if (disabled) return;\n      const map: Record<string, number> = {\n        ArrowRight: current + stride,\n        ArrowUp: current + stride,\n        ArrowLeft: current - stride,\n        ArrowDown: current - stride,\n        PageUp: current + stride * 10,\n        PageDown: current - stride * 10,\n        Home: lo,\n        End: hi,\n      };\n      if (event.key in map) {\n        event.preventDefault();\n        commit(map[event.key]);\n      }\n    },\n    [disabled, current, stride, lo, hi, commit],\n  );\n\n  return {\n    current,\n    percent,\n    dragging,\n    min: lo,\n    max: hi,\n    step: stride,\n    commit,\n    /** Pointer handlers for the track element — drag anywhere on it. */\n    trackProps: {\n      ref: trackRef,\n      onPointerDown,\n      onPointerMove,\n      onPointerUp: endDrag,\n      onPointerCancel: endDrag,\n      onLostPointerCapture: endDrag,\n    },\n    /** ARIA + keyboard props for the focusable slider element. */\n    sliderProps: {\n      // Callback keeps the handle typed across button/div/motion hosts.\n      ref: (node: HTMLElement | null) => {\n        sliderEl.current = node;\n      },\n      role: \"slider\" as const,\n      tabIndex: disabled ? -1 : 0,\n      \"aria-label\": ariaLabel,\n      \"aria-valuemin\": lo,\n      \"aria-valuemax\": hi,\n      \"aria-valuenow\": current,\n      \"aria-valuetext\": formatValueText?.(current),\n      \"aria-disabled\": disabled || undefined,\n      onKeyDown,\n    },\n  };\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}