06Drag-to-confirm — a button that reaches a confirmed state once a dragged position value passes a threshold
Once the dragged position value passes a set threshold value, it changes to a confirmed state; if released while still below the threshold, the position value returns to its starting place.
Best forA payment-confirmation button, a confirmation for a risky action
THRESH=0.7THUMB_W=40settle

<main class="stage">
<div class="ctadcs is-demo" id="ctadcs">
<div class="ctadcs__panel">
<div class="ctadcs__track" id="ctadcsTrack">
<span class="ctadcs__trackLabel">Slide to confirm payment</span>
<button class="ctadcs__thumb" id="ctadcsThumb" type="button" aria-label="Slide to confirm"></button>
</div>
<span class="ctadcs__hint" id="ctadcsHint">Must pass the 70% threshold to confirm</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(임계 판정·스프링 복귀)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-yellow);
.stage { position: relative; }
.ctadcs__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
padding: $sp-5;
border-radius: $r-card;
background: $stage-paper;
}
.ctadcs__track {
position: relative;
width: 220px;
height: 48px;
border-radius: $r-pill;
background: $subject-cream;
display: flex;
align-items: center;
padding: $sp-1;
}
.ctadcs__trackLabel {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: rgba($subject-ink, .5);
font-size: 13px;
font-weight: 700;
pointer-events: none;
}
.ctadcs__thumb {
position: relative;
width: 40px;
height: 40px;
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
cursor: grab;
touch-action: none;
}
.ctadcs__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctadcs__panel { padding: $sp-4; gap: $sp-2; }
.ctadcs__track { width: 180px; height: 44px; }
.ctadcs__thumb { width: 36px; height: 36px; }
}
@media (prefers-reduced-motion: reduce) {
.ctadcs__thumb { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.ctadcs__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
border-radius: 16px;
background: #ffffff;
}
.ctadcs__track {
position: relative;
width: 220px;
height: 48px;
border-radius: 999px;
background: #fff7e6;
display: flex;
align-items: center;
padding: 4px;
}
.ctadcs__trackLabel {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: rgba(23, 20, 26, 0.5);
font-size: 13px;
font-weight: 700;
pointer-events: none;
}
.ctadcs__thumb {
position: relative;
width: 40px;
height: 40px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
cursor: grab;
touch-action: none;
}
.ctadcs__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctadcs__panel {
padding: 16px;
gap: 8px;
}
.ctadcs__track {
width: 180px;
height: 44px;
}
.ctadcs__thumb {
width: 36px;
height: 36px;
}
}
@media (prefers-reduced-motion: reduce) {
.ctadcs__thumb {
transition: none !important;
}
}(function () {
var root = document.getElementById('ctadcs');
var track = document.getElementById('ctadcsTrack');
var thumb = document.getElementById('ctadcsThumb');
var hint = document.getElementById('ctadcsHint');
// 계산 겹: 끄는 위치 값을 트랙 폭 대비 비율(0~1)로 clamp 하고, THRESH(0.7)를 넘겼는지로
// 확정 여부를 가른다. 기준 미달로 놓이면 목표(0)까지 매 프레임 lerp(관성 없는 스프링 복귀)한다.
var THRESH = 0.7;
var pos = 0, target = 0, raf = null, confirmed = false;
var THUMB_W = 40;
function maxTravel() { return track.clientWidth - THUMB_W - 8; }
function apply(p) {
var mt = maxTravel();
thumb.style.transform = 'translateX(' + (p * mt).toFixed(1) + 'px)';
}
function settle() {
var diff = target - pos;
pos += diff * 0.3;
if (Math.abs(diff) < 0.002) { pos = target; }
apply(pos);
if (Math.abs(target - pos) > 0.002) {
raf = requestAnimationFrame(settle);
} else {
raf = null;
}
}
function goTo(p) {
target = p;
if (raf === null) { raf = requestAnimationFrame(settle); }
}
function release(p) {
if (p >= THRESH) {
confirmed = true;
goTo(1);
hint.textContent = 'Confirmed';
setTimeout(function () { confirmed = false; goTo(0); hint.textContent = 'Must pass the 70% threshold to confirm'; }, 500);
} else {
goTo(0);
}
}
var dragging = false, startX = 0, startPos = 0;
function onDown(clientX) {
if (confirmed) { return; }
dragging = true; startX = clientX; startPos = pos;
if (raf) { cancelAnimationFrame(raf); raf = null; }
}
function onMove(clientX) {
if (!dragging) { return; }
var mt = maxTravel();
var dp = (clientX - startX) / mt;
pos = Math.max(0, Math.min(1, startPos + dp));
apply(pos);
}
function onUp() {
if (!dragging) { return; }
dragging = false;
release(pos);
}
function wireReal() {
thumb.addEventListener('pointerdown', function (e) { root.classList.remove('is-demo'); stopAutoplay(); onDown(e.clientX); });
window.addEventListener('pointermove', function (e) { onMove(e.clientX); });
window.addEventListener('pointerup', onUp);
}
wireReal();
apply(0);
// 자동 루프: 실제 드래그 대신 목표 값을 단계적으로 옮겨 같은 release() 판정 로직을 탄다.
var autoTarget = 0, autoTimer = null;
function autoTick() {
autoTarget = autoTarget >= 1 ? 0 : Math.min(1, autoTarget + 0.35);
pos = autoTarget;
apply(pos);
if (autoTarget >= 1) { release(1); }
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 220); }
})();import { useEffect, useRef } from "react";
import styles from "./CtaBtnDragConfirmSlide.module.scss";
export type CtaBtnDragConfirmSlideProps = {
color?: string;
};
export default function CtaBtnDragConfirmSlide({
color = "#0a4fe8",
}: CtaBtnDragConfirmSlideProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const track = root.querySelector<HTMLDivElement>(`.${styles.track}`);
const thumb = root.querySelector<HTMLButtonElement>(`.${styles.thumb}`);
const hint = root.querySelector<HTMLSpanElement>(`.${styles.hint}`);
if (!track || !thumb || !hint) return;
// 계산 겹: 끄는 위치 값을 트랙 폭 대비 비율(0~1)로 clamp 하고, THRESH(0.7)를 넘겼는지로
// 확정 여부를 가른다. 기준 미달로 놓이면 목표(0)까지 매 프레임 lerp(스프링 복귀)한다.
const THRESH = 0.7;
const THUMB_W = 40;
let pos = 0, target = 0, raf: number | null = null, confirmed = false;
function maxTravel() { return track!.clientWidth - THUMB_W - 8; }
function apply(p: number) {
const mt = maxTravel();
thumb!.style.transform = "translateX(" + (p * mt).toFixed(1) + "px)";
}
function settle() {
const diff = target - pos;
pos += diff * 0.3;
if (Math.abs(diff) < 0.002) { pos = target; }
apply(pos);
raf = Math.abs(target - pos) > 0.002 ? requestAnimationFrame(settle) : null;
}
function goTo(p: number) {
target = p;
if (raf === null) { raf = requestAnimationFrame(settle); }
}
function release(p: number) {
if (p >= THRESH) {
confirmed = true;
goTo(1);
hint!.textContent = "확정되었습니다";
window.setTimeout(() => { confirmed = false; goTo(0); hint!.textContent = "기준(70%)을 넘겨야 확정됩니다"; }, 500);
} else {
goTo(0);
}
}
let dragging = false, startX = 0, startPos = 0;
function onDown(clientX: number) {
if (confirmed) return;
dragging = true; startX = clientX; startPos = pos;
if (raf !== null) { cancelAnimationFrame(raf); raf = null; }
}
function onMove(clientX: number) {
if (!dragging) return;
const mt = maxTravel();
const dp = (clientX - startX) / mt;
pos = Math.max(0, Math.min(1, startPos + dp));
apply(pos);
}
function onUp() {
if (!dragging) return;
dragging = false;
release(pos);
}
function onPointerDown(e: PointerEvent) { stopAutoplay(); onDown(e.clientX); }
function onPointerMove(e: PointerEvent) { onMove(e.clientX); }
thumb.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onUp);
apply(0);
let autoTarget = 0, autoTimer: number | null = null;
function autoTick() {
autoTarget = autoTarget >= 1 ? 0 : Math.min(1, autoTarget + 0.35);
pos = autoTarget;
apply(pos);
if (autoTarget >= 1) { release(1); }
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 220); }
return () => {
stopAutoplay();
if (raf !== null) { cancelAnimationFrame(raf); }
thumb.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onUp);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.panel}>
<div className={styles.track}>
<span className={styles.trackLabel}>밀어서 결제 확정</span>
<button className={styles.thumb} type="button" aria-label="밀어서 확정" style={{ background: color }} />
</div>
<span className={styles.hint}>기준(70%)을 넘겨야 확정됩니다</span>
</div>
</div>
);
}06 Drag-to-confirm — a button that reaches a confirmed state once a dragged position value passes a threshold — Measured area 5.158% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · CTA Button Design: 9 Interaction Patterns
The post this demo lives in
CTA Button Design: 9 Interaction PatternsOne shadow length value grows and shrinks with hover state, one perimeter progress value depletes to zero over time, and See all demos in this post
If you like this effect - similar demos
Buttons02 Active-indicator slide — when the active-button value changes, the highlight background's position value glides to the new button along measured coordinatesButton Group CSS: 9 Adjoined Patterns
Buttons02 Crack-line Button That Splits With a Press-state ValueButton Click Effect CSS: 9 Canvas Pixel Patterns
Buttons02 Button that unlocks when the form is valid9 button state css patterns, loading to done