Anatomy of the Slide Confirm Button
How a drag-to-confirm thumb measures its own track, trails a fill behind an x motion value, and commits past a 90% line.
↓Scroll to step through it
The track measures itself
A drag is a deliberate confirmation gesture: you can't mash it, and you can
see how far you've committed before you let go. The component is one motion
value, x, and a track that measures itself.
maxX, how far the thumb can travel, isn't a fixed number. It's
trackWidth - thumb - pad × 2, recomputed by a ResizeObserver so the
button stays correct if its container resizes.
At the md size that's a 288px track, a 40px thumb, and 4px of pad
on each side, so maxX comes out to 240. Everything below uses those
numbers.
The fill is a second absolutely-positioned pill, pinned at top/bottom/
left: pad, whose width is derived straight from x:
const fillWidth = useTransform(x, (v) => v + dims.thumb);At rest (x = 0) the fill's width equals the thumb's width, so it sits
exactly under the thumb with no bleed on either side. Drag the thumb 120px
right and the fill is 160px wide. It always trails the thumb's trailing
edge by exactly pad. The label centered over the track fades out over the
first 60% of the travel, so by the time you're most of the way across, the
instruction has already gotten out of the way:
const labelOpacity = useTransform(x, [0, Math.max(1, maxX * 0.6)], [1, 0]);The threshold
threshold defaults to 0.9: the thumb has to clear 90% of maxX before
release counts as a confirm. Everything before that line springs back;
everything past it springs the rest of the way home.
Both outcomes use the same spring (stiffness: 500, damping: 40),
aimed at different targets. A failed drag springs x back to 0; a
confirmed one springs it the rest of the way to maxX, so the last few
percent of the gesture finishes itself:
const confirm = () => {
setStatus("confirmed");
animate(x, maxX, { type: "spring", stiffness: 500, damping: 40 });
onConfirm?.();
};
const settleBack = () => {
animate(
x,
0,
reduce ? { duration: 0.2 } : { type: "spring", stiffness: 500, damping: 40 },
);
};reduce, this component's own useReducedMotion() check, swaps that
same spring for a flat 0.2s tween on the settle-back path, so a
reduced-motion user still gets an instant, legible snap instead of a
bounce.
Two Framer Motion drag props shape how the thumb feels under a real finger:
drag={confirmed || disabled ? false : "x"}
dragConstraints={{ left: 0, right: maxX }}
dragElastic={0.04}
dragMomentum={false}dragElastic={0.04} lets the thumb creep slightly past 0 or maxX
under drag pressure, so the track doesn't feel like a hard wall.
dragConstraints still clamps the resting range.
dragMomentum={false} turns off inertial coasting after release entirely:
without it, a fast flick could carry x past the threshold on momentum
alone, which would make "past the line" mean something different depending
on drag speed instead of drag distance. Distance is the only thing that
should decide a confirm.
The result
A measured track, a fill derived from one motion value, a threshold that picks the target for the same spring, and a drag config tuned so distance commits, not speed. Drag it, or press the arrow key.
- Track
- rounded-full border, bg-muted
- Fill
- trails thumb, width = x + thumb
- Thumb
- draggable, x is a motion value
- Label
- fades over first 60% of travel
React.useLayoutEffect(() => {const el = trackRef.current;if (!el) return;const measure = () => { const usable = el.clientWidth - dims.thumb - dims.pad * 2; setMaxX(Math.max(0, usable));};measure();const ro = new ResizeObserver(measure);ro.observe(el);return () => ro.disconnect();}, [dims.thumb, dims.pad]);Anatomy of the Slide Confirm Button
How a drag-to-confirm thumb measures its own track, trails a fill behind an x motion value, and commits past a 90% line.
- Track
- rounded-full border, bg-muted
- Fill
- trails thumb, width = x + thumb
- Thumb
- draggable, x is a motion value
- Label
- fades over first 60% of travel
React.useLayoutEffect(() => {const el = trackRef.current;if (!el) return;const measure = () => { const usable = el.clientWidth - dims.thumb - dims.pad * 2; setMaxX(Math.max(0, usable));};measure();const ro = new ResizeObserver(measure);ro.observe(el);return () => ro.disconnect();}, [dims.thumb, dims.pad]);The track measures itself
A drag is a deliberate confirmation gesture: you can't mash it, and you can
see how far you've committed before you let go. The component is one motion
value, x, and a track that measures itself.
maxX, how far the thumb can travel, isn't a fixed number. It's
trackWidth - thumb - pad × 2, recomputed by a ResizeObserver so the
button stays correct if its container resizes.
At the md size that's a 288px track, a 40px thumb, and 4px of pad
on each side, so maxX comes out to 240. Everything below uses those
numbers.
The fill is a second absolutely-positioned pill, pinned at top/bottom/
left: pad, whose width is derived straight from x:
const fillWidth = useTransform(x, (v) => v + dims.thumb);At rest (x = 0) the fill's width equals the thumb's width, so it sits
exactly under the thumb with no bleed on either side. Drag the thumb 120px
right and the fill is 160px wide. It always trails the thumb's trailing
edge by exactly pad. The label centered over the track fades out over the
first 60% of the travel, so by the time you're most of the way across, the
instruction has already gotten out of the way:
const labelOpacity = useTransform(x, [0, Math.max(1, maxX * 0.6)], [1, 0]);- Falls short
- released below 216px → spring(500,40) back to 0
- Past the line
- released past 216px → confirm() springs to maxX
- Threshold
- maxX × 0.9 = 216px
const handleDragEnd = () => {if (statusRef.current === "confirmed") return;if (maxX > 0 && x.get() >= maxX * threshold) confirm();else settleBack();};The threshold
threshold defaults to 0.9: the thumb has to clear 90% of maxX before
release counts as a confirm. Everything before that line springs back;
everything past it springs the rest of the way home.
Both outcomes use the same spring (stiffness: 500, damping: 40),
aimed at different targets. A failed drag springs x back to 0; a
confirmed one springs it the rest of the way to maxX, so the last few
percent of the gesture finishes itself:
const confirm = () => {
setStatus("confirmed");
animate(x, maxX, { type: "spring", stiffness: 500, damping: 40 });
onConfirm?.();
};
const settleBack = () => {
animate(
x,
0,
reduce ? { duration: 0.2 } : { type: "spring", stiffness: 500, damping: 40 },
);
};reduce, this component's own useReducedMotion() check, swaps that
same spring for a flat 0.2s tween on the settle-back path, so a
reduced-motion user still gets an instant, legible snap instead of a
bounce.
Two Framer Motion drag props shape how the thumb feels under a real finger:
drag={confirmed || disabled ? false : "x"}
dragConstraints={{ left: 0, right: maxX }}
dragElastic={0.04}
dragMomentum={false}dragElastic={0.04} lets the thumb creep slightly past 0 or maxX
under drag pressure, so the track doesn't feel like a hard wall.
dragConstraints still clamps the resting range.
dragMomentum={false} turns off inertial coasting after release entirely:
without it, a fast flick could carry x past the threshold on momentum
alone, which would make "past the line" mean something different depending
on drag speed instead of drag distance. Distance is the only thing that
should decide a confirm.
The result
A measured track, a fill derived from one motion value, a threshold that picks the target for the same spring, and a drag config tuned so distance commits, not speed. Drag it, or press the arrow key.
Keyboard confirms instantly
The thumb is a real <button>, and Enter / Space / ArrowRight all
call confirm() directly. There is no simulated drag and no threshold to
cross for a keypress:
const handleKeyDown = (event) => {
if (disabled || statusRef.current === "confirmed") return;
if (event.key === "Enter" || event.key === " " || event.key === "ArrowRight") {
event.preventDefault();
confirm();
}
};ArrowRight gets its own case because it matches "slide right to confirm,"
so a keyboard user gets a shortcut that fits the gesture, not only the
generic activation keys.
Motion Score
opacityFade / cross-fadepathLengthSVG pathLength reveal