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
Dragging is the most deliberate confirmation gesture there is — you can't
mash it, and you can feel exactly how far you've committed before you let
go. The whole thing 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 real 288px track, a 40px thumb, 4px of pad
on each side — so maxX comes out to 240. Everything below uses those
exact 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 — just
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 just slightly past 0 or maxX
under drag pressure — a tiny give so the track doesn't feel like a hard
wall — while 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 — not speed — is what commits. Drag it, or just 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
Dragging is the most deliberate confirmation gesture there is — you can't
mash it, and you can feel exactly how far you've committed before you let
go. The whole thing 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 real 288px track, a 40px thumb, 4px of pad
on each side — so maxX comes out to 240. Everything below uses those
exact 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 — just
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 just slightly past 0 or maxX
under drag pressure — a tiny give so the track doesn't feel like a hard
wall — while 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 — not speed — is what commits. Drag it, or just press the arrow key.
Keyboard confirms instantly
The thumb is a real <button>, and Enter / Space / ArrowRight all
call confirm() directly — no simulated dragging, no waiting to cross a
threshold that doesn't apply to a keypress:
const handleKeyDown = (event) => {
if (disabled || statusRef.current === "confirmed") return;
if (event.key === "Enter" || event.key === " " || event.key === "ArrowRight") {
event.preventDefault();
confirm();
}
};ArrowRight earning its own case matters here — it's the key that
semantically matches "slide right to confirm," so a keyboard user gets a
gesture-appropriate shortcut instead of only the generic activation keys.
Motion Score
opacityFade / cross-fadepathLengthSVG pathLength reveal