"use client"; import { useEffect, useId, useLayoutEffect, useRef, useState, type FocusEvent, type KeyboardEvent, type PointerEvent, type ReactNode, } from "react"; import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionStyle, type MotionValue, type Variants, } from "motion/react"; import { cn } from "@/lib/utils"; export type SliderValue = number | [number, number]; export interface SliderMark { value: number; label?: string; } export interface SliderProps { label: string; value?: T; defaultValue?: T; onValueChange?: (value: T) => void; onValueCommit?: (value: T) => void; min?: number; max?: number; step?: number; largeStep?: number; marks?: (number | SliderMark)[]; minStepsBetweenThumbs?: number; format?: (value: number) => string; showValue?: boolean; thumbLabels?: [string, string]; start?: ReactNode; end?: ReactNode; description?: ReactNode; name?: string; disabled?: boolean; className?: string; } type Part = { key: string; digit: number } | { key: string; text: string }; type Drag = { pointer: number; index: number | null; grab: number; x: number; raw: number; samples: { t: number; p: number }[]; }; const motionTokens = { spring: { snappy: { visualDuration: 0.35, bounce: 0.15 }, morph: { visualDuration: 0.45, bounce: 0.1 }, smooth: { visualDuration: 0.4, bounce: 0 }, }, duration: { instant: 0.08, fast: 0.18, normal: 0.3, }, blur: { subtle: 1.5, soft: 3, }, ease: { enter: [0.16, 1, 0.3, 1] as const, standard: [0.2, 0, 0, 1] as const, }, }; const enterEase = [...motionTokens.ease.enter] as [number, number, number, number]; const exitEase = [...motionTokens.ease.standard] as [number, number, number, number]; const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; const physical = ({ visualDuration, bounce, }: { visualDuration: number; bounce: number; }) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: "spring" as const, stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta: 0.002, restSpeed: 0.02, }; }; const thumbSpring = physical(motionTokens.spring.snappy); const kickSpring = physical(motionTokens.spring.morph); const STRETCH = 9; const BUMP_PX = 150; const SNAP_PX = 12; const rubber = (distance: number, limit = STRETCH) => Math.sign(distance) * (1 - 1 / ((Math.abs(distance) * 0.55) / limit + 1)) * limit; const project = (velocity: number) => velocity * 0.099; const decimalsOf = (value: number) => (String(value).split(".")[1] ?? "").length; const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value)); const isDigit = (char: string) => char >= "0" && char <= "9"; function partsOf(text: string): Part[] { const chars = [...text]; const first = chars.findIndex(isDigit); const last = chars.length - 1 - [...chars].reverse().findIndex(isDigit); let place = chars.filter(isDigit).length; return chars.map((char, index): Part => { if (first < 0 || index < first) return { key: `p${index}${char}`, text: char }; if (index > last) return { key: `s${chars.length - index}${char}`, text: char }; if (isDigit(char)) return { key: `d${--place}`, digit: Number(char) }; return { key: `g${place}${char}`, text: char }; }); } const slot: Variants = { enter: (direction: number) => ({ width: 0, scale: 0.6, opacity: 0, y: `${direction * 0.3}em`, filter: `blur(${motionTokens.blur.soft}px)`, }), center: { width: "auto", scale: 1, opacity: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" }, transition: { width: motionTokens.spring.morph, scale: motionTokens.spring.morph, opacity: motionTokens.spring.morph, y: motionTokens.spring.snappy, filter: { duration: 0.22, ease: enterEase }, }, }, exit: (direction: number) => ({ width: 0, scale: 0.6, opacity: 0, y: `${direction * -0.3}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { width: motionTokens.spring.smooth, scale: motionTokens.spring.smooth, y: { duration: motionTokens.duration.fast, ease: exitEase }, opacity: { duration: motionTokens.duration.instant }, filter: { duration: motionTokens.duration.instant }, }, }), }; const still: Variants = { enter: { width: "auto", scale: 1, opacity: 0, y: 0, filter: "none" }, center: { width: "auto", scale: 1, opacity: 1, y: 0, filter: "none", transition: { duration: motionTokens.duration.instant }, }, exit: { width: 0, opacity: 0, transition: { duration: 0 } }, }; function Glyph({ position, digit, }: { position: MotionValue; digit: number; }) { const offset = (current: number) => ((((digit - current) % 10) + 15) % 10) - 5; const y = useTransform(position, (current) => `${offset(current) * 1.05}em`); const opacity = useTransform( position, (current) => Math.max(0, 1 - Math.abs(offset(current)) ** 1.5 * 1.1) ); const visibility = useTransform(position, (current) => Math.abs(offset(current)) >= 1 ? "hidden" : "visible" ); const filter = useTransform(position, (current) => { const distance = Math.abs(offset(current)); return distance < 0.02 || distance >= 1 ? "none" : `blur(${(distance * motionTokens.blur.soft * 0.75).toFixed(2)}px)`; }); return ( {digit} ); } function Wheel({ digit, direction }: { digit: number; direction: number }) { const reduced = useReducedMotion(); const position = useMotionValue(digit); const wheel = useRef({ digit, target: digit }); useIsomorphicLayoutEffect(() => { const state = wheel.current; if (state.digit === digit) return; state.target += direction > 0 ? (digit - state.digit + 10) % 10 : -((state.digit - digit + 10) % 10); state.digit = digit; if (reduced) position.jump(state.target); else animate(position, state.target, thumbSpring); }, [digit, direction, position, reduced]); return ( <> 0 {DIGITS.map((item) => ( ))} ); } function RollingNumber({ value, text }: { value: number; text: string }) { const reduced = useReducedMotion(); const [trail, setTrail] = useState({ value, direction: 1 }); if (trail.value !== value) { setTrail({ value, direction: value > trail.value ? 1 : -1 }); } const direction = trail.value === value ? trail.direction : value > trail.value ? 1 : -1; return ( {partsOf(text).map((part) => ( {"digit" in part ? ( ) : ( part.text )} ))} ); } const bubbleIn = { opacity: 0, scale: 0.6, y: 6, filter: `blur(${motionTokens.blur.subtle}px)`, }; const bubbleRest = { opacity: 1, scale: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" }, }; const bubbleOut = { opacity: 0, scale: 0.8, y: 4, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: exitEase }, }; const bubbleEnter = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enterEase }, filter: { duration: motionTokens.duration.fast, ease: enterEase }, }; interface ThumbProps { quiet: boolean; index: number; shown: MotionValue; value: number; text: string; low: number; high: number; ariaLabel?: string; labelledBy?: string; active: boolean; lifted: boolean; bubble: boolean; disabled?: boolean; onKeyDown: (event: KeyboardEvent) => void; onFocus: (event: FocusEvent) => void; onBlur: () => void; } function Thumb({ quiet, index, shown, value, text, low, high, ariaLabel, labelledBy, active, lifted, bubble, disabled, onKeyDown, onFocus, onBlur, }: ThumbProps) { const reduced = useReducedMotion(); const x = useTransform(shown, (current) => `${current - 100}%`); return ( {bubble && ( )} ); } export function Slider({ label, value, defaultValue, onValueChange, onValueCommit, min = 0, max = 100, step: stepProp = 1, largeStep, marks, minStepsBetweenThumbs = 0, format, showValue = true, thumbLabels, start, end, description, name, disabled, className, }: SliderProps) { const reduced = useReducedMotion(); const labelId = useId(); const step = stepProp > 0 ? stepProp : 1; const span = max - min || 1; const isRange = Array.isArray(value ?? defaultValue); const toArray = (input: SliderValue | undefined) => input === undefined ? [min, max].slice(0, isRange ? 2 : 1) : Array.isArray(input) ? [input[0], input[1]] : [input]; const [internal, setInternal] = useState(() => toArray(defaultValue)); const values = value === undefined ? internal : toArray(value); const decimals = Math.max(decimalsOf(step), decimalsOf(min)); const formatValue = format ?? ((input: number) => input.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals, })); const gap = isRange ? minStepsBetweenThumbs * step : 0; const pct = (input: number) => ((input - min) / span) * 100; const valueAt = (percent: number) => min + (percent / 100) * span; const snap = (input: number) => Number((min + Math.round((input - min) / step) * step).toFixed(decimals)); const lowOf = (index: number, current: number[]) => index === 1 ? current[0] + gap : min; const highOf = (index: number, current: number[]) => index === 0 && isRange ? current[1] - gap : max; const settle = (index: number, input: number, current: number[]) => clamp( snap(clamp(input, min, max)), lowOf(index, current), highOf(index, current) ); const trackRef = useRef(null); const thumbsRef = useRef(null); const latest = useRef(values); const goal = useRef(values.map(pct)); const drag = useRef(null); const pointerFocus = useRef(false); const lingerTimer = useRef(undefined); const [dragging, setDragging] = useState(null); const [keyFocus, setKeyFocus] = useState(null); const [linger, setLinger] = useState(null); const [lastActive, setLastActive] = useState(isRange ? 1 : 0); const [quiet, setQuiet] = useState(null); const pos0 = useMotionValue(pct(values[0])); const pos1 = useMotionValue(pct(values[1] ?? values[0])); const lag0 = useMotionValue(0); const lag1 = useMotionValue(0); const shown0 = useTransform(() => pos0.get() + lag0.get()); const shown1 = useTransform(() => pos1.get() + lag1.get()); const clipPath = useTransform(() => { const s0 = clamp(shown0.get(), 0, 100); const s1 = clamp(shown1.get(), 0, 100); const from = isRange ? Math.min(s0, s1) : 0; const to = isRange ? Math.max(s0, s1) : s0; return `inset(0 ${(100 - to).toFixed(3)}% 0 ${from.toFixed(3)}% round 999px)`; }); const thumbs = [ { pos: pos0, lag: lag0, shown: shown0 }, { pos: pos1, lag: lag1, shown: shown1 }, ]; const emit = (next: number[]) => (isRange ? [next[0], next[1]] : next[0]) as T; function commit(index: number, next: number) { const current = latest.current; if (current[index] === next) return; const updated = current.map((item, at) => (at === index ? next : item)); latest.current = updated; if (value === undefined) setInternal(updated); onValueChange?.(emit(updated)); } useIsomorphicLayoutEffect(() => { latest.current = values; values.forEach((item, index) => { const target = pct(item); const thumb = thumbs[index]; if (drag.current?.index === index || goal.current[index] === target) return; goal.current[index] = target; const from = thumb.pos.get() + thumb.lag.get(); thumb.lag.jump(0); thumb.pos.jump(from); if (reduced) thumb.pos.jump(target); else animate(thumb.pos, target, thumbSpring); }); }); useEffect(() => () => window.clearTimeout(lingerTimer.current), []); const thumbNode = (index: number) => thumbsRef.current?.querySelector(`[data-index="${index}"]`); function focusFromPointer(index: number) { const node = thumbNode(index); if (!node || document.activeElement === node) return; setQuiet(index); pointerFocus.current = true; node.focus({ preventScroll: true }); pointerFocus.current = false; } const trackWidth = () => trackRef.current?.getBoundingClientRect().width || 1; function nearest(at: number, current: number[]) { if (!isRange) return 0; const low = Math.abs(at - pct(current[0])); const high = Math.abs(at - pct(current[1])); return low === high ? (at < pct(current[0]) ? 0 : 1) : low < high ? 0 : 1; } function holdBubble(index: number) { window.clearTimeout(lingerTimer.current); setLinger(index); lingerTimer.current = window.setTimeout(() => setLinger(null), 700); } function begin(state: Drag, index: number, at: number, press: boolean) { const thumb = thumbs[index]; const from = thumb.pos.get() + thumb.lag.get(); state.index = index; state.grab = press ? 0 : at - from; thumb.lag.jump(0); thumb.pos.jump(from); setDragging(index); setLastActive(index); window.clearTimeout(lingerTimer.current); setLinger(null); focusFromPointer(index); if (press) follow(state, at, 0, true); } function follow(state: Drag, at: number, time: number, press = false) { const index = state.index; if (index === null) return; const thumb = thumbs[index]; const current = latest.current; const width = trackWidth(); const raw = at - state.grab; const low = pct(lowOf(index, current)); const high = pct(highOf(index, current)); const edge = clamp(raw, low, high); const placed = reduced ? edge : edge + (rubber(((raw - edge) / 100) * width) / width) * 100; if (press && !reduced) { const from = thumb.pos.get(); thumb.pos.jump(placed); thumb.lag.jump(from - placed); animate(thumb.lag, 0, thumbSpring); } else { thumb.pos.set(placed); } state.raw = raw; state.samples.push({ t: time, p: placed }); if (state.samples.length > 6) state.samples.shift(); commit(index, settle(index, valueAt(raw), current)); } function onPointerDown(event: PointerEvent) { if (disabled || event.button !== 0 || !event.isPrimary) return; const rect = trackRef.current?.getBoundingClientRect(); if (!rect) return; const at = ((event.clientX - rect.left) / (rect.width || 1)) * 100; const grabbed = (event.target as HTMLElement).closest( "[data-thumb]" ); const current = latest.current; event.currentTarget.setPointerCapture(event.pointerId); const state: Drag = { pointer: event.pointerId, index: null, grab: 0, x: event.clientX, raw: at, samples: [], }; drag.current = state; if (grabbed && isRange && current[0] === current[1]) { focusFromPointer(Number(grabbed.dataset.index)); return; } begin( state, grabbed ? Number(grabbed.dataset.index) : nearest(at, current), at, !grabbed ); state.samples = [ { t: event.timeStamp, p: thumbs[state.index ?? 0].pos.get() }, ]; } function onPointerMove(event: PointerEvent) { const state = drag.current; if (!state || state.pointer !== event.pointerId) return; const rect = trackRef.current?.getBoundingClientRect(); if (!rect) return; const at = ((event.clientX - rect.left) / (rect.width || 1)) * 100; if (state.index === null) { const dx = event.clientX - state.x; if (Math.abs(dx) < 2) return; begin(state, dx < 0 ? 0 : 1, at - (dx / (rect.width || 1)) * 100, false); } follow(state, at, event.timeStamp); } function onPointerEnd(event: PointerEvent) { const state = drag.current; if (!state || state.pointer !== event.pointerId) return; drag.current = null; const index = state.index; if (index === null) return; const thumb = thumbs[index]; const current = latest.current; const width = trackWidth(); const samples = state.samples; const first = samples[0]; const last = samples[samples.length - 1]; const elapsed = last && first ? (last.t - first.t) / 1000 : 0; const velocity = (elapsed > 0.008 && event.timeStamp - last.t < 60 ? (last.p - first.p) / elapsed : 0) + thumb.lag.getVelocity(); const stepPct = (step / span) * 100; const carry = event.type === "pointerup" && (stepPct / 100) * width >= SNAP_PX ? clamp(project(velocity), -stepPct, stepPct) : 0; const next = settle(index, valueAt(state.raw + carry), current); const from = thumb.pos.get() + thumb.lag.get(); const target = pct(next); thumb.lag.jump(0); thumb.pos.jump(from); goal.current[index] = target; if (reduced) thumb.pos.jump(target); else animate(thumb.pos, target, { ...thumbSpring, velocity }); commit(index, next); setDragging(null); holdBubble(index); onValueCommit?.(emit(latest.current)); } function onKeyDown(index: number, event: KeyboardEvent) { if (disabled) return; const current = latest.current; const now = current[index]; const large = largeStep ?? Math.max(step, snap(min + span / 10) - min); const moves: Record = { ArrowRight: step, ArrowUp: step, ArrowLeft: -step, ArrowDown: -step, PageUp: large, PageDown: -large, }; let wanted: number; if (event.key === "Home") wanted = lowOf(index, current); else if (event.key === "End") wanted = highOf(index, current); else if (event.key in moves) wanted = now + (event.shiftKey && /^Arrow/.test(event.key) ? Math.sign(moves[event.key]) * large : moves[event.key]); else return; event.preventDefault(); setKeyFocus(index); setQuiet(null); setLastActive(index); const next = settle(index, wanted, current); if (next === now) { const toward = Math.sign(wanted - now); if (toward && !reduced) animate(thumbs[index].pos, goal.current[index], { ...kickSpring, velocity: ((toward * BUMP_PX) / trackWidth()) * 100, }); return; } commit(index, next); onValueCommit?.(emit(latest.current)); } function jumpTo(target: number) { if (disabled) return; const current = latest.current; const index = nearest(pct(target), current); const next = settle(index, target, current); setLastActive(index); focusFromPointer(index); if (next === current[index]) return; commit(index, next); onValueCommit?.(emit(latest.current)); } const markList = (marks ?? []) .map((mark) => (typeof mark === "number" ? ({ value: mark } as SliderMark) : mark)) .filter((mark) => mark.value >= min && mark.value <= max); const ticks = markList.filter((mark) => mark.value > min && mark.value < max); const labelled = markList.filter((mark) => mark.label); const inRange = (mark: number) => isRange ? mark >= values[0] && mark <= values[1] : mark <= values[0]; const names = thumbLabels ?? [ `${label}, minimum`, `${label}, maximum`, ]; return (
0 || undefined} >
{label} {showValue && ( )}
{start && (
{start}
)}
event.preventDefault()} >
{ticks.map((mark) => ( ))} {ticks.map((mark) => ( ))}
{values.map((item, index) => ( onKeyDown(index, event)} onFocus={(event) => { if ( !pointerFocus.current && event.currentTarget.matches(":focus-visible") ) { setKeyFocus(index); } }} onBlur={() => { setKeyFocus((current) => current === index ? null : current ); setQuiet((current) => current === index ? null : current ); }} /> ))}
{labelled.length > 0 && ( )}
{end && (
{end}
)}
{description && (

{description}

)} {name && values.map((item, index) => ( ))}
); } export default Slider;