Forms
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. Built on a native range input.
Download
- React
- Tailwind CSS v4
- motion
components/ui/range-flow.tsx
"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 (
<div className={className} style={{ "--r1": colors[0], "--r2": colors[1] } as CSSProperties}>
<div className="mb-3 flex items-baseline justify-between gap-3">
<label htmlFor={id} className="text-sm font-medium text-white/80">
{label}
</label>
<output htmlFor={id} className="font-mono text-sm text-white/60 tabular-nums">
{format(v)}
</output>
</div>
<div className="relative h-10" onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)}>
<div aria-hidden className="absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-white/10" />
<div aria-hidden className="absolute inset-x-2.5 top-1/2 h-2 -translate-y-1/2">
<motion.div
className="absolute inset-y-0 -left-2.5 rounded-full"
style={{ width: fill, background: "linear-gradient(90deg, var(--r1), var(--r2))" }}
/>
<motion.div className="absolute top-1/2" style={{ left }}>
<motion.div
className={`size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-[3px] border-[var(--r2)] bg-white shadow-[0_0_16px_-2px_var(--r2)] transition-transform duration-200 motion-reduce:transition-none ${
drag ? "scale-110" : ""
} ${focus ? "ring-4 ring-[color-mix(in_srgb,var(--r2)_35%,transparent)]" : ""}`}
/>
<motion.div
className="absolute bottom-5 left-0 origin-bottom"
style={{ rotate: reduce ? 0 : swing, x: "-50%" }}
initial={false}
animate={{ opacity: bubble ? 1 : 0, scale: bubble ? 1 : 0.6, y: bubble ? 0 : 6 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 30 }}
>
<div className="relative mb-1.5 rounded-lg bg-white px-2 py-1 font-mono text-xs font-semibold whitespace-nowrap text-[#141026] tabular-nums">
{format(v)}
<span className="absolute top-full left-1/2 size-2 -translate-x-1/2 -translate-y-1 rotate-45 bg-white" />
</div>
</motion.div>
</motion.div>
</div>
<input
id={id}
type="range"
name={name}
min={min}
max={max}
step={step}
value={v}
aria-valuetext={format(v)}
onChange={(e) => {
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"
/>
</div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/range-flow.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/range-flow-demo.tsx
"use client"; import { RangeFlow } from "@/components/ui/range-flow"; const money = (n: number) => `$${n.toLocaleString("en-US")}`; export default function RangeFlowExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-5"> <form className="flex w-full max-w-sm flex-col gap-8" onSubmit={(e) => e.preventDefault()}> <RangeFlow label="Budget" min={500} max={20000} step={500} defaultValue={5000} format={money} name="budget" className="w-full" /> <RangeFlow label="Volume" defaultValue={60} format={(n) => `${n}%`} colors={["#f472b6", "#fbbf24"]} className="w-full" /> </form> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| label | string | — | Field name above the slider. |
| min / max / step | number | 0 / 100 / 1 | Range and step size. |
| value | number | — | Value (controlled). |
| defaultValue | number | min | Value at start (uncontrolled). |
| onChange | (value: number) => void | — | Called while sliding. |
| format | (value: number) => string | String | Text for the value, on screen and for screen readers. |
| name | string | — | Form field name. |
| colors | [string, string] | ["#a78bfa", "#22d3ee"] | Fill gradient start and end. |
| className | string | "w-full max-w-sm" | Wrapper look. |