"use client"; import { useEffect, useId, useState, type CSSProperties } from "react"; import { motion, useReducedMotion, useSpring, useTransform, useVelocity } from "motion/react"; type RangeFlowProps = { /** Field name shown above the slider. */ label: string; min?: number; max?: number; step?: number; /** Value (controlled). */ value?: number; /** Value at start (uncontrolled). */ defaultValue?: number; /** Called with the new value while sliding. */ onChange?: (value: number) => void; /** Turns the number into text, e.g. (n) => `$${n}`. Used on screen and for screen readers. */ format?: (value: number) => string; /** Form field name, so the value is sent with a normal form. */ name?: string; /** Track fill colours: [start, end]. */ colors?: [string, string]; /** Wrapper look (width etc.). */ className?: string; }; /* RangeFlow: a slider whose fill and thumb glide on a spring. The value bubble hangs from the thumb and swings with how fast you drag. A native range input does the work, so keyboard and touch are free. */ export function RangeFlow({ label, min = 0, max = 100, step = 1, value, defaultValue, onChange, format = String, name, colors = ["#a78bfa", "#22d3ee"], className = "w-full max-w-sm", }: RangeFlowProps) { const reduce = useReducedMotion(); const id = useId(); const [inner, setInner] = useState(defaultValue ?? min); const [hover, setHover] = useState(false); const [focus, setFocus] = useState(false); const [drag, setDrag] = useState(false); const v = value ?? inner; const pct = max > min ? ((v - min) / (max - min)) * 100 : 0; const pos = useSpring(pct, reduce ? { stiffness: 10000, damping: 500 } : { stiffness: 320, damping: 30, mass: 0.6 }); useEffect(() => pos.set(pct), [pos, pct]); const left = useTransform(pos, (p) => `${p}%`); const fill = useTransform(pos, (p) => `calc(${p}% + 10px)`); const velocity = useVelocity(pos); const swing = useTransform(velocity, [-300, 300], [22, -22], { clamp: true }); const bubble = hover || focus || drag; return (
{format(v)}
setHover(true)} onPointerLeave={() => setHover(false)}>
{format(v)}
{ const n = Number(e.target.value); if (value === undefined) setInner(n); onChange?.(n); }} onFocus={(e) => setFocus(e.currentTarget.matches(":focus-visible"))} onBlur={() => setFocus(false)} onKeyDown={() => setFocus(true)} onPointerDown={() => setDrag(true)} onPointerUp={() => setDrag(false)} onPointerCancel={() => setDrag(false)} className="absolute inset-0 h-full w-full cursor-pointer appearance-none opacity-0 [&::-moz-range-thumb]:size-5 [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none" />
); }