Range Double Handle
Two handles bound a range and the fill between them tracks both, one-to-one under the pointer and settling on a spring from the keyboard.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Range Double Handle
*
* Two handles bound a range and the fill between them tracks both. Under
* the pointer the handles follow one-to-one; from the keyboard they settle
* into each new step on a spring, so a held arrow key reads as a glide
* rather than a series of jumps.
*
* Self-contained: depends only on `react` and `motion`. Surfaces are mixed
* from the inherited text color, so the control reads correctly on a light
* page and on a dark one.
* Works with zero props; tune via `variant`, `min`, `max`, `step`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type RangeDoubleHandleProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Lower bound of the scale. */
min?: number;
/** Upper bound of the scale. */
max?: number;
/** Granularity of a single arrow key press. */
step?: number;
/** Starting position of the lower handle. */
defaultLow?: number;
/** Starting position of the upper handle. */
defaultHigh?: number;
/** Prefix used in the readout and the value bubble. */
unit?: string;
/** What the range is measuring. */
label?: string;
/** Accent for the fill, the handles and focus. */
accent?: string;
/** Track length in px. Fixed so handle travel is exact transform math. */
trackWidth?: number;
/** Fires whenever either handle moves. */
onRangeChange?: (low: number, high: number) => void;
};
type VariantConfig = {
/** Spring used for keyboard steps — never while a finger is down. */
settle: { type: "spring"; stiffness: number; damping: number };
/** How much the held handle grows. */
grip: number;
/** How far the value bubble rises as it appears, in px. */
rise: number;
fade: number;
};
// Quality rule: a handle under the pointer must be exactly under the
// pointer, so dragging is a direct write with no transition at all — a
// spring there would lag the finger and read as broken. The spring is
// reserved for keyboard steps, and sits at or above a 0.8 damping ratio
// so a handle never rings around the value it just landed on. Only the
// handles and the fill scale; the readout is text and stays put.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Tight and instrument-like. For a filter panel used constantly.
subtle: {
settle: { type: "spring", stiffness: 730, damping: 53 },
grip: 1.04,
rise: 3,
fade: 0.09,
},
// One soft settle per step. The all-purpose setting.
default: {
settle: { type: "spring", stiffness: 520, damping: 42 },
grip: 1.14,
rise: 6,
fade: 0.16,
},
// A little more give in the handle and the bubble, same single settle.
playful: {
settle: { type: "spring", stiffness: 400, damping: 34 },
grip: 1.24,
rise: 9,
fade: 0.23,
},
};
const HANDLE = 20;
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
type Handle = "low" | "high";
export default function RangeDoubleHandle({
variant = "default",
min = 0,
max = 1000,
step = 10,
defaultLow = 200,
defaultHigh = 720,
unit = "$",
label = "Price range",
accent = "#5B5BD6",
trackWidth = 264,
onRangeChange,
}: RangeDoubleHandleProps) {
const [low, setLow] = useState(defaultLow);
const [high, setHigh] = useState(defaultHigh);
const [dragging, setDragging] = useState<Handle | null>(null);
const [focused, setFocused] = useState<Handle | null>(null);
const trackRef = useRef<HTMLDivElement>(null);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const span = max - min;
const toX = (value: number) => ((value - min) / span) * trackWidth;
const format = (value: number) => `${unit}${value.toLocaleString("en-US")}`;
const commit = (handle: Handle, raw: number) => {
const snapped = Math.round(raw / step) * step;
if (handle === "low") {
const next = Math.min(Math.max(snapped, min), high);
setLow(next);
onRangeChange?.(next, high);
} else {
const next = Math.max(Math.min(snapped, max), low);
setHigh(next);
onRangeChange?.(low, next);
}
};
const valueAt = (clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return min;
const ratio = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1);
return min + ratio * span;
};
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const pointerX = event.clientX - rect.left;
const toLow = Math.abs(pointerX - toX(low));
const toHigh = Math.abs(pointerX - toX(high));
// Whichever handle is nearer takes the drag, so a press anywhere on
// the track is a usable grab rather than a miss.
const handle: Handle = toLow <= toHigh ? "low" : "high";
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(handle);
// Pressing the bare track jumps the nearer handle to the press.
// Pressing the handle itself must not move it at all — a control that
// twitches the moment it is touched feels miscalibrated.
if (Math.min(toLow, toHigh) > HANDLE / 2) {
commit(handle, valueAt(event.clientX));
}
};
const onKeyDown = (handle: Handle) => (event: React.KeyboardEvent) => {
const current = handle === "low" ? low : high;
const page = Math.max(step, Math.round(span / 10 / step) * step);
let next: number | null = null;
if (event.key === "ArrowRight" || event.key === "ArrowUp") next = current + step;
else if (event.key === "ArrowLeft" || event.key === "ArrowDown") next = current - step;
else if (event.key === "PageUp") next = current + page;
else if (event.key === "PageDown") next = current - page;
else if (event.key === "Home") next = min;
else if (event.key === "End") next = max;
if (next === null) return;
event.preventDefault();
commit(handle, next);
};
// Under the pointer: no transition, ever. From the keyboard: one settle.
const move = (handle: Handle) =>
dragging === handle || reduceMotion ? { duration: 0 } : cfg.settle;
const renderHandle = (handle: Handle) => {
const value = handle === "low" ? low : high;
const isLow = handle === "low";
const lifted = dragging === handle || focused === handle;
return (
<motion.div
role="slider"
tabIndex={0}
aria-label={isLow ? `Minimum ${label}` : `Maximum ${label}`}
aria-valuemin={isLow ? min : low}
aria-valuemax={isLow ? high : max}
aria-valuenow={value}
aria-valuetext={format(value)}
aria-orientation="horizontal"
onKeyDown={onKeyDown(handle)}
onFocus={() => setFocused(handle)}
onBlur={() => setFocused(null)}
animate={{ x: toX(value) }}
transition={{ x: move(handle) }}
style={{
position: "absolute",
top: "50%",
left: 0,
width: HANDLE,
height: HANDLE,
marginLeft: -HANDLE / 2,
marginTop: -HANDLE / 2,
borderRadius: "50%",
cursor: "grab",
touchAction: "none",
outline: "none",
}}
>
{/* Only the circle grows on grab. The value bubble is its sibling,
not its child, so the number it carries is never scaled by the
handle swelling underneath it. */}
<motion.span
aria-hidden
animate={{ scale: lifted ? cfg.grip : 1 }}
transition={reduceMotion ? { duration: 0 } : cfg.settle}
style={{
position: "absolute",
inset: 0,
borderRadius: "50%",
border: `2px solid ${accent}`,
background: "#FFFFFF",
boxShadow: lifted
? `0 2px 10px rgba(0,0,0,0.22), 0 0 0 4px color-mix(in srgb, ${accent} 24%, transparent)`
: "0 1px 4px rgba(0,0,0,0.18)",
// Focus is painted as a ring on the handle itself: the default
// outline would sit on a circle that is being transformed.
transition: "box-shadow 150ms ease-out",
}}
/>
<AnimatePresence initial={false}>
{lifted && (
<motion.span
key="bubble"
initial={
reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.rise }
}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{
duration: reduceMotion ? 0.1 : cfg.fade,
ease: "easeOut",
}}
style={{
position: "absolute",
bottom: "calc(100% + 8px)",
left: "50%",
translate: "-50% 0",
padding: "3px 7px",
borderRadius: 7,
background: accent,
color: "#FFFFFF",
// Constant size: the bubble carries a number, and a number
// that scales as it appears is a number that looks wrong.
fontSize: 11,
fontWeight: 650,
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
pointerEvents: "none",
}}
>
{format(value)}
</motion.span>
)}
</AnimatePresence>
</motion.div>
);
};
return (
<div style={{ width: trackWidth + HANDLE, color: "inherit" }}>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 12,
marginBottom: 22,
padding: `0 ${HANDLE / 2}px`,
}}
>
<span style={{ fontSize: 11.5, fontWeight: 650, opacity: 0.55 }}>
{label.toUpperCase()}
</span>
<span
style={{
fontSize: 12.5,
fontWeight: 600,
fontVariantNumeric: "tabular-nums",
}}
>
{format(low)} – {format(high)}
</span>
</div>
<div
role="group"
aria-label={label}
style={{ padding: `0 ${HANDLE / 2}px` }}
>
<div
ref={trackRef}
onPointerDown={startDrag}
onPointerMove={(event) => {
if (!dragging) return;
commit(dragging, valueAt(event.clientX));
}}
onPointerUp={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setDragging(null);
}}
onPointerCancel={() => setDragging(null)}
style={{
position: "relative",
width: trackWidth,
height: 5,
borderRadius: 999,
background: tone(14),
touchAction: "none",
}}
>
{/* The fill is one bar scaled from its left edge, so both ends
are driven by transforms and stay on the compositor. */}
<motion.div
aria-hidden
animate={{
x: toX(low),
scaleX: Math.max((high - low) / span, 0.0001),
}}
transition={{
x: move("low"),
scaleX: move(dragging === "high" ? "high" : "low"),
}}
style={{
position: "absolute",
inset: 0,
width: trackWidth,
borderRadius: 999,
background: accent,
transformOrigin: "left center",
}}
/>
{renderHandle("low")}
{renderHandle("high")}
</div>
</div>
</div>
);
}About this pattern
A two-handle range is the rare control where the right answer is two different motions. Under a finger the handle is written straight to the pointer position with no transition at all, because a handle that lags the touch reads as broken hardware. From the keyboard each arrow press is a discrete jump, so the same handle settles into it on a single damped spring and a held key becomes a glide instead of a stutter. The fill is one bar scaled from its left edge, which keeps both ends on the compositor, and the value bubble rises beside the grabbed handle rather than inside it, so the number it carries is never scaled by the handle swelling underneath.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Form
Two handles over a histogram, with the selected span filled between them.
Related patterns
- Combobox Filter NarrowTyping fades out the options that no longer match while the survivors slide up.
- Field Reorder DragA row lifts onto a shadow while the rows around it part to make room — by pointer, and by arrow key from a grabbed state.
- Currency Input FormatSeparators fade in where they belong as the amount groups itself, and the caret holds its place.