06スライド確定 — 引いた位置の値が基準を超えると確定状態になるボタン
引いた位置の値が決まった基準の値を超えた状態になると確定した状態に変わり、基準に届かないまま離された状態になると位置の値は最初の場所に戻ります。
使いどころ決済確定ボタン、危険な操作の確認ボタン
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">スライドして決済確定</span>
<button class="ctadcs__thumb" id="ctadcsThumb" type="button" aria-label="スライドして確定"></button>
</div>
<span class="ctadcs__hint" id="ctadcsHint">基準(70%)を超えないと確定しません</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 = '確定しました';
setTimeout(function () { confirmed = false; goTo(0); hint.textContent = '基準(70%)を超えないと確定しません'; }, 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 スライド確定 — 引いた位置の値が基準を超えると確定状態になるボタン — 計測 累積面積 5.158% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · CTA ボタン デザイン 9選 — 伸びる影から跳ねるバーまで
このデモが属する記事
CTA ボタン デザイン 9選 — 伸びる影から跳ねるバーまで9つのうち1つは伸びる影の長さの値がホバー状態に応じて伸び縮みし、1つは周囲の進行の値が決まった時間の間に0まで減り、もう1つは引いた位置の値が基準を超えると確定した状態に変わります。cta ボタン デザインの9つの動きを見ていきます。 この記事のデモをすべて見る


