{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "author": "DesignX ", "name": "range-slider-inline", "type": "registry:component", "title": "Inline Slider", "description": "A compact slider with its label and value inside the track.", "dependencies": [ "motion@^14.0.0" ], "registryDependencies": [ "@dx/lib-ease", "@dx/lib-touch", "@dx/utils" ], "files": [ { "path": "components/motion/range-slider-inline.tsx", "type": "registry:component", "target": "components/motion/range-slider-inline.tsx", "content": "import {\n animate,\n motion,\n useMotionValue,\n useReducedMotion,\n useTransform,\n} from \"motion/react\";\nimport {\n type PointerEvent,\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport { SPRING_GLIDE } from \"@/lib/ease\";\nimport { type SliderOptions, snapSliderValue, useSlider } from \"@/lib/hooks/use-slider\";\nimport { capturePointer, releasePointer, TOUCH_GESTURE_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nconst STOP_COUNT = 10;\nconst HANDLE_START = 8;\nconst HANDLE_END_INSET = 12;\nconst TEXT_INSET = 20;\n// Matches RangeSlider's bouncy grab and release feedback.\nconst SPRING_BOUNCY = { type: \"spring\", stiffness: 500, damping: 14, mass: 0.7 } as const;\n\ntype Stop = { value: number; x: number };\n\nfunction mapBetweenStops(\n stops: Stop[],\n point: number,\n from: keyof Stop,\n to: keyof Stop,\n) {\n const upperIndex = stops.findIndex((stop) => stop[from] >= point);\n const upper = stops[upperIndex < 0 ? stops.length - 1 : upperIndex];\n const lower = stops[Math.max(0, upperIndex - 1)];\n if (lower[from] === upper[from]) return upper[to];\n return lower[to] +\n ((point - lower[from]) / (upper[from] - lower[from])) * (upper[to] - lower[to]);\n}\n\nfunction nearestStop(stops: Stop[], x: number) {\n return stops.reduce((nearest, stop) =>\n Math.abs(stop.x - x) < Math.abs(nearest.x - x) ? stop : nearest,\n );\n}\n\nexport interface InlineSliderProps extends SliderOptions {\n /** Rounds snap-stop values. While dragging, the readout keeps this step's decimal precision. */\n step?: number;\n /** Compact label inside the left edge of the track. */\n label: string;\n /** Formats the inline value without changing its precision. */\n format?: (value: number) => string;\n /** Show markers for the ten evenly spaced snap stops, except where they overlap inline text. */\n showTicks?: boolean;\n className?: string;\n}\n\n/** An always-visible inline slider with an inset fill and a thumb that parts\n * around its labels, keeping their text readable as the handle passes them. */\nexport function InlineSlider({\n label,\n format = String,\n showTicks = true,\n className,\n ...options\n}: InlineSliderProps) {\n const reduce = useReducedMotion();\n const step = options.step && options.step > 0 ? options.step : 1;\n const precision = step.toFixed(6).replace(/0+$/, \"\").split(\".\")[1]?.length ?? 0;\n const { current, min, max, commit, trackProps, sliderProps } = useSlider({\n ...options,\n step: 10 ** -precision,\n \"aria-label\": options[\"aria-label\"] ?? label,\n formatValueText: options.formatValueText ?? format,\n });\n const labelRef = useRef(null);\n const readoutRef = useRef(null);\n const [geometry, setGeometry] = useState({\n width: 292,\n labelWidth: 22,\n readoutWidth: 24,\n });\n const [dragging, setDragging] = useState(false);\n const dragFrame = useRef(null);\n const pendingDragValue = useRef(null);\n const gesture = useRef<{\n id: number;\n left: number;\n offset: number;\n x: number;\n } | null>(null);\n\n useLayoutEffect(() => {\n const track = trackProps.ref.current;\n const labelElement = labelRef.current;\n const readout = readoutRef.current;\n if (!track || !labelElement || !readout) return;\n const measure = () => {\n const width = track.getBoundingClientRect().width;\n if (!width) return;\n const next = {\n width,\n labelWidth: labelElement.getBoundingClientRect().width,\n readoutWidth: readout.getBoundingClientRect().width,\n };\n setGeometry((previous) =>\n previous.width === next.width && previous.labelWidth === next.labelWidth &&\n previous.readoutWidth === next.readoutWidth ? previous : next,\n );\n };\n measure();\n const observer = new ResizeObserver(measure);\n observer.observe(track);\n observer.observe(labelElement);\n observer.observe(readout);\n return () => observer.disconnect();\n }, [trackProps.ref]);\n\n // Each stop owns both a value and a physical position. As in RangeSlider,\n // the stops span the entire track at even intervals; text only affects\n // whether a marker is painted, never whether its stop remains interactive.\n const endX = Math.max(HANDLE_START, geometry.width - HANDLE_END_INSET);\n const stops = useMemo(() => {\n const values = [...new Set(Array.from({ length: STOP_COUNT }, (_, index) =>\n snapSliderValue(min + (index / (STOP_COUNT - 1)) * (max - min), min, max, step),\n ))];\n return values.map((value, index) => ({\n value,\n x: values.length === 1\n ? HANDLE_START\n : HANDLE_START + (index / (values.length - 1)) * (endX - HANDLE_START),\n }));\n }, [min, max, step, endX]);\n const restingX = mapBetweenStops(stops, current, \"value\", \"x\");\n // One motion value owns the thumb for the entire gesture. Pointer movement\n // writes pixels directly; only release/click/keyboard changes use a spring.\n // Never derive dragging pixels from a rounded value or swap to a lagging spring.\n const handleX = useMotionValue(restingX);\n const restingTarget = useRef(restingX);\n const settleTo = useCallback((x: number) => {\n restingTarget.current = x;\n handleX.jump(handleX.get());\n if (reduce) handleX.jump(x);\n else animate(handleX, x, { type: \"spring\", ...SPRING_GLIDE });\n }, [handleX, reduce]);\n useLayoutEffect(() => {\n if (gesture.current || restingTarget.current === restingX) return;\n settleTo(restingX);\n }, [restingX, settleTo]);\n useEffect(() => () => {\n handleX.stop();\n if (dragFrame.current !== null) cancelAnimationFrame(dragFrame.current);\n }, [handleX]);\n const fillRight = useTransform(handleX, (x) =>\n x >= endX ? geometry.width - 2 : x + 8,\n );\n // Slide a fixed-size fill inside the inset clipping window. The labels and\n // dots stay above it, and only transforms animate.\n const fillX = useTransform(fillRight, (right) => right - geometry.width + 2);\n // Part progressively over six pixels at each text edge instead of\n // toggling the stem on/off in a single pointer frame. The thumb uses the\n // same two-dot treatment across both the label and numeric readout.\n const split = useTransform(handleX, (x) => {\n const overlap = (start: number, end: number) => Math.max(0, Math.min(\n 1,\n (x + 4 - start) / 6,\n (end - x) / 6,\n ));\n return Math.max(\n overlap(TEXT_INSET, TEXT_INSET + geometry.labelWidth),\n overlap(\n geometry.width - TEXT_INSET - geometry.readoutWidth,\n geometry.width - TEXT_INSET,\n ),\n );\n });\n const stemOpacity = useTransform(split, (amount) => 1 - amount);\n const capTop = useTransform(split, (amount) => -amount);\n const capBottom = useTransform(split, (amount) => amount);\n const labelBounds = { start: TEXT_INSET, end: TEXT_INSET + geometry.labelWidth };\n const readoutBounds = {\n start: geometry.width - TEXT_INSET - geometry.readoutWidth,\n end: geometry.width - TEXT_INSET,\n };\n const overlapsText = (x: number, bounds: { start: number; end: number }) =>\n x + 2 >= bounds.start && x - 2 <= bounds.end;\n const ticks = showTicks\n ? stops\n .map((stop) => stop.x)\n .filter((x) => !overlapsText(x, labelBounds) && !overlapsText(x, readoutBounds))\n : [];\n\n const queueDragCommit = (value: number) => {\n pendingDragValue.current = value;\n if (dragFrame.current !== null) return;\n dragFrame.current = requestAnimationFrame(() => {\n dragFrame.current = null;\n if (pendingDragValue.current !== null) commit(pendingDragValue.current);\n pendingDragValue.current = null;\n });\n };\n\n const cancelDragCommit = () => {\n if (dragFrame.current !== null) cancelAnimationFrame(dragFrame.current);\n dragFrame.current = null;\n pendingDragValue.current = null;\n };\n\n const endGesture = (event: PointerEvent) => {\n const active = gesture.current;\n if (!active || active.id !== event.pointerId) return;\n // Clear before releasing capture: its lost-capture event must not commit twice.\n gesture.current = null;\n cancelDragCommit();\n setDragging(false);\n if (!options.disabled && geometry.width > 0) {\n const x = event.type === \"pointerup\"\n ? event.clientX - active.left - active.offset\n : active.x;\n const stop = nearestStop(stops, x);\n commit(stop.value);\n settleTo(options.value === undefined ? stop.x : restingX);\n } else {\n settleTo(restingX);\n }\n releasePointer(event.currentTarget, event.pointerId);\n };\n\n return (\n {\n if (options.disabled || event.button !== 0 || gesture.current) return;\n const rect = event.currentTarget.getBoundingClientRect();\n if (!rect.width) return;\n event.preventDefault();\n const pointerX = event.clientX - rect.left;\n const thumbX = handleX.get();\n // Grabbing the thumb preserves the exact grab point. A track click\n // waits for release, so it glides to a dot without an intermediate jump.\n const offset = Math.abs(pointerX - thumbX - 2) <= 12 ? pointerX - thumbX : 2;\n gesture.current = { id: event.pointerId, left: rect.left, offset, x: thumbX };\n setDragging(true);\n handleX.stop();\n cancelDragCommit();\n capturePointer(event.currentTarget, event.pointerId);\n event.currentTarget.querySelector(\"[role=slider]\")?.focus({ preventScroll: true });\n }}\n onPointerMove={(event) => {\n const active = gesture.current;\n if (!active || active.id !== event.pointerId || options.disabled) return;\n const x = Math.min(\n endX,\n Math.max(HANDLE_START, event.clientX - active.left - active.offset),\n );\n active.x = x;\n handleX.set(x);\n // Use the same piecewise map as the resting stops, so value and\n // position agree throughout the drag.\n queueDragCommit(mapBetweenStops(stops, x, \"x\", \"value\"));\n }}\n onPointerUp={endGesture}\n onPointerCancel={endGesture}\n onLostPointerCapture={endGesture}\n className={cn(\n \"relative h-10 w-full touch-none select-none overflow-hidden rounded-lg bg-muted\",\n TOUCH_GESTURE_CLASS,\n options.disabled\n ? \"pointer-events-none opacity-50\"\n : \"cursor-grab active:cursor-grabbing\",\n className,\n )}\n >\n \n \n \n
\n \n {label}\n \n \n {format(current)}\n \n {ticks.map((left) => (\n \n ))}\n
\n \n \n \n \n \n {\n if (options.disabled) return;\n const next = {\n ArrowRight: stops.find((stop) => stop.value > current)?.value ?? max,\n ArrowUp: stops.find((stop) => stop.value > current)?.value ?? max,\n ArrowLeft: stops.findLast((stop) => stop.value < current)?.value ?? min,\n ArrowDown: stops.findLast((stop) => stop.value < current)?.value ?? min,\n Home: min,\n End: max,\n PageUp: max,\n PageDown: min,\n }[event.key];\n if (next !== undefined) {\n event.preventDefault();\n commit(next);\n }\n }}\n className=\"absolute inset-0 cursor-inherit touch-none rounded-lg border-0 outline-none\"\n />\n \n );\n}\n" } ], "categories": [ "motion-kit" ], "meta": { "group": "motion", "docs": "https://dxuireact.com/docs/components/range-slider-inline" } }