"use client";
import * as React from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
export interface ElasticSliderProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange" | "children"> {
/** Visible label and accessible name of the slider. */
label: string;
/** Hide the label row visually (it stays available to screen readers). */
hideLabel?: boolean;
/** Current value (controlled). */
value?: number;
/** Initial value (uncontrolled). */
defaultValue?: number;
/** Called on every value change. */
onValueChange?: (value: number) => void;
/** Called when a drag ends or a key changes the value. */
onValueCommit?: (value: number) => void;
min?: number;
max?: number;
step?: number;
/** Turns a value into text for the bubble, label row and aria-valuetext. */
formatValue?: (value: number) => string;
/** Maximum distance in px the track rubber-bands past either end. */
elasticity?: number;
/** Spring that snaps the stretched track back. */
spring?: Spring;
/** When the value bubble above the thumb is shown. */
bubble?: "always" | "active" | "off";
/** Fill, thumb ring and focus colour. */
accent?: string;
/** Icon at the start of the track (e.g. volume low). */
startIcon?: React.ReactNode;
/** Icon at the end of the track (e.g. volume high). */
endIcon?: React.ReactNode;
disabled?: boolean;
/** Submits the value with a form under this name. */
name?: string;
}
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
/** Thumb radius in px: the thumb travels inside the track so it never overhangs the ends. */
const THUMB_R = 10;
function decimalsOf(step: number) {
const s = String(step);
return s.includes(".") ? s.length - s.indexOf(".") - 1 : 0;
}
/** Soft limit: grows 1:1 at first, then flattens toward `limit`. */
function rubberBand(distance: number, limit: number) {
if (limit <= 0) return 0;
return limit * (1 - Math.exp(-distance / (limit * 1.6)));
}
/**
* A range slider whose track stretches like rubber when dragged past either end and springs
* back on release. Implements the WAI-ARIA slider pattern on the thumb; the stretch is driven by
* motion values, so only stepped value changes re-render.
*/
export function ElasticSlider({
label,
hideLabel = false,
value: valueProp,
defaultValue = 50,
onValueChange,
onValueCommit,
min = 0,
max = 100,
step = 1,
formatValue = (v) => String(v),
elasticity = 28,
spring = { stiffness: 420, damping: 14, mass: 0.6 },
bubble = "active",
accent = "#FF7448",
startIcon,
endIcon,
disabled = false,
name,
className = "",
style,
...props
}: ElasticSliderProps) {
const labelId = React.useId();
const reduceMotion = useReducedMotion();
const [internal, setInternal] = React.useState(() => clamp(defaultValue, min, max));
const value = clamp(valueProp ?? internal, min, max);
const [pressed, setPressed] = React.useState(false);
const [keyboardFocus, setKeyboardFocus] = React.useState(false);
const trackRef = React.useRef<HTMLDivElement>(null);
const thumbRef = React.useRef<HTMLDivElement>(null);
const dragging = React.useRef(false);
const latest = React.useRef(value);
// Signed overshoot in px: negative past the start, positive past the end.
const overflow = useMotionValue(0);
const trackWidth = useMotionValue(1);
const stretchX = useTransform([overflow, trackWidth], ([o, w]: number[]) => 1 + Math.abs(o) / w);
// The band thins slightly as it stretches, like real elastic.
const stretchY = useTransform([overflow, trackWidth], ([o, w]: number[]) => 1 - Math.min((Math.abs(o) / w) * 2.2, 0.35));
const originX = useTransform(overflow, (o) => (o < 0 ? 1 : 0));
const startIconX = useTransform(overflow, (o) => Math.min(o, 0));
const endIconX = useTransform(overflow, (o) => Math.max(o, 0));
const startIconScale = useTransform(overflow, (o) => 1 + Math.max(-o, 0) / 90);
const endIconScale = useTransform(overflow, (o) => 1 + Math.max(o, 0) / 90);
const range = max - min || 1;
const fraction = (value - min) / range;
// Thumb centre (and fill end) inset by the thumb radius on both sides.
const thumbLeft = `calc(${THUMB_R}px + (100% - ${THUMB_R * 2}px) * ${fraction})`;
const text = formatValue(value);
const snap = (raw: number) => {
const stepped = Math.round((raw - min) / step) * step + min;
return clamp(Number(stepped.toFixed(decimalsOf(step))), min, max);
};
const commitValue = (next: number) => {
latest.current = next;
if (next === value) return;
if (valueProp === undefined) setInternal(next);
onValueChange?.(next);
};
const springBack = () => {
animate(overflow, 0, reduceMotion ? { duration: 0 } : { type: "spring", ...spring });
};
/** Maps a pointer x position to a value, and any distance past the ends to rubber-band stretch. */
const updateFromPointer = (clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return;
trackWidth.set(rect.width);
const ratio = (clientX - rect.left - THUMB_R) / Math.max(rect.width - THUMB_R * 2, 1);
commitValue(snap(min + clamp(ratio, 0, 1) * range));
if (reduceMotion) return;
if (clientX < rect.left) overflow.set(-rubberBand(rect.left - clientX, elasticity));
else if (clientX > rect.right) overflow.set(rubberBand(clientX - rect.right, elasticity));
else overflow.set(0);
};
const endDrag = () => {
if (!dragging.current) return;
dragging.current = false;
setPressed(false);
springBack();
onValueCommit?.(latest.current);
};
/** A small bump when the keyboard pushes against an end. */
const bump = (direction: 1 | -1) => {
if (reduceMotion || elasticity <= 0) return;
const width = trackRef.current?.offsetWidth;
if (width) trackWidth.set(width);
animate(overflow, direction * Math.min(elasticity, 10), { duration: 0.08, ease: "easeOut" }).then(springBack);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const big = Math.max(step, snap(min + range / 10) - min);
let next: number;
switch (event.key) {
case "ArrowRight":
case "ArrowUp":
next = value + step;
break;
case "ArrowLeft":
case "ArrowDown":
next = value - step;
break;
case "PageUp":
next = value + big;
break;
case "PageDown":
next = value - big;
break;
case "Home":
next = min;
break;
case "End":
next = max;
break;
default:
return;
}
event.preventDefault();
if ((next > max && value === max) || (next < min && value === min)) {
bump(next > max ? 1 : -1);
return;
}
const snapped = snap(next);
commitValue(snapped);
onValueCommit?.(snapped);
};
const showBubble = bubble === "always" || (bubble === "active" && (pressed || keyboardFocus));
return (
<div
className={`group/es flex w-full select-none flex-col gap-2.5 ${disabled ? "opacity-50" : ""} ${className}`}
style={{ ["--es-accent" as string]: accent, ...style }}
{...props}
>
<div className={hideLabel ? "sr-only" : "flex items-baseline justify-between gap-3"}>
<span id={labelId} className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{label}
</span>
<span aria-hidden="true" className="font-mono text-xs tabular-nums text-zinc-900 dark:text-zinc-100">
{text}
</span>
</div>
{/* Extra room above the track keeps the bubble clear of the label row. */}
<div className={`flex items-center gap-3 ${bubble === "off" ? "" : "mt-3.5"}`}>
{startIcon && (
<motion.span
aria-hidden="true"
className="inline-flex shrink-0 text-zinc-500 dark:text-zinc-400 [&_svg]:size-4"
style={{ x: startIconX, scale: startIconScale }}
>
{startIcon}
</motion.span>
)}
<div
ref={trackRef}
className={`relative h-8 flex-1 touch-pan-y ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
onPointerDown={(event) => {
if (disabled || event.button !== 0) return;
// Keep text from being selected and move focus to the thumb ourselves.
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
thumbRef.current?.focus({ preventScroll: true });
dragging.current = true;
setPressed(true);
updateFromPointer(event.clientX);
}}
onPointerMove={(event) => {
if (dragging.current) updateFromPointer(event.clientX);
}}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={endDrag}
>
{/* The band: stretches from the opposite end when pulled past min or max. */}
<motion.div
aria-hidden="true"
className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-zinc-900/10 transition-[height] duration-200 group-hover/es:h-2 dark:bg-white/10"
style={{ scaleX: stretchX, scaleY: stretchY, originX }}
>
<div className="absolute inset-y-0 left-0 rounded-full bg-[var(--es-accent)]" style={{ width: thumbLeft }} />
</motion.div>
<motion.div className="pointer-events-none absolute top-1/2" style={{ left: thumbLeft, x: overflow }}>
<motion.div
aria-hidden="true"
className="absolute bottom-full left-0 mb-4 -translate-x-1/2 rounded-md bg-zinc-900 px-2 py-1 font-mono text-[11px] tabular-nums text-white shadow-sm dark:bg-white dark:text-zinc-900"
initial={false}
animate={showBubble ? { opacity: 1, y: 0, scale: 1 } : { opacity: 0, y: 4, scale: 0.9 }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 30 }}
>
{text}
</motion.div>
<motion.div
ref={thumbRef}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-labelledby={labelId}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={text}
aria-orientation="horizontal"
aria-disabled={disabled || undefined}
onKeyDown={handleKeyDown}
onFocus={(event) => setKeyboardFocus(event.currentTarget.matches(":focus-visible"))}
onBlur={() => setKeyboardFocus(false)}
className="pointer-events-auto absolute left-0 top-0 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-zinc-900/15 bg-white shadow-[0_1px_3px_rgb(0_0_0/0.18)] outline-none ring-[var(--es-accent)] ring-offset-2 ring-offset-white focus-visible:ring-2 dark:border-white/20 dark:bg-zinc-100 dark:ring-offset-[#131619]"
initial={false}
animate={{ scale: pressed && !reduceMotion ? 1.25 : 1 }}
transition={{ type: "spring", stiffness: 520, damping: 26 }}
>
<span aria-hidden="true" className="absolute inset-[5px] rounded-full bg-[var(--es-accent)]" />
</motion.div>
</motion.div>
</div>
{endIcon && (
<motion.span
aria-hidden="true"
className="inline-flex shrink-0 text-zinc-500 dark:text-zinc-400 [&_svg]:size-4"
style={{ x: endIconX, scale: endIconScale }}
>
{endIcon}
</motion.span>
)}
</div>
{name !== undefined && <input type="hidden" name={name} value={value} />}
</div>
);
}