Slider Drag Value
The handle stays under the finger while held, with a value bubble that rises on grab.
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 { useId, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Slider Drag Value
*
* The handle sits exactly under the finger while it is held, and the
* value bubble rises out of it on grab. Let go and the handle settles on
* a spring; nudge it with the arrow keys and it springs there too.
*
* 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`, `label`, `min`, `max`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SliderDragValueProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Field label. Also the slider's accessible name. */
label?: string;
min?: number;
max?: number;
/** Arrow-key increment, and the grid the drag snaps to. */
step?: number;
/** Starting value. */
defaultValue?: number;
/** Rendered in the bubble, beside the label, and in `aria-valuetext`. */
format?: (value: number) => string;
/** Track width in pixels. A number, because the handle travels in pixels. */
width?: number;
/** Fill, handle and bubble color. */
accent?: string;
/** Fires on every committed value change, drag included. */
onValueChange?: (value: number) => void;
};
type VariantConfig = {
/** Settles the handle after a key press or a click on the track. */
settle: { type: "spring"; stiffness: number; damping: number };
/** How much the handle swells while it is held. */
grab: number;
/** How far the bubble rises as it appears, in pixels. */
rise: number;
/** Seconds for the bubble to arrive. */
bubble: number;
};
// Quality rule: every spring sits at or above a 0.8 damping ratio, so a
// released handle lands once instead of hunting around the value the
// user chose — overshoot on a slider is not a flourish, it is a wrong
// number on screen. The bubble carries text, so it translates and fades
// and never scales. Variants differ in tempo and swell, not in bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Tight and quiet, for a panel of several sliders.
subtle: {
settle: { type: "spring", stiffness: 780, damping: 53 },
grab: 1.03,
rise: 2,
bubble: 0.11,
},
// One soft settle, a visible swell under the finger. All-purpose.
default: {
settle: { type: "spring", stiffness: 520, damping: 40 },
grab: 1.14,
rise: 5,
bubble: 0.16,
},
// A rounder settle and a bubble that travels further, for a single
// slider that is the point of the screen.
playful: {
settle: { type: "spring", stiffness: 340, damping: 30 },
grab: 1.25,
rise: 9,
bubble: 0.21,
},
};
/** 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 track or border that is correctly
* toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const HANDLE = 20;
const TRACK_HEIGHT = 6;
export default function SliderDragValue({
variant = "default",
label = "Monthly budget",
min = 0,
max = 1000,
step = 20,
defaultValue = 420,
format = (value: number) => `$${value}`,
width = 280,
accent = "#5B5BD6",
onValueChange,
}: SliderDragValueProps) {
const [value, setValue] = useState(defaultValue);
const [dragging, setDragging] = useState(false);
const [focused, setFocused] = useState(false);
const [ring, setRing] = useState(false);
const trackRef = useRef<HTMLDivElement>(null);
const labelId = useId();
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const fraction = (value - min) / (max - min);
const travel = width - HANDLE;
const commit = (next: number) => {
const clamped = Math.min(max, Math.max(min, next));
if (clamped === value) return;
setValue(clamped);
onValueChange?.(clamped);
};
/** Pointer position to a value on the step grid. */
const valueAt = (clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect) return value;
const raw = (clientX - rect.left - HANDLE / 2) / (rect.width - HANDLE);
const clamped = Math.min(1, Math.max(0, raw));
const steps = Math.round((clamped * (max - min)) / step);
return min + steps * step;
};
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
// Pointer capture keeps the drag alive when the finger leaves the
// track, which is where a slider is usually finished from.
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
commit(valueAt(event.clientX));
// A pointer drag should leave focus where the keyboard would find
// it, so the arrow keys carry on from wherever the drag stopped.
(event.currentTarget.lastElementChild as HTMLElement | null)?.focus();
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const jump = step * 5;
let next: number | null = null;
if (event.key === "ArrowRight" || event.key === "ArrowUp") next = value + step;
else if (event.key === "ArrowLeft" || event.key === "ArrowDown") next = value - step;
else if (event.key === "PageUp") next = value + jump;
else if (event.key === "PageDown") next = value - jump;
else if (event.key === "Home") next = min;
else if (event.key === "End") next = max;
if (next === null) return;
event.preventDefault();
commit(next);
};
// Under the finger the handle must be exactly where the pointer is —
// a spring would trail behind it and read as lag. The spring is for
// the jumps the user does not steer: key presses and track clicks.
const track = dragging || reduceMotion ? { duration: 0 } : cfg.settle;
const bubbleUp = dragging || focused;
return (
<div style={{ width, color: "inherit" }}>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginBottom: 14,
}}
>
<span id={labelId} style={{ fontSize: 13, fontWeight: 600, opacity: 0.75 }}>
{label}
</span>
<span
style={{
fontSize: 13,
fontWeight: 650,
// Tabular figures: the readout must not reflow the row while
// the value runs through three digits.
fontVariantNumeric: "tabular-nums",
}}
>
{format(value)}
</span>
</div>
<div
ref={trackRef}
onPointerDown={onPointerDown}
onPointerMove={(event) => {
if (dragging) commit(valueAt(event.clientX));
}}
onPointerUp={() => setDragging(false)}
onPointerCancel={() => setDragging(false)}
style={{
position: "relative",
height: HANDLE,
display: "flex",
alignItems: "center",
cursor: "pointer",
touchAction: "none",
}}
>
<div
aria-hidden
style={{
position: "absolute",
left: 0,
right: 0,
height: TRACK_HEIGHT,
borderRadius: TRACK_HEIGHT,
background: tone(14),
overflow: "hidden",
}}
>
{/* The fill is scaled, not resized: one compositor property,
and it stays in step with the handle frame for frame. */}
<motion.div
initial={false}
animate={{ scaleX: fraction }}
transition={track}
style={{
width: "100%",
height: "100%",
borderRadius: TRACK_HEIGHT,
background: accent,
transformOrigin: "0% 50%",
}}
/>
</div>
<motion.div
role="slider"
tabIndex={0}
aria-labelledby={labelId}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
// Screen readers should hear the formatted amount, not the raw
// number that happens to back it.
aria-valuetext={format(value)}
onKeyDown={onKeyDown}
onFocus={(event) => {
setFocused(true);
setRing(event.currentTarget.matches(":focus-visible"));
}}
onBlur={() => {
setFocused(false);
setRing(false);
}}
initial={false}
animate={{ x: fraction * travel }}
transition={track}
style={{
position: "absolute",
left: 0,
width: HANDLE,
height: HANDLE,
outline: "none",
}}
>
<motion.div
initial={false}
// The swell is the grab feedback. A solid disc has no text in
// it, so scaling it deforms nothing.
animate={{ scale: dragging && !reduceMotion ? cfg.grab : 1 }}
transition={cfg.settle}
style={{
width: "100%",
height: "100%",
borderRadius: HANDLE,
background: accent,
boxShadow: ring
? `0 0 0 3px ${accent}66, 0 1px 4px rgba(0,0,0,0.28)`
: "0 1px 4px rgba(0,0,0,0.28)",
transition: "box-shadow 140ms ease-out",
}}
/>
<AnimatePresence>
{bubbleUp ? (
<motion.div
aria-hidden
initial={{ opacity: 0, y: reduceMotion ? 0 : cfg.rise }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : cfg.rise }}
transition={{
duration: reduceMotion ? 0.1 : cfg.bubble,
ease: "easeOut",
}}
style={{
position: "absolute",
bottom: HANDLE + 8,
left: "50%",
// Centering is a static transform; Motion only ever
// writes the y offset next to it.
x: "-50%",
padding: "3px 8px",
borderRadius: 7,
background: accent,
color: "#FFFFFF",
fontSize: 12,
fontWeight: 650,
lineHeight: "16px",
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
pointerEvents: "none",
}}
>
{format(value)}
</motion.div>
) : null}
</AnimatePresence>
</motion.div>
</div>
</div>
);
}About this pattern
The rule that makes a slider feel connected: while the pointer is down there is no easing at all, because a handle that springs toward the finger reads as lag, not as polish. The spring is kept for the movements the user is not steering — an arrow-key nudge, a click further along the track — where it turns a jump into a travel that can be followed. The handle swells under the grab and the readout bubble rises out of it, translating and fading rather than scaling, since it is a number and numbers deform badly. Arrows, Page keys, Home and End all move it, and the formatted amount goes into aria-valuetext.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Form
Direct tracking under the finger, with the swell as the only grab feedback.
Related patterns
- 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.
- File Drop Zone ActiveThe dashed outline energizes and the target lifts off the page while a file is held over it.
- OTP Paste FillA pasted block of digits lands in the boxes as a quick left-to-right cascade instead of appearing all at once.