persianlabsui
uielastic-slidersource: ok · hit
Elastic Slider
Slider with elastic rubber-band drag and magnetic snap feedback.
npx shadcn add "https://ui.persian-labs.ir/r/elastic-slider.json"free
Interaction
carouseldrag
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Registry dependencies
utils
Files2 · source cached 10/8/2026, 9:43:24 PM
registry/base/ui/elastic-slider.tsx
"use client"
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react"
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react"
import { useControllableState } from "@/hooks/use-controllable-state"
import { cn } from "@/lib/utils"
// Drag detection & rubber band
const CLICK_THRESHOLD = 3
const DEAD_ZONE = 32
const MAX_CURSOR_RANGE = 200
const MAX_STRETCH = 8
// Layout offsets used by the "handle dodges label/value" calculation.
const HANDLE_BUFFER = 8
const LABEL_OFFSET = 12 + 4
const VALUE_OFFSET = 12 - 8
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v))
}
function decimalsForStep(step: number): number {
const s = step.toString()
const dot = s.indexOf(".")
return dot === -1 ? 0 : s.length - dot - 1
}
function roundValue(val: number, step: number): number {
const raw = Math.round(val / step) * step
return parseFloat(raw.toFixed(decimalsForStep(step)))
}
// Magnetic snap to the nearest decile when within 3.125% of it.
function snapToDecile(rawValue: number, min: number, max: number): number {
const normalized = (rawValue - min) / (max - min)
const nearest = Math.round(normalized * 10) / 10
if (Math.abs(normalized - nearest) <= 0.03125) {
return min + nearest * (max - min)
}
return rawValue
}
export type ElasticSliderProps = {
/** Label shown inside the track. */
label: string
/** Controlled value. Use together with `onValueChange` */
value?: number
/** Initial value for uncontrolled mode. Falls back to `min` */
defaultValue?: number
/** Called with the new value on drag, click, or key press. */
onValueChange?: (value: number) => void
/**
* Minimum value.
* @defaultValue 0 */
min?: number
/**
* Maximum value.
* @defaultValue 1 */
max?: number
/**
* Smallest increment.
* @defaultValue 0.01 */
step?: number
/** Format the displayed value. Defaults to `value.toFixed(...)` based on `step` */
formatValue?: (value: number) => string
className?: string
/** Accessible name. Falls back to `label` */
"aria-label"?: string
}
export function ElasticSlider({
label,
value: valueProp,
defaultValue,
onValueChange,
min = 0,
max = 1,
step = 0.01,
formatValue,
className,
"aria-label": ariaLabel,
}: ElasticSliderProps) {
const [value = min, setValue] = useControllableState({
prop: valueProp,
defaultProp: defaultValue ?? min,
onChange: onValueChange,
})
const shouldReduceMotion = useReducedMotion()
const wrapperRef = useRef<HTMLDivElement>(null)
const trackRef = useRef<HTMLDivElement>(null)
const labelRef = useRef<HTMLSpanElement>(null)
const valueRef = useRef<HTMLSpanElement>(null)
const [isInteracting, setIsInteracting] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isHovered, setIsHovered] = useState(false)
/** Ring only for Tab focus or keyboard value nudges, not pointer press/drag. */
const [keyboardFocusRing, setKeyboardFocusRing] = useState(false)
// Pointer session state — mutable, does not trigger re-renders.
const pointerDownPos = useRef<{ x: number; y: number } | null>(null)
const pendingPointerFocusRef = useRef(false)
const isClickRef = useRef(true)
const animRef = useRef<ReturnType<typeof animate> | null>(null)
const wrapperRectRef = useRef<DOMRect | null>(null)
const scaleRef = useRef(1)
/**
* Reads the ambient text direction where the slider actually renders, since
* this project is RTL-first by default. Value 0 always maps to the reading
* start (right edge in RTL) and value max to the reading end, matching how
* a native <input type="range"> behaves in RTL browsers.
*/
const dirRef = useRef<"ltr" | "rtl">("ltr")
useLayoutEffect(() => {
function updateDir() {
if (!wrapperRef.current) return
dirRef.current =
getComputedStyle(wrapperRef.current).direction === "rtl" ? "rtl" : "ltr"
}
updateDir()
const observer = new MutationObserver(updateDir)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["dir"],
subtree: true,
})
return () => observer.disconnect()
}, [])
const percentage = ((value - min) / (max - min)) * 100
const isActive = isInteracting || isHovered
const displayValue = formatValue
? formatValue(value)
: value.toFixed(decimalsForStep(step))
// Fill + handle driven by a single motion value for imperative updates.
const fillPercent = useMotionValue(percentage)
const fillWidth = useTransform(fillPercent, (pct) => `${pct}%`)
const handleInsetStart = useTransform(
fillPercent,
(pct) => `max(4px, calc(${pct}% - 8px))`
)
// Rubber band: widens the track and pulls it toward the overshot edge.
// The track is anchored at the reading start (inset-inline-start: 0), so the
// shift direction is direction-aware: in LTR the track is pinned at the
// physical left (overshooting left shifts it: x = s, s < 0); in RTL it is
// pinned at the physical right (overshooting right shifts it: x = s, s > 0).
// The width grows by |s| either way, so the anchored edge stays put and only
// the overshot edge stretches.
const rubberStretch = useMotionValue(0)
const rubberWidth = useTransform(
rubberStretch,
(s) => `calc(100% + ${Math.abs(s)}px)`
)
const rubberX = useTransform(rubberStretch, (s) => {
if (s === 0) return 0
const overshootPastAnchor = dirRef.current === "rtl" ? s > 0 : s < 0
return overshootPastAnchor ? s : 0
})
// Sync from props when not interacting and no spring is in flight.
useEffect(() => {
if (!isInteracting && !animRef.current) {
fillPercent.jump(percentage)
}
}, [percentage, isInteracting, fillPercent])
const positionToValue = useCallback(
(clientX: number) => {
const rect = wrapperRectRef.current
if (!rect) return min
const sceneX = (clientX - rect.left) / scaleRef.current
const nativeWidth = wrapperRef.current?.offsetWidth ?? rect.width
let percent = clamp(sceneX / nativeWidth, 0, 1)
if (dirRef.current === "rtl") percent = 1 - percent
return clamp(min + percent * (max - min), min, max)
},
[min, max]
)
const percentFromValue = useCallback(
(v: number) => ((v - min) / (max - min)) * 100,
[min, max]
)
// Animate fill to a target percent, or jump instantly when the user prefers
// reduced motion. Position still updates — only the spring is skipped.
const animateFillTo = useCallback(
(targetPercent: number) => {
animRef.current?.stop()
if (shouldReduceMotion) {
fillPercent.jump(targetPercent)
animRef.current = null
return
}
animRef.current = animate(fillPercent, targetPercent, {
type: "spring",
stiffness: 300,
damping: 25,
mass: 0.8,
onComplete: () => {
animRef.current = null
},
})
},
[fillPercent, shouldReduceMotion]
)
const stopFillAnimation = useCallback(() => {
animRef.current?.stop()
animRef.current = null
}, [])
const computeRubberStretch = useCallback((clientX: number, sign: number) => {
const rect = wrapperRectRef.current
if (!rect) return 0
const distancePast = sign < 0 ? rect.left - clientX : clientX - rect.right
const overflow = Math.max(0, distancePast - DEAD_ZONE)
return (
sign * MAX_STRETCH * Math.sqrt(Math.min(overflow / MAX_CURSOR_RANGE, 1))
)
}, [])
const handlePointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault()
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
pointerDownPos.current = { x: e.clientX, y: e.clientY }
isClickRef.current = true
setIsInteracting(true)
pendingPointerFocusRef.current = true
setKeyboardFocusRing(false)
// Pointer interactions should move focus to the slider so subsequent
// keyboard input is received and focus styles match the active state.
trackRef.current?.focus({ preventScroll: true })
requestAnimationFrame(() => {
pendingPointerFocusRef.current = false
})
// Snapshot the wrapper rect so later math is immune to layout shifts.
const wrapper = wrapperRef.current
if (wrapper) {
const rect = wrapper.getBoundingClientRect()
wrapperRectRef.current = rect
scaleRef.current = rect.width / wrapper.offsetWidth
}
}, [])
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isInteracting || !pointerDownPos.current) return
const dx = e.clientX - pointerDownPos.current.x
const dy = e.clientY - pointerDownPos.current.y
if (isClickRef.current && Math.hypot(dx, dy) > CLICK_THRESHOLD) {
isClickRef.current = false
setIsDragging(true)
}
if (isClickRef.current) return
const rect = wrapperRectRef.current
if (rect && !shouldReduceMotion) {
if (e.clientX < rect.left) {
rubberStretch.jump(computeRubberStretch(e.clientX, -1))
} else if (e.clientX > rect.right) {
rubberStretch.jump(computeRubberStretch(e.clientX, 1))
} else {
rubberStretch.jump(0)
}
}
const newValue = positionToValue(e.clientX)
stopFillAnimation()
fillPercent.jump(percentFromValue(newValue))
setValue(roundValue(newValue, step))
},
[
isInteracting,
positionToValue,
percentFromValue,
setValue,
step,
fillPercent,
rubberStretch,
computeRubberStretch,
shouldReduceMotion,
stopFillAnimation,
]
)
const handlePointerUp = useCallback(
(e: React.PointerEvent) => {
if (!isInteracting) return
if (isClickRef.current) {
// Coarse sliders (≤10 positions) snap to the nearest step;
// continuous sliders keep the decile-magnetic behavior.
const rawValue = positionToValue(e.clientX)
const discreteSteps = (max - min) / step
const snapped =
discreteSteps <= 10
? clamp(min + Math.round((rawValue - min) / step) * step, min, max)
: snapToDecile(rawValue, min, max)
animateFillTo(percentFromValue(snapped))
setValue(roundValue(snapped, step))
}
if (!shouldReduceMotion && rubberStretch.get() !== 0) {
animate(rubberStretch, 0, {
type: "spring",
visualDuration: 0.35,
bounce: 0.15,
})
}
setIsInteracting(false)
setIsDragging(false)
pointerDownPos.current = null
},
[
isInteracting,
positionToValue,
percentFromValue,
setValue,
min,
max,
step,
animateFillTo,
rubberStretch,
shouldReduceMotion,
]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// Shift + Arrow is a Figma-style fast nudge: jumps by 10x the step,
// independent of the WAI-ARIA Page step (which scales with range).
const arrowStep = e.shiftKey ? step * 10 : step
// Right/Left arrows follow reading direction: in RTL, ArrowRight moves
// toward the reading start (decreases value) like a native range input.
const forwardKey = dirRef.current === "rtl" ? "ArrowLeft" : "ArrowRight"
const backwardKey = dirRef.current === "rtl" ? "ArrowRight" : "ArrowLeft"
const next = (() => {
switch (e.key) {
case forwardKey:
case "ArrowUp":
return value + arrowStep
case backwardKey:
case "ArrowDown":
return value - arrowStep
case "Home":
return min
case "End":
return max
default:
return null
}
})()
if (next === null) return
e.preventDefault()
setKeyboardFocusRing(true)
const snapped = roundValue(clamp(next, min, max), step)
animateFillTo(percentFromValue(snapped))
setValue(snapped)
},
[value, min, max, step, animateFillTo, percentFromValue, setValue]
)
const handleTrackFocus = useCallback(() => {
if (!pendingPointerFocusRef.current) {
setKeyboardFocusRing(true)
}
}, [])
const handleTrackBlur = useCallback(() => {
setKeyboardFocusRing(false)
}, [])
// Measure label + value to derive "dodge" thresholds so the handle fades
// when it would overlap either text.
const [dodge, setDodge] = useState({ left: 38, right: 72 })
useLayoutEffect(() => {
const wrapper = wrapperRef.current
if (!wrapper) return
const measure = () => {
const trackWidth = wrapper.offsetWidth
if (trackWidth <= 0) return
const labelEl = labelRef.current
const valueEl = valueRef.current
const left = labelEl
? ((LABEL_OFFSET + labelEl.offsetWidth + HANDLE_BUFFER) / trackWidth) *
100
: 38
const right = valueEl
? ((trackWidth - VALUE_OFFSET - valueEl.offsetWidth - HANDLE_BUFFER) /
trackWidth) *
100
: 72
setDodge((prev) => {
return prev.left === left && prev.right === right
? prev
: { left, right }
})
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(wrapper)
if (labelRef.current) observer.observe(labelRef.current)
if (valueRef.current) observer.observe(valueRef.current)
return () => observer.disconnect()
}, [label, displayValue])
const valueDodge = percentage < dodge.left || percentage > dodge.right
const handleOpacity = !isActive
? 0
: valueDodge
? 0.1
: isDragging
? 0.8
: 0.5
const discreteSteps = (max - min) / step
const hashMarkCount = discreteSteps <= 10 ? discreteSteps - 1 : 9
const hashMarkPct = (i: number) => {
return discreteSteps <= 10
? (((i + 1) * step) / (max - min)) * 100
: (i + 1) * 10
}
return (
<div
ref={wrapperRef}
data-slot="elastic-slider"
className={cn(
"[--elastic-slider-height:--spacing(9)] [--elastic-slider-radius:var(--radius-lg)]",
"[--elastic-slider-bg:var(--muted)]",
"[--elastic-slider-fill:var(--muted-foreground)]/10",
"[--elastic-slider-fill-active:var(--muted-foreground)]/20",
"[--elastic-slider-hash:var(--muted-foreground)]/30",
"[--elastic-slider-handle:var(--foreground)]",
"[--elastic-slider-label:var(--muted-foreground)]",
"[--elastic-slider-focus:var(--foreground)]",
"relative h-(--elastic-slider-height)",
className
)}
>
<motion.div
ref={trackRef}
role="slider"
tabIndex={0}
data-slot="elastic-slider-track"
data-active={isActive}
data-focus-visible={keyboardFocusRing}
aria-label={ariaLabel ?? label}
aria-orientation="horizontal"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={displayValue}
className={cn(
"group/elastic-slider absolute inset-y-0 start-0 cursor-pointer touch-none overflow-hidden rounded-(--elastic-slider-radius) bg-(--elastic-slider-bg) outline-none select-none",
"data-[focus-visible=true]:ring-2 data-[focus-visible=true]:ring-ring/50 data-[focus-visible=true]:ring-offset-1 data-[focus-visible=true]:ring-offset-background"
)}
style={{ width: rubberWidth, x: rubberX }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onFocus={handleTrackFocus}
onBlur={handleTrackBlur}
onKeyDown={handleKeyDown}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div
data-slot="elastic-slider-hash-marks"
aria-hidden="true"
className="pointer-events-none absolute inset-0"
>
{Array.from({ length: hashMarkCount }, (_, i) => (
<div
key={i}
className={cn(
"absolute top-1/2 h-2 w-px -translate-y-1/2 rounded-full transition-colors duration-200",
"bg-transparent group-data-[active=true]/elastic-slider:bg-(--elastic-slider-hash)"
)}
style={{
insetInlineStart: `${hashMarkPct(i)}%`,
transform: "translateX(-50%) translateY(-50%)",
}}
/>
))}
</div>
<motion.div
data-slot="elastic-slider-fill"
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-y-0 start-0 transition-colors",
"bg-(--elastic-slider-fill) group-data-[active=true]/elastic-slider:bg-(--elastic-slider-fill-active)"
)}
style={{ width: fillWidth }}
/>
<motion.div
data-slot="elastic-slider-handle"
aria-hidden="true"
className="pointer-events-none absolute top-1/2 h-5 w-1 rounded-full bg-(--elastic-slider-handle)"
style={{ insetInlineStart: handleInsetStart, y: "-50%" }}
animate={{
opacity: handleOpacity,
scaleX: isActive ? 1 : 0.25,
scaleY: isActive && valueDodge ? 0.75 : 1,
}}
transition={
shouldReduceMotion
? { duration: 0 }
: {
scaleX: {
type: "spring",
visualDuration: 0.25,
bounce: 0.15,
},
scaleY: { type: "spring", visualDuration: 0.2, bounce: 0.1 },
opacity: { duration: 0.15 },
}
}
/>
<span
ref={labelRef}
data-slot="elastic-slider-label"
aria-hidden="true"
className="pointer-events-none absolute start-3 top-1/2 inline-flex -translate-y-1/2 items-center text-sm/none font-medium text-(--elastic-slider-label) transition-colors"
>
{label}
</span>
<span
ref={valueRef}
data-slot="elastic-slider-value"
aria-hidden="true"
className={cn(
"pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 font-mono text-sm/none font-medium transition-colors",
"text-(--elastic-slider-label) group-data-[active=true]/elastic-slider:text-(--elastic-slider-focus)"
)}
>
{displayValue}
</span>
</motion.div>
</div>
)
}
registry/base/hooks/use-controllable-state.ts
// Thanks @radix-ui
import * as React from "react"
// use-layout-effect.tsx
// https://github.com/radix-ui/primitives/blob/main/packages/react/use-layout-effect/src/use-layout-effect.tsx
/**
* On the server, React emits a warning when calling `useLayoutEffect`.
* This is because neither `useLayoutEffect` nor `useEffect` run on the server.
* We use this safe version which suppresses the warning by replacing it with a noop on the server.
*
* See: https://reactjs.org/docs/hooks-reference.html#uselayouteffect
*/
const useLayoutEffect = globalThis?.document ? React.useLayoutEffect : () => {}
// use-controllable-state.tsx
// https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/use-controllable-state.tsx
// Prevent bundlers from trying to optimize the import
const useInsertionEffect: typeof useLayoutEffect =
(React as never)[" useInsertionEffect ".trim().toString()] || useLayoutEffect
type ChangeHandler<T> = (state: T) => void
type SetStateFn<T> = React.Dispatch<React.SetStateAction<T>>
interface UseControllableStateParams<T> {
prop?: T | undefined
defaultProp: T
onChange?: ChangeHandler<T>
caller?: string
}
export function useControllableState<T>({
prop,
defaultProp,
onChange = () => {},
caller,
}: UseControllableStateParams<T>): [T, SetStateFn<T>] {
const [uncontrolledProp, setUncontrolledProp, onChangeRef] =
useUncontrolledState({
defaultProp,
onChange,
})
const isControlled = prop !== undefined
const value = isControlled ? prop : uncontrolledProp
// OK to disable conditionally calling hooks here because they will always run
// consistently in the same environment. Bundlers should be able to remove the
// code block entirely in production.
/* eslint-disable react-hooks/rules-of-hooks */
if (process.env.NODE_ENV !== "production") {
const isControlledRef = React.useRef(prop !== undefined)
React.useEffect(() => {
const wasControlled = isControlledRef.current
if (wasControlled !== isControlled) {
const from = wasControlled ? "controlled" : "uncontrolled"
const to = isControlled ? "controlled" : "uncontrolled"
console.warn(
`${caller} is changing from ${from} to ${to}. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.`
)
}
isControlledRef.current = isControlled
}, [isControlled, caller])
}
/* eslint-enable react-hooks/rules-of-hooks */
const setValue = React.useCallback<SetStateFn<T>>(
(nextValue) => {
if (isControlled) {
const value = isFunction(nextValue) ? nextValue(prop) : nextValue
if (value !== prop) {
onChangeRef.current?.(value)
}
} else {
setUncontrolledProp(nextValue)
}
},
[isControlled, prop, setUncontrolledProp, onChangeRef]
)
return [value, setValue]
}
function useUncontrolledState<T>({
defaultProp,
onChange,
}: Omit<UseControllableStateParams<T>, "prop">): [
Value: T,
setValue: React.Dispatch<React.SetStateAction<T>>,
OnChangeRef: React.RefObject<ChangeHandler<T> | undefined>,
] {
const [value, setValue] = React.useState(defaultProp)
const prevValueRef = React.useRef(value)
const onChangeRef = React.useRef(onChange)
useInsertionEffect(() => {
onChangeRef.current = onChange
}, [onChange])
React.useEffect(() => {
if (prevValueRef.current !== value) {
onChangeRef.current?.(value)
prevValueRef.current = value
}
}, [value, prevValueRef])
return [value, setValue, onChangeRef]
}
function isFunction(value: unknown): value is (...args: never[]) => unknown {
return typeof value === "function"
}