{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "slider", "title": "Slider (Base UI)", "description": "Animated slider with spring-snapped thumb, step dots, range mode, and click-to-edit value display. Base UI flavor.", "dependencies": [ "framer-motion", "@base-ui/react" ], "registryDependencies": [ "https://zeron-ui.vercel.app/r/surfaces.json", "utils", "https://zeron-ui.vercel.app/r/springs.json", "https://zeron-ui.vercel.app/r/shape-context.json" ], "files": [ { "path": "src/components/ui/slider.tsx", "content": "\"use client\";\n\nimport {\n forwardRef,\n useRef,\n useState,\n useEffect,\n useLayoutEffect,\n useCallback,\n useMemo,\n type CSSProperties,\n type HTMLAttributes,\n} from \"react\";\nimport {\n motion,\n useMotionValue,\n useTransform,\n animate,\n AnimatePresence,\n type MotionValue,\n} from \"framer-motion\";\nimport { Slider as SliderPrimitive } from \"@base-ui/react/slider\";\nimport { cn } from \"@/lib/utils\";\nimport { spring } from \"@/lib/springs\";\nimport { useShape } from \"@/lib/shape-context\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype SliderValue = number | [number, number];\ntype ValuePosition = \"left\" | \"right\" | \"top\" | \"bottom\" | \"tooltip\";\n\ninterface SliderProps\n extends Omit, \"onChange\" | \"defaultValue\"> {\n value: SliderValue;\n onChange: (value: SliderValue) => void;\n min?: number;\n max?: number;\n step?: number;\n /**\n * Discrete list of allowed values, e.g. [0.1, 0.5, 0.7, 1.1, 1.3].\n *\n * When set, the thumb snaps only to these values (positioned proportionally\n * along the track) and arrow keys walk the list. `min`/`max` derive from the\n * list's extremes and `step` is ignored.\n */\n steps?: number[];\n showSteps?: boolean;\n showValue?: boolean;\n valuePosition?: ValuePosition;\n formatValue?: (v: number) => string;\n label?: string;\n disabled?: boolean;\n trackClassName?: string;\n trackStyle?: CSSProperties;\n fillClassName?: string;\n fillStyle?: CSSProperties;\n hideFill?: boolean;\n thumbColor?: string;\n thumbBorderColor?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst THUMB_SIZE = 20;\nconst THUMB_SIZE_REST = 16;\nconst TRACK_BG_HEIGHT = 18;\nconst DOT_SIZE = 4;\nconst PIP_SIZE = 5;\n// Inset track BG so its rounded-end centers align with thumb centers at min/max\nconst TRACK_INSET = (THUMB_SIZE - TRACK_BG_HEIGHT) / 2;\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction valueToPixel(\n v: number,\n min: number,\n max: number,\n trackWidth: number\n): number {\n if (max === min) return 0;\n const usable = trackWidth - THUMB_SIZE;\n return ((v - min) / (max - min)) * usable;\n}\n\nfunction nearestStepIndex(v: number, steps: number[]): number {\n let idx = 0;\n for (let i = 1; i < steps.length; i++) {\n if (Math.abs(steps[i] - v) < Math.abs(steps[idx] - v)) idx = i;\n }\n return idx;\n}\n\nfunction pixelToValue(\n px: number,\n min: number,\n max: number,\n step: number,\n trackWidth: number,\n stepValues: number[] | null = null\n): number {\n const usable = trackWidth - THUMB_SIZE;\n if (usable <= 0) return min;\n const raw = (px / usable) * (max - min) + min;\n if (stepValues) return stepValues[nearestStepIndex(raw, stepValues)];\n const snapped = Math.round((raw - min) / step) * step + min;\n return Math.max(min, Math.min(max, snapped));\n}\n\nfunction toPrimitiveValue(value: SliderValue): number[] {\n return Array.isArray(value) ? value : [value];\n}\n\n// ---------------------------------------------------------------------------\n// ValueDisplay (internal)\n// ---------------------------------------------------------------------------\n\ninterface ValueDisplayProps {\n values: number[];\n editingIndex: number | null;\n onStartEdit: (index: number) => void;\n onCommitEdit: (index: number, v: number) => void;\n onCancelEdit: () => void;\n min: number;\n max: number;\n step: number;\n stepValues: number[] | null;\n formatValue: (v: number) => string;\n label?: string;\n isRange: boolean;\n isInteracting: boolean;\n}\n\nfunction ValueDisplay({\n values,\n editingIndex,\n onStartEdit,\n onCommitEdit,\n onCancelEdit,\n min,\n max,\n step,\n stepValues,\n formatValue,\n label,\n isRange,\n isInteracting,\n}: ValueDisplayProps) {\n const shape = useShape();\n const [inputValue, setInputValue] = useState(\"\");\n const inputRef = useRef(null);\n\n useEffect(() => {\n if (editingIndex !== null) {\n setInputValue(String(values[editingIndex]));\n requestAnimationFrame(() => inputRef.current?.select());\n }\n }, [editingIndex]);\n\n const commitEdit = useCallback(\n (index: number) => {\n const parsed = parseFloat(inputValue);\n if (!isNaN(parsed)) {\n const clamped = Math.max(min, Math.min(max, parsed));\n const snapped = stepValues\n ? stepValues[nearestStepIndex(clamped, stepValues)]\n : Math.round((clamped - min) / step) * step + min;\n onCommitEdit(index, snapped);\n } else {\n onCancelEdit();\n }\n },\n [inputValue, min, max, step, stepValues, onCommitEdit, onCancelEdit]\n );\n\n const renderValue = (index: number) => {\n if (editingIndex === index) {\n return (\n \n {/* Ghost for layout stability — widest possible value */}\n \n {label ? `${label}: ` : \"\"}\n {formatValue(max)}\n \n \n {label && (\n {label}:\n )}\n setInputValue(e.target.value)}\n onBlur={() => commitEdit(index)}\n onKeyDown={(e) => {\n if (e.key === \"Enter\") commitEdit(index);\n if (e.key === \"Escape\") onCancelEdit();\n }}\n aria-label={`Edit slider value${isRange ? (index === 0 ? \" (start)\" : \" (end)\") : \"\"}`}\n className={cn(\n \"w-[5ch] bg-transparent text-fg-default outline-none border-b border-border text-center\",\n shape.input,\n \"font-medium\"\n )}\n />\n \n \n );\n }\n\n return (\n onStartEdit(index)}\n >\n {formatValue(values[index])}\n \n );\n };\n\n\n const widestValue = isRange\n ? `${label ? `${label}: ` : \"\"}${formatValue(max)} — ${formatValue(max)}`\n : `${label ? `${label}: ` : \"\"}${formatValue(max)}`;\n\n return (\n \n {/* Invisible ghost — reserves width of widest possible value */}\n \n {widestValue}\n \n \n {label && editingIndex === null && (\n {label}: \n )}\n {isRange ? (\n <>\n {renderValue(0)}\n \n {renderValue(1)}\n \n ) : (\n renderValue(0)\n )}\n \n \n );\n}\n\n// ---------------------------------------------------------------------------\n// TooltipValue (internal)\n// ---------------------------------------------------------------------------\n\ninterface TooltipValueProps {\n value: number;\n formatValue: (v: number) => string;\n motionX: MotionValue;\n}\n\nfunction TooltipValue({ value, formatValue, motionX }: TooltipValueProps) {\n const shape = useShape();\n const tooltipX = useTransform(motionX, (x) => x + THUMB_SIZE / 2);\n return (\n \n \n {formatValue(value)}\n \n \n );\n}\n\n// ---------------------------------------------------------------------------\n// Slider\n// ---------------------------------------------------------------------------\n\nconst Slider = forwardRef(\n (\n {\n value,\n onChange,\n min: minProp = 0,\n max: maxProp = 100,\n step = 1,\n steps,\n showSteps = false,\n showValue = true,\n valuePosition = \"left\",\n formatValue = String,\n label,\n disabled = false,\n trackClassName,\n trackStyle,\n fillClassName,\n fillStyle,\n hideFill = false,\n thumbColor,\n thumbBorderColor,\n className,\n ...props\n },\n ref\n ) => {\n const isRange = Array.isArray(value);\n const values = toPrimitiveValue(value);\n const shape = useShape();\n\n // Non-uniform step mode: sorted, deduped list of allowed values. Keyed on\n // the joined string so inline array literals don't recompute every render.\n const stepsKey = steps ? steps.join(\",\") : \"\";\n const stepValues = useMemo(() => {\n if (!stepsKey) return null;\n const parsed = Array.from(new Set(stepsKey.split(\",\").map(Number))).sort(\n (a, b) => a - b\n );\n return parsed.length > 1 ? parsed : null;\n }, [stepsKey]);\n const min = stepValues ? stepValues[0] : minProp;\n const max = stepValues ? stepValues[stepValues.length - 1] : maxProp;\n\n // --- Refs ---\n const trackRef = useRef(null);\n const trackWidthRef = useRef(0);\n const dragging = useRef(false);\n const activeDragThumb = useRef(0);\n const valuesRef = useRef(values);\n const minRef = useRef(min);\n const maxRef = useRef(max);\n valuesRef.current = values;\n minRef.current = min;\n maxRef.current = max;\n\n // --- State ---\n const [isHovered, setIsHovered] = useState(false);\n const [isPressed, setIsPressed] = useState(false);\n const [editingIndex, setEditingIndex] = useState(null);\n const [hoverPreview, setHoverPreview] = useState<{\n left: number;\n width: number;\n snappedValue: number;\n cursorX: number;\n } | null>(null);\n const [focusedThumb, setFocusedThumb] = useState(null);\n const [showHoverTooltip, setShowHoverTooltip] = useState(false);\n const hoverDelayRef = useRef | null>(null);\n\n // Show hover tooltip after 100ms delay\n useEffect(() => {\n if (isHovered) {\n hoverDelayRef.current = setTimeout(() => setShowHoverTooltip(true), 100);\n } else {\n if (hoverDelayRef.current) clearTimeout(hoverDelayRef.current);\n setShowHoverTooltip(false);\n }\n return () => { if (hoverDelayRef.current) clearTimeout(hoverDelayRef.current); };\n }, [isHovered]);\n\n // --- Motion values ---\n const motionX0 = useMotionValue(0);\n const motionX1 = useMotionValue(0);\n\n // --- Derived motion values for fill ---\n const fillLeft = useTransform(motionX0, (x) =>\n isRange ? x + THUMB_SIZE / 2 - TRACK_INSET : 0\n );\n const fillWidthSingle = useTransform(motionX0, (x) => x + THUMB_SIZE / 2 - TRACK_INSET);\n const fillWidthRange = useTransform(\n [motionX0, motionX1] as MotionValue[],\n ([x0, x1]) => (x1 as number) - (x0 as number)\n );\n const fillWidth = isRange ? fillWidthRange : fillWidthSingle;\n\n // --- Step dots mask (hides dots on filled side, like SliderComfortable pips) ---\n const stepDotsMaskSingle = useTransform(\n motionX0,\n (x) => {\n const edge = x + THUMB_SIZE / 2;\n return `linear-gradient(to right, transparent ${edge}px, black ${edge + 2}px)`;\n }\n );\n const stepDotsMaskRange = useTransform(\n [motionX0, motionX1] as MotionValue[],\n ([x0, x1]) => {\n const left = (x0 as number) + THUMB_SIZE / 2;\n const right = (x1 as number) + THUMB_SIZE / 2;\n return `linear-gradient(to right, black ${left - 2}px, transparent ${left}px, transparent ${right}px, black ${right + 2}px)`;\n }\n );\n const stepDotsMask = isRange ? stepDotsMaskRange : stepDotsMaskSingle;\n\n // --- Hover preview computation ---\n const computeHoverPreview = useCallback(\n (cursorX: number, trackWidth: number) => {\n // cursorX and trackWidth are in layout space (offsetWidth-relative),\n // unaffected by ancestor CSS transforms. THUMB_SIZE / TRACK_INSET are\n // also layout-space, so the math below is consistent end-to-end.\n const usable = trackWidth - THUMB_SIZE;\n const rawPx = cursorX - THUMB_SIZE / 2;\n const clampedPx = Math.max(0, Math.min(usable, rawPx));\n const rawVal = usable > 0 ? (clampedPx / usable) * (max - min) + min : min;\n const snappedVal = stepValues\n ? stepValues[nearestStepIndex(rawVal, stepValues)]\n : Math.max(\n min,\n Math.min(max, Math.round((rawVal - min) / step) * step + min)\n );\n const snappedPercent = max === min ? 0 : (snappedVal - min) / (max - min);\n const snappedX = THUMB_SIZE / 2 + snappedPercent * usable;\n\n // Find nearest thumb center\n const c0 = motionX0.get() + THUMB_SIZE / 2;\n const c1 = motionX1.get() + THUMB_SIZE / 2;\n const nearestIdx = isRange\n ? (Math.abs(snappedX - c0) <= Math.abs(snappedX - c1) ? 0 : 1)\n : 0;\n const nearest = nearestIdx === 0 ? c0 : c1;\n\n // Extend hover bar to track edges at extremes so there's no gap\n const edgeX = snappedVal === min ? 0 : snappedVal === max ? trackWidth : snappedX;\n const left = Math.min(nearest, edgeX);\n const width = Math.abs(edgeX - nearest);\n setHoverPreview({ left, width, snappedValue: snappedVal, cursorX: snappedX });\n },\n [min, max, step, stepValues, isRange, motionX0, motionX1]\n );\n\n // --- Initial sync (before paint) ---\n const initialSyncDone = useRef(false);\n const [ready, setReady] = useState(false);\n useLayoutEffect(() => {\n const el = trackRef.current;\n if (!el || initialSyncDone.current) return;\n const w = el.offsetWidth;\n trackWidthRef.current = w;\n const px0 = valueToPixel(values[0], min, max, w);\n motionX0.set(px0);\n if (isRange && values[1] !== undefined) {\n const px1 = valueToPixel(values[1], min, max, w);\n motionX1.set(px1);\n }\n initialSyncDone.current = true;\n setReady(true);\n }, []);\n\n // --- Track width measurement (resize only) ---\n useEffect(() => {\n const el = trackRef.current;\n if (!el) return;\n const ro = new ResizeObserver(([entry]) => {\n const w = entry.contentRect.width;\n trackWidthRef.current = w;\n if (!dragging.current && initialSyncDone.current) {\n const v = valuesRef.current;\n const mn = minRef.current;\n const mx = maxRef.current;\n const px0 = valueToPixel(v[0], mn, mx, w);\n animate(motionX0, px0, spring.moderate);\n if (isRange && v[1] !== undefined) {\n const px1 = valueToPixel(v[1], mn, mx, w);\n animate(motionX1, px1, spring.moderate);\n }\n }\n });\n ro.observe(el);\n return () => ro.disconnect();\n }, [isRange, motionX0, motionX1]);\n\n // --- Sync motion values on value change (keyboard, programmatic) ---\n // Depend on a primitive key rather than the `values` array — its identity\n // changes every render (toPrimitiveValue allocates), which would restart the\n // animation on unrelated re-renders (hover/tooltip state churn).\n const valuesKey = values.join(\",\");\n useEffect(() => {\n if (!initialSyncDone.current) return;\n if (dragging.current) return;\n const tw = trackWidthRef.current;\n if (tw <= 0) return;\n const v = valuesRef.current;\n const px0 = valueToPixel(v[0], min, max, tw);\n animate(motionX0, px0, spring.moderate);\n if (isRange && v[1] !== undefined) {\n const px1 = valueToPixel(v[1], min, max, tw);\n animate(motionX1, px1, spring.moderate);\n }\n }, [valuesKey, min, max, isRange, motionX0, motionX1]);\n\n // --- Range crossing prevention ---\n const clampForRange = useCallback(\n (px: number, thumbIndex: number): number => {\n if (!isRange) return px;\n if (thumbIndex === 0) {\n return Math.min(px, motionX1.get() - THUMB_SIZE * 0.5);\n } else {\n return Math.max(px, motionX0.get() + THUMB_SIZE * 0.5);\n }\n },\n [isRange, motionX0, motionX1]\n );\n\n // --- Emit value change ---\n const emitChange = useCallback(\n (thumbIndex: number, newValue: number) => {\n if (isRange) {\n const newValues: [number, number] = [...(values as [number, number])];\n newValues[thumbIndex] = newValue;\n onChange(newValues);\n } else {\n onChange(newValue);\n }\n },\n [isRange, values, onChange]\n );\n\n // --- Pointer handlers on track ---\n const handlePointerDown = useCallback(\n (e: React.PointerEvent) => {\n if (disabled) return;\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n e.preventDefault();\n e.stopPropagation(); // Prevent Primitive from also handling the drag\n\n const trackEl = trackRef.current;\n if (!trackEl) return;\n const trackRect = trackEl.getBoundingClientRect();\n const layoutWidth = trackEl.offsetWidth;\n if (layoutWidth <= 0 || trackRect.width <= 0) return;\n // Normalize cursor to layout space so it matches motionX (which is\n // rendered as a CSS-pixel transform), even under ancestor CSS scale.\n const scale = trackRect.width / layoutWidth;\n const localX = (e.clientX - trackRect.left) / scale - THUMB_SIZE / 2;\n const clamped = Math.max(\n 0,\n Math.min(layoutWidth - THUMB_SIZE, localX)\n );\n\n // Determine which thumb to drag\n if (isRange) {\n const dist0 = Math.abs(clamped - motionX0.get());\n const dist1 = Math.abs(clamped - motionX1.get());\n activeDragThumb.current = dist0 <= dist1 ? 0 : 1;\n } else {\n activeDragThumb.current = 0;\n }\n\n dragging.current = true;\n setIsPressed(true);\n\n const motionX =\n activeDragThumb.current === 0 ? motionX0 : motionX1;\n\n // Snap to step grid immediately\n const snappedValue = pixelToValue(\n clamped,\n min,\n max,\n step,\n layoutWidth,\n stepValues\n );\n const snappedPx = valueToPixel(snappedValue, min, max, layoutWidth);\n\n // Clamp for range crossing\n const finalPx = clampForRange(\n snappedPx,\n activeDragThumb.current\n );\n // Spring-animate thumb to clicked position\n animate(motionX, finalPx, spring.moderate);\n\n // Update value\n const finalValue = pixelToValue(\n finalPx,\n min,\n max,\n step,\n layoutWidth,\n stepValues\n );\n emitChange(activeDragThumb.current, finalValue);\n\n (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);\n },\n [disabled, isRange, min, max, step, stepValues, motionX0, motionX1, clampForRange, emitChange]\n );\n\n const handlePointerMove = useCallback(\n (e: React.PointerEvent) => {\n if (!dragging.current) return;\n e.stopPropagation();\n const trackEl = trackRef.current;\n if (!trackEl) return;\n const trackRect = trackEl.getBoundingClientRect();\n const layoutWidth = trackEl.offsetWidth;\n if (layoutWidth <= 0 || trackRect.width <= 0) return;\n const scale = trackRect.width / layoutWidth;\n const localX = (e.clientX - trackRect.left) / scale - THUMB_SIZE / 2;\n const clamped = Math.max(\n 0,\n Math.min(layoutWidth - THUMB_SIZE, localX)\n );\n\n const motionX =\n activeDragThumb.current === 0 ? motionX0 : motionX1;\n\n // Snap to step grid during drag\n const snappedValue = pixelToValue(\n clamped,\n min,\n max,\n step,\n layoutWidth,\n stepValues\n );\n const snappedPx = valueToPixel(snappedValue, min, max, layoutWidth);\n const finalPx = clampForRange(\n snappedPx,\n activeDragThumb.current\n );\n motionX.set(finalPx);\n\n const finalValue = pixelToValue(\n finalPx,\n min,\n max,\n step,\n layoutWidth,\n stepValues\n );\n emitChange(activeDragThumb.current, finalValue);\n },\n [min, max, step, stepValues, motionX0, motionX1, clampForRange, emitChange]\n );\n\n const handlePointerUp = useCallback(() => {\n if (!dragging.current) return;\n dragging.current = false;\n setIsPressed(false);\n setHoverPreview(null);\n\n // Spring settle to final quantized position\n const tw = trackWidthRef.current;\n const motionX =\n activeDragThumb.current === 0 ? motionX0 : motionX1;\n const currentPx = motionX.get();\n const snapped = pixelToValue(currentPx, min, max, step, tw, stepValues);\n const snappedPx = valueToPixel(snapped, min, max, tw);\n animate(motionX, snappedPx, spring.moderate);\n }, [min, max, step, stepValues, motionX0, motionX1]);\n\n // --- Primitive keyboard handler ---\n // In steps mode the primitive runs on indices (0..len-1, step 1) so arrow\n // keys walk the list; map indices back to actual values on the way out.\n const handlePrimitiveChange = useCallback(\n (newValues: number[]) => {\n if (dragging.current) return;\n const mapped = stepValues\n ? newValues.map((i) => stepValues[Math.round(i)])\n : newValues;\n if (isRange) {\n onChange(mapped as [number, number]);\n } else {\n onChange(mapped[0]);\n }\n },\n [isRange, onChange, stepValues]\n );\n\n // --- Click-to-edit handlers ---\n const handleStartEdit = useCallback((index: number) => {\n setEditingIndex(index);\n }, []);\n\n const handleCommitEdit = useCallback(\n (index: number, v: number) => {\n emitChange(index, v);\n setEditingIndex(null);\n },\n [emitChange]\n );\n\n const handleCancelEdit = useCallback(() => {\n setEditingIndex(null);\n }, []);\n\n // --- Step dots ---\n const stepDots = useMemo(\n () =>\n showSteps\n ? stepValues\n ? stepValues.map((v) => ({\n value: v,\n percent: max === min ? 0 : (v - min) / (max - min),\n }))\n : Array.from(\n { length: Math.round((max - min) / step) + 1 },\n (_, i) => {\n const v = min + i * step;\n const percent = (v - min) / (max - min);\n return { value: v, percent };\n }\n )\n : [],\n [showSteps, min, max, step, stepValues]\n );\n\n // --- Interaction state for tooltip ---\n const isInteracting = isHovered || isPressed;\n\n // --- Per-thumb accessible names ---\n // aria-label on Root lands on a role-less div and never reaches the\n // thumb's input, so each Thumb gets its own label.\n const thumbAriaLabel = (index: number): string | undefined => {\n if (!isRange) return label;\n if (!label) return index === 0 ? \"Minimum\" : \"Maximum\";\n return index === 0 ? `${label} minimum` : `${label} maximum`;\n };\n\n // --- Value display component ---\n const valueDisplay = showValue && valuePosition !== \"tooltip\" && (\n \n );\n\n // --- Render visual thumb (not Primitive — purely visual) ---\n const renderVisualThumb = (index: number) => {\n const motionX = index === 0 ? motionX0 : motionX1;\n return (\n \n \n {/* Focus ring */}\n \n \n );\n };\n\n return (\n \n {/* Top / Left value */}\n {(valuePosition === \"top\" || valuePosition === \"left\") && valueDisplay}\n\n {/* Track area */}\n setIsHovered(true)}\n onPointerLeave={() => {\n setIsHovered(false);\n setHoverPreview(null);\n }}\n onMouseMove={(e) => {\n if (dragging.current) return;\n const trackEl = trackRef.current;\n if (!trackEl) return;\n const trackRect = trackEl.getBoundingClientRect();\n const layoutWidth = trackEl.offsetWidth;\n if (layoutWidth <= 0 || trackRect.width <= 0) return;\n // Normalize to layout space so the formula's THUMB_SIZE / TRACK_INSET\n // constants (layout px) match the cursor's coordinate space, even\n // when an ancestor applies a CSS scale transform (e.g. /demo).\n const scale = trackRect.width / layoutWidth;\n const layoutX = (e.clientX - trackRect.left) / scale;\n const clamped = Math.max(0, Math.min(layoutWidth, layoutX));\n computeHoverPreview(clamped, layoutWidth);\n }}\n >\n {/* Tooltip values */}\n {showValue && valuePosition === \"tooltip\" && (\n \n {isInteracting && (\n \n )}\n {isInteracting && isRange && values[1] !== undefined && (\n \n )}\n \n )}\n\n {/* Base UI Slider — invisible, provides ARIA + keyboard nav */}\n nearestStepIndex(v, stepValues))\n : values\n }\n onValueChange={(v) => handlePrimitiveChange(v as number[])}\n min={stepValues ? 0 : min}\n max={stepValues ? stepValues.length - 1 : max}\n step={stepValues ? 1 : step}\n disabled={disabled}\n className=\"absolute inset-0 opacity-0 pointer-events-none\"\n style={{ height: THUMB_SIZE }}\n >\n \n \n \n \n formatValue(values[0]) : undefined\n }\n className=\"block outline-none\"\n style={{ width: THUMB_SIZE, height: THUMB_SIZE }}\n onFocus={(e) => { if ((e.currentTarget as HTMLElement).matches(\":focus-visible\")) setFocusedThumb(0); }}\n onBlur={() => setFocusedThumb((prev) => prev === 0 ? null : prev)}\n />\n {isRange && (\n formatValue(values[1]) : undefined\n }\n className=\"block outline-none\"\n style={{ width: THUMB_SIZE, height: THUMB_SIZE }}\n onFocus={(e) => { if ((e.currentTarget as HTMLElement).matches(\":focus-visible\")) setFocusedThumb(1); }}\n onBlur={() => setFocusedThumb((prev) => prev === 1 ? null : prev)}\n />\n )}\n \n \n\n {/* Visual track with pointer handlers */}\n \n {/* Extended hit area — 8px beyond each edge */}\n \n {/* Hover value tooltip */}\n \n {hoverPreview && showHoverTooltip && !isPressed && valuePosition !== \"tooltip\" && (\n \n \n {formatValue(hoverPreview.snappedValue)}\n \n \n )}\n \n\n {/* Track background */}\n \n {/* Filled range */}\n {!hideFill && (\n \n )}\n\n {/* Hover preview */}\n hoverPreview.left\n ? \"0 9999px 9999px 0\"\n : \"9999px 0 0 9999px\",\n backgroundColor: \"var(--emphasis)\",\n }}\n />\n\n \n\n {/* Step dots — masked so filled side is hidden */}\n {stepDots.length > 0 && (\n \n {stepDots.map(({ value: v, percent }) => (\n \n \n \n ))}\n \n )}\n\n {/* Visual thumbs */}\n {renderVisualThumb(0)}\n {isRange && renderVisualThumb(1)}\n \n \n\n {/* Bottom / Right value */}\n {(valuePosition === \"bottom\" || valuePosition === \"right\") &&\n valueDisplay}\n \n );\n }\n);\n\nSlider.displayName = \"Slider\";\n\n// ---------------------------------------------------------------------------\n// SliderComfortable\n// ---------------------------------------------------------------------------\n\ninterface SliderComfortableProps\n extends Omit, \"onChange\" | \"defaultValue\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onDragOver\" | \"onAnimationStart\"> {\n value: number;\n onChange: (value: number) => void;\n min?: number;\n max?: number;\n step?: number;\n variant?: \"pips\" | \"scrubber\";\n label?: string;\n formatValue?: (v: number) => string;\n disabled?: boolean;\n}\n\nconst SliderComfortable = forwardRef(\n (\n {\n value,\n onChange,\n min = 0,\n max = 100,\n step = 1,\n variant = \"pips\",\n label,\n formatValue = String,\n disabled = false,\n className,\n ...props\n },\n ref\n ) => {\n const containerRef = useRef(null);\n const dragging = useRef(false);\n const handleDragging = useRef(false);\n const [isHovered, setIsHovered] = useState(false);\n const [isPressed, setIsPressed] = useState(false);\n const [isFocused, setIsFocused] = useState(false);\n const [hoverPreview, setHoverPreview] = useState<{\n left: number;\n width: number;\n snappedValue: number;\n cursorX: number;\n } | null>(null);\n const [showHoverTooltip, setShowHoverTooltip] = useState(false);\n const hoverDelayRef = useRef | null>(null);\n const shape = useShape();\n\n // Show hover tooltip after 100ms delay\n useEffect(() => {\n if (isHovered) {\n hoverDelayRef.current = setTimeout(() => setShowHoverTooltip(true), 100);\n } else {\n if (hoverDelayRef.current) clearTimeout(hoverDelayRef.current);\n setShowHoverTooltip(false);\n }\n return () => { if (hoverDelayRef.current) clearTimeout(hoverDelayRef.current); };\n }, [isHovered]);\n\n const mergedRef = useCallback(\n (el: HTMLDivElement | null) => {\n containerRef.current = el;\n if (typeof ref === \"function\") (ref as React.RefCallback)(el);\n else if (ref) (ref as React.RefObject).current = el;\n },\n [ref]\n );\n\n const pipSteps = useMemo(\n () => Array.from(\n { length: Math.round((max - min) / step) + 1 },\n (_, i) => min + i * step\n ),\n [min, max, step]\n );\n const pipCount = pipSteps.length;\n\n // Fill motion value\n const fillPercent = useMotionValue(\n max === min ? 0 : Math.max(0, Math.min(1, (value - min) / (max - min)))\n );\n // Small offset when value is at min so the handle line stays visible\n const zeroTarget = variant === \"pips\" ? 8 : 17;\n const zeroOffset = useMotionValue(value === min ? zeroTarget : 0);\n\n const fillWidthStyle = useTransform(fillPercent, (p) => `${p * 100}%`);\n const handleLeftStyle = useTransform(\n [fillPercent, zeroOffset] as MotionValue[],\n ([p, zo]) => `calc(${(p as number) * 100}% - 8px + ${zo as number}px)`\n );\n const handleLineLeftStyle = useTransform(\n [fillPercent, zeroOffset] as MotionValue[],\n ([p, zo]) => `calc(${(p as number) * 100}% - 9px + ${zo as number}px)`\n );\n // Pips-specific: offset by px-3 (12px) padding so fill edge aligns with active pip center\n const pipsFillWidthStyle = useTransform(\n [fillPercent, zeroOffset] as MotionValue[],\n ([p, zo]) => `calc(${(p as number) * 100}% + ${20 - 20 * (p as number) - (zo as number) * 2.5}px)`\n );\n const pipsHandleLineLeftStyle = useTransform(\n fillPercent,\n (p) => `calc(${p * 100}% + ${11 - 24 * p}px)`\n );\n const pipsMaskStyle = useTransform(\n [fillPercent, zeroOffset] as MotionValue[],\n ([p, zo]) => {\n const offset = 20 - 20 * (p as number) - (zo as number) * 2.5;\n return `linear-gradient(to right, transparent calc(${(p as number) * 100}% + ${offset}px), black calc(${(p as number) * 100}% + ${offset + 2}px))`;\n }\n );\n const pipsOnBrandTextMaskStyle = useTransform(\n [fillPercent, zeroOffset] as MotionValue[],\n ([p, zo]) => {\n const offset = 20 - 20 * (p as number) - (zo as number) * 2.5;\n const edge = `calc(${(p as number) * 100}% + ${offset}px)`;\n return `linear-gradient(to right, black ${edge}, transparent ${edge})`;\n }\n );\n const scrubberOnBrandTextMaskStyle = useTransform(\n fillPercent,\n (p) => `linear-gradient(to right, black ${p * 100}%, transparent ${p * 100}%)`\n );\n\n // --- Hover preview computation ---\n const computeHoverPreview = useCallback(\n (clientX: number) => {\n const el = containerRef.current;\n if (!el) return;\n const rect = el.getBoundingClientRect();\n // Use clientWidth (padding box) — CSS % and absolute left/width are relative to it\n const w = el.clientWidth;\n if (w <= 0 || rect.width <= 0) return;\n // Normalize cursor to layout space so it matches `w` (layout, padding\n // box). offsetWidth is the layout border-box; the difference vs `w` is\n // the horizontal border contribution split across both sides.\n const scale = rect.width / el.offsetWidth;\n const borderLeftLayout = (el.offsetWidth - w) / 2;\n const visualX = clientX - rect.left;\n const layoutX = visualX / scale - borderLeftLayout;\n const clamped = Math.max(0, Math.min(w, layoutX));\n\n // Snap to nearest step value\n let snappedVal: number;\n if (variant === \"pips\") {\n if (pipCount <= 1) return;\n const index = Math.max(0, Math.min(pipCount - 1, Math.round((clamped / w) * (pipCount - 1))));\n snappedVal = pipSteps[index];\n } else {\n const raw = min + (clamped / w) * (max - min);\n snappedVal = Math.max(min, Math.min(max, Math.round((raw - min) / step) * step + min));\n }\n const snappedPercent = max === min ? 0 : (snappedVal - min) / (max - min);\n const snappedX = snappedPercent * w;\n\n // Current handle position — for pips, match the visual fill edge offset\n const currentPercent = fillPercent.get();\n let handleX: number;\n if (variant === \"pips\") {\n const zo = zeroOffset.get();\n handleX = currentPercent * w + (20 - 20 * currentPercent - zo * 2.5);\n } else {\n handleX = currentPercent * w;\n }\n\n // Extend hover bar to container edges at extremes so there's no gap\n const edgeX = snappedVal === min ? 0 : snappedVal === max ? w : snappedX;\n const left = Math.min(handleX, edgeX);\n const width = Math.abs(edgeX - handleX);\n setHoverPreview({ left, width, snappedValue: snappedVal, cursorX: snappedX });\n },\n [variant, pipSteps, pipCount, min, max, step, fillPercent, zeroOffset]\n );\n\n // Sync fill on programmatic value change\n useEffect(() => {\n if (dragging.current || handleDragging.current) return;\n const percent = max === min ? 0 : Math.max(0, Math.min(1, (value - min) / (max - min)));\n animate(fillPercent, percent, spring.fast);\n animate(zeroOffset, value === min ? zeroTarget : 0, spring.fast);\n }, [value, min, max, variant, fillPercent, zeroOffset, zeroTarget]);\n\n const getValueFromX = useCallback(\n (clientX: number) => {\n const rect = containerRef.current?.getBoundingClientRect();\n if (!rect) return min;\n const x = clientX - rect.left;\n const clamped = Math.max(0, Math.min(rect.width, x));\n if (variant === \"pips\") {\n if (pipCount <= 1) return min;\n const index = Math.max(\n 0,\n Math.min(pipCount - 1, Math.round((clamped / rect.width) * (pipCount - 1)))\n );\n return pipSteps[index];\n } else {\n const raw = min + (clamped / rect.width) * (max - min);\n const snapped = Math.round((raw - min) / step) * step + min;\n return Math.max(min, Math.min(max, snapped));\n }\n },\n [variant, pipSteps, pipCount, min, max, step]\n );\n\n const handlePointerDown = useCallback(\n (e: React.PointerEvent) => {\n if (disabled) return;\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n e.preventDefault();\n dragging.current = true;\n setIsPressed(true);\n const newVal = getValueFromX(e.clientX);\n onChange(newVal);\n const newPercent = Math.max(0, Math.min(1, (newVal - min) / (max - min)));\n animate(fillPercent, newPercent, spring.fast);\n animate(zeroOffset, newVal === min ? zeroTarget : 0, spring.fast);\n (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);\n },\n [disabled, getValueFromX, onChange, fillPercent, zeroOffset, zeroTarget, min, max]\n );\n\n const handlePointerMove = useCallback(\n (e: React.PointerEvent) => {\n if (!dragging.current) return;\n const newVal = getValueFromX(e.clientX);\n onChange(newVal);\n const newPercent = Math.max(0, Math.min(1, (newVal - min) / (max - min)));\n if (variant === \"scrubber\") {\n fillPercent.set(newPercent);\n } else {\n animate(fillPercent, newPercent, spring.fast);\n }\n animate(zeroOffset, newVal === min ? zeroTarget : 0, spring.fast);\n },\n [getValueFromX, onChange, variant, fillPercent, zeroOffset, zeroTarget, min, max]\n );\n\n const handlePointerUp = useCallback(() => {\n dragging.current = false;\n setIsPressed(false);\n setHoverPreview(null);\n }, []);\n\n // Resize handle drag handlers (direct cursor position)\n const handleResizePointerDown = useCallback(\n (e: React.PointerEvent) => {\n if (disabled) return;\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n e.preventDefault();\n e.stopPropagation();\n handleDragging.current = true;\n setIsPressed(true);\n const newVal = getValueFromX(e.clientX);\n onChange(newVal);\n fillPercent.set(Math.max(0, Math.min(1, (newVal - min) / (max - min))));\n animate(zeroOffset, newVal === min ? zeroTarget : 0, spring.fast);\n (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);\n },\n [disabled, getValueFromX, onChange, fillPercent, zeroOffset, zeroTarget, min, max]\n );\n\n const handleResizePointerMove = useCallback(\n (e: React.PointerEvent) => {\n if (!handleDragging.current) return;\n const newVal = getValueFromX(e.clientX);\n onChange(newVal);\n fillPercent.set(Math.max(0, Math.min(1, (newVal - min) / (max - min))));\n animate(zeroOffset, newVal === min ? zeroTarget : 0, spring.fast);\n },\n [getValueFromX, onChange, fillPercent, zeroOffset, zeroTarget, min, max]\n );\n\n const handleResizePointerUp = useCallback(() => {\n handleDragging.current = false;\n setIsPressed(false);\n setHoverPreview(null);\n }, []);\n\n const handlePrimitiveChange = useCallback(\n (newValues: number[]) => {\n onChange(newValues[0]);\n },\n [onChange]\n );\n\n const isActive = isHovered || isFocused;\n\n return (\n { if (!disabled) setIsHovered(true); }}\n onPointerLeave={() => {\n if (!disabled) {\n setIsHovered(false);\n setHoverPreview(null);\n }\n }}\n onMouseMove={(e) => {\n if (disabled || dragging.current || handleDragging.current) return;\n computeHoverPreview(e.clientX);\n }}\n >\n {/* Extended hit area — 8px beyond each edge */}\n \n {/* Hover value tooltip — outside overflow-hidden container */}\n \n {hoverPreview && showHoverTooltip && !isPressed && (\n \n \n {formatValue(hoverPreview.snappedValue)}\n \n \n )}\n \n\n \n {/* Invisible Base UI Slider for keyboard nav + a11y */}\n handlePrimitiveChange(v as number[])}\n min={min}\n max={max}\n step={step}\n disabled={disabled}\n className=\"absolute inset-0 opacity-0 pointer-events-none [&_*]:pointer-events-none\"\n >\n \n \n \n \n {\n if ((e.currentTarget as HTMLElement).matches(\":focus-visible\")) setIsFocused(true);\n }}\n onBlur={() => setIsFocused(false)}\n />\n \n \n\n {/* Hover preview */}\n \n\n {/* Pips: dots layer — z-decoration */}\n {variant === \"pips\" && (\n \n {pipSteps.map((pipValue) => {\n const isActivePip = pipValue === value;\n return (\n \n \n \n );\n })}\n \n )}\n\n {/* Pips: label + value BG layer — z-indicator (occludes dots behind text) */}\n {variant === \"pips\" && (\n
\n {label && (\n \n {label}\n \n )}\n \n {formatValue(value)}\n \n
\n )}\n\n {/* Pips: fill — z-control */}\n {variant === \"pips\" && (\n \n )}\n\n {/* Pips: handle line — z-control */}\n {variant === \"pips\" && (\n \n )}\n\n {/* Pips: label + value text layer — z-foreground */}\n {variant === \"pips\" && (\n
\n {label && (\n \n {label}\n \n )}\n \n {formatValue(value)}\n \n
\n )}\n\n {/* Pips: on-brand text, clipped to the filled region */}\n {variant === \"pips\" && (\n \n {label && {label}}\n \n {formatValue(value)}\n \n \n )}\n\n {/* Scrubber: fill */}\n {variant === \"scrubber\" && (\n \n )}\n\n {/* Scrubber: handle line */}\n {variant === \"scrubber\" && (\n \n )}\n\n {/* Scrubber: label */}\n {variant === \"scrubber\" && label && (\n \n {label}\n \n )}\n\n {/* Scrubber: flex-1 spacer + value */}\n {variant === \"scrubber\" && (\n <>\n
\n \n {formatValue(value)}\n \n \n )}\n\n {/* Scrubber: on-brand text, clipped to the filled region */}\n {variant === \"scrubber\" && (\n \n {label && {label}}\n
\n \n {formatValue(value)}\n \n \n )}\n\n {/* Resize handle (scrubber only) */}\n {variant === \"scrubber\" && (\n \n )}\n \n
\n );\n }\n);\n\nSliderComfortable.displayName = \"SliderComfortable\";\n\nexport { Slider, SliderComfortable };\nexport type { SliderProps, SliderValue, ValuePosition, SliderComfortableProps };\n", "type": "registry:ui", "target": "components/ui/slider.tsx" } ], "type": "registry:ui" }