09끌었다 놓는 관성 토글
노브를 손가락으로 끌면 손끝을 그대로 따라오고, 절반을 넘긴 채 놓으면 남은 거리를 관성으로 달려가 붙습니다. 절반을 못 넘기면 원래 자리로 되돌아옵니다.
쓰임실수로 켜지면 곤란한 잠금 해제
pointerdownsetPointerCapturetranslateX

<main class="stage">
<div class="dt is-demo" id="dt">
<section class="dt__panel">
<p class="dt__label">잠금 해제</p>
<div class="dt__track" id="dtTrack">
<span class="dt__fill" aria-hidden="true"></span>
<button class="dt__knob" id="dtKnob" type="button" role="switch" aria-checked="false" aria-label="잠금 해제 스위치">
<span class="dt__grip" aria-hidden="true"></span>
</button>
</div>
<p class="dt__hint">끌어서 밀어 보세요</p>
<p class="dt__state" role="status"><span class="dt__wordOff">잠김</span><span class="dt__wordOn">열림</span></p>
</section>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 끌었다 놓는 관성 토글 — 끄는 동안은 손끝을 그대로 따라오고(transition 없음), 놓는 순간에만 관성이 붙는다.
// 그 두 구간을 가르는 것이 .is-dragging 하나다: 끌 때는 transition 을 끄고, 놓으면 $easing 이 남은 거리를 달린다.
// 포인터는 setPointerCapture 로 노브에 묶어 트랙 밖으로 손이 나가도 끊기지 않게 한다.
// 클릭·Space 로도 같은 동작이 되고(버튼), role="switch" + aria-checked 로 상태를 읽어 준다.
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-blue-deep;
@include stage($stage-ink);
.dt { display: grid; place-items: center; width: 100%; }
.dt__panel {
box-sizing: border-box; width: clamp(196px, 62vw, 232px);
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
padding: $sp-4; border-radius: $r-card;
background: rgba($subject-cream, .07);
border: 1px solid rgba($subject-cream, .18);
box-shadow: $shadow-raised;
}
.dt__label {
margin: 0; font-weight: 700; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
letter-spacing: .14em; color: $subject-cream;
}
// ── 트랙 ─────────────────────────────────────────────────────
.dt__track {
position: relative; box-sizing: border-box;
width: 168px; height: 48px; border-radius: $r-pill;
background: rgba($stage-ink, .8);
box-shadow: inset 0 3px 6px rgba($stage-ink, .9), inset 0 -1px 0 rgba($subject-cream, .18);
}
// 노브가 지나온 만큼 차오르는 자국
.dt__fill {
position: absolute; left: 4px; top: 4px; width: 160px; height: 40px;
border-radius: $r-pill; background: $color;
transform: scaleX(0); transform-origin: left center; // 왼쪽 변에서 자라는 채움 막대
transition: transform $duration $easing;
}
.dt__knob {
position: absolute; left: 4px; top: 4px; z-index: 2;
box-sizing: border-box; width: 44px; height: 40px;
margin: 0; padding: 0; border: 0; cursor: grab;
border-radius: $r-pill; touch-action: none;
background: linear-gradient(180deg, $subject-cream, rgba($subject-cream, .72) 62%, rgba($stage-ink, .45));
box-shadow: 0 4px 8px rgba($stage-ink, .7), inset 0 1px 0 rgba($stage-paper, .9);
transform: translateX(0);
transition: transform $duration $easing;
}
.dt__knob:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
// 끄는 동안에는 손끝과 노브가 어긋나면 안 되므로 전환을 끈다
.dt.is-dragging .dt__knob { transition: none; cursor: grabbing; }
.dt.is-dragging .dt__fill { transition: none; }
.dt__grip {
position: absolute; left: calc(50% - 7px); top: calc(50% - 7px); width: 14px; height: 14px;
background:
linear-gradient(90deg, rgba($subject-ink, .38) 2px, transparent 2px) 0 0 / 6px 100% repeat-x;
}
.dt__hint {
margin: 0; font-weight: 500; font-size: clamp(10px, 2.4vw, 11px); line-height: 1;
color: rgba($subject-cream, .78);
}
.dt__state { position: relative; margin: 0; width: 96px; height: 18px; }
.dt__wordOff, .dt__wordOn {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: clamp(12px, 3vw, 14px); line-height: 1;
color: $subject-cream;
}
.dt__wordOn { display: none; color: $stage-yellow; }
// ── 열림 ─────────────────────────────────────────────────────
.dt.is-open .dt__knob { transform: translateX(116px); }
.dt.is-open .dt__fill { transform: scaleX(1); }
.dt.is-open .dt__wordOff { display: none; }
.dt.is-open .dt__wordOn { display: flex; }
// ── 미리보기 루프(2초 한 바퀴) ────────────────────────────────
// 끄는 손을 흉내 낸다: 천천히 끌려가다(등속) 절반을 넘긴 뒤 놓으면 나머지를 관성으로 달린다.
.dt.is-demo .dt__knob {
animation-name: dtDrag; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes dtDrag {
0% { transform: translateX(0); }
8% { transform: translateX(0); }
28% { transform: translateX(64px); }
40% { transform: translateX(122px); }
46% { transform: translateX(116px); }
72% { transform: translateX(116px); }
84% { transform: translateX(-4px); }
88% { transform: translateX(0); }
100% { transform: translateX(0); }
}
.dt.is-demo .dt__fill {
animation-name: dtFill; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes dtFill {
0% { transform: scaleX(0); }
8% { transform: scaleX(0); }
28% { transform: scaleX(.42); }
40% { transform: scaleX(1); }
72% { transform: scaleX(1); }
84% { transform: scaleX(0); }
100% { transform: scaleX(0); }
}
.dt.is-demo .dt__wordOn { display: flex; opacity: 0; }
.dt.is-demo .dt__wordOff { opacity: 1; }
.dt.is-demo .dt__wordOn {
animation-name: dtWordOn; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes dtWordOn {
0% { opacity: 0; }
42% { opacity: 1; }
82% { opacity: 0; }
100% { opacity: 0; }
}
.dt.is-demo .dt__wordOff {
animation-name: dtWordOff; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes dtWordOff {
0% { opacity: 1; }
42% { opacity: 0; }
82% { opacity: 1; }
100% { opacity: 1; }
}
@media (max-width: 340px) {
.dt__panel { gap: $sp-1; padding: $sp-3; width: clamp(168px, 86vw, 196px); }
.dt__track { width: 144px; height: 40px; }
.dt__fill { width: 136px; height: 32px; }
.dt__knob { width: 40px; height: 32px; }
.dt.is-open .dt__knob { transform: translateX(96px); }
.dt__state { width: 84px; height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.dt .dt__knob { transition: none !important; }
.dt .dt__fill { transition: none !important; }
.dt.is-demo .dt__knob { animation: none !important; transform: translateX(0) !important; }
.dt.is-demo .dt__fill { animation: none !important; transform: scaleX(0) !important; }
.dt.is-demo .dt__wordOn { animation: none !important; opacity: 0 !important; }
.dt.is-demo .dt__wordOff { animation: none !important; opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dt {
display: grid;
place-items: center;
width: 100%;
}
.dt__panel {
box-sizing: border-box;
width: clamp(196px, 62vw, 232px);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.07);
border: 1px solid rgba(255, 247, 230, 0.18);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.dt__label {
margin: 0;
font-weight: 700;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
letter-spacing: 0.14em;
color: #fff7e6;
}
.dt__track {
position: relative;
box-sizing: border-box;
width: 168px;
height: 48px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.8);
box-shadow: inset 0 3px 6px rgba(23, 20, 26, 0.9), inset 0 -1px 0 rgba(255, 247, 230, 0.18);
}
.dt__fill {
position: absolute;
left: 4px;
top: 4px;
width: 160px;
height: 40px;
border-radius: 999px;
background: #0a4fe8;
transform: scaleX(0);
transform-origin: left center;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.dt__knob {
position: absolute;
left: 4px;
top: 4px;
z-index: 2;
box-sizing: border-box;
width: 44px;
height: 40px;
margin: 0;
padding: 0;
border: 0;
cursor: grab;
border-radius: 999px;
touch-action: none;
background: linear-gradient(180deg, #fff7e6, rgba(255, 247, 230, 0.72) 62%, rgba(23, 20, 26, 0.45));
box-shadow: 0 4px 8px rgba(23, 20, 26, 0.7), inset 0 1px 0 rgba(255, 255, 255, 0.9);
transform: translateX(0);
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.dt__knob:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.dt.is-dragging .dt__knob {
transition: none;
cursor: grabbing;
}
.dt.is-dragging .dt__fill {
transition: none;
}
.dt__grip {
position: absolute;
left: calc(50% - 7px);
top: calc(50% - 7px);
width: 14px;
height: 14px;
background: linear-gradient(90deg, rgba(23, 20, 26, 0.38) 2px, transparent 2px) 0 0/6px 100% repeat-x;
}
.dt__hint {
margin: 0;
font-weight: 500;
font-size: clamp(10px, 2.4vw, 11px);
line-height: 1;
color: rgba(255, 247, 230, 0.78);
}
.dt__state {
position: relative;
margin: 0;
width: 96px;
height: 18px;
}
.dt__wordOff, .dt__wordOn {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: clamp(12px, 3vw, 14px);
line-height: 1;
color: #fff7e6;
}
.dt__wordOn {
display: none;
color: #ffd23f;
}
.dt.is-open .dt__knob {
transform: translateX(116px);
}
.dt.is-open .dt__fill {
transform: scaleX(1);
}
.dt.is-open .dt__wordOff {
display: none;
}
.dt.is-open .dt__wordOn {
display: flex;
}
.dt.is-demo .dt__knob {
animation-name: dtDrag;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes dtDrag {
0% {
transform: translateX(0);
}
8% {
transform: translateX(0);
}
28% {
transform: translateX(64px);
}
40% {
transform: translateX(122px);
}
46% {
transform: translateX(116px);
}
72% {
transform: translateX(116px);
}
84% {
transform: translateX(-4px);
}
88% {
transform: translateX(0);
}
100% {
transform: translateX(0);
}
}
.dt.is-demo .dt__fill {
animation-name: dtFill;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes dtFill {
0% {
transform: scaleX(0);
}
8% {
transform: scaleX(0);
}
28% {
transform: scaleX(0.42);
}
40% {
transform: scaleX(1);
}
72% {
transform: scaleX(1);
}
84% {
transform: scaleX(0);
}
100% {
transform: scaleX(0);
}
}
.dt.is-demo .dt__wordOn {
display: flex;
opacity: 0;
}
.dt.is-demo .dt__wordOff {
opacity: 1;
}
.dt.is-demo .dt__wordOn {
animation-name: dtWordOn;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dtWordOn {
0% {
opacity: 0;
}
42% {
opacity: 1;
}
82% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.dt.is-demo .dt__wordOff {
animation-name: dtWordOff;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dtWordOff {
0% {
opacity: 1;
}
42% {
opacity: 0;
}
82% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@media (max-width: 340px) {
.dt__panel {
gap: 4px;
padding: 12px;
width: clamp(168px, 86vw, 196px);
}
.dt__track {
width: 144px;
height: 40px;
}
.dt__fill {
width: 136px;
height: 32px;
}
.dt__knob {
width: 40px;
height: 32px;
}
.dt.is-open .dt__knob {
transform: translateX(96px);
}
.dt__state {
width: 84px;
height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.dt .dt__knob {
transition: none !important;
}
.dt .dt__fill {
transition: none !important;
}
.dt.is-demo .dt__knob {
animation: none !important;
transform: translateX(0) !important;
}
.dt.is-demo .dt__fill {
animation: none !important;
transform: scaleX(0) !important;
}
.dt.is-demo .dt__wordOn {
animation: none !important;
opacity: 0 !important;
}
.dt.is-demo .dt__wordOff {
animation: none !important;
opacity: 1 !important;
}
}var dtRoot = document.getElementById('dt');
var dtTrack = document.getElementById('dtTrack');
var dtKnob = document.getElementById('dtKnob');
var dragging = false;
var startX = 0;
var startOffset = 0;
var offset = 0;
var suppressClick = false; // 끌기가 일어난 뒤 따라오는 click 은 토글이 아니라 끌기의 끝이다
function travel() {
// 노브가 갈 수 있는 최대 거리 = 트랙 안폭 - 노브 폭 (레일 여백 4px 씩)
return dtTrack.clientWidth - dtKnob.offsetWidth - 8;
}
function place(px) {
offset = Math.max(0, Math.min(travel(), px));
dtKnob.style.transform = 'translateX(' + offset + 'px)';
}
function settle(open) {
dtKnob.style.transform = '';
dtKnob.setAttribute('aria-checked', open ? 'true' : 'false');
dtRoot.classList.toggle('is-open', open);
offset = open ? travel() : 0;
}
function onDown(e) {
dtRoot.classList.remove('is-demo');
dragging = true;
startX = e.clientX;
startOffset = dtRoot.classList.contains('is-open') ? travel() : 0;
dtRoot.classList.add('is-dragging');
dtKnob.setPointerCapture(e.pointerId);
}
function onMove(e) {
if (!dragging) return;
if (Math.abs(e.clientX - startX) > 6) suppressClick = true;
place(startOffset + (e.clientX - startX));
}
function onUp() {
if (!dragging) return;
dragging = false;
dtRoot.classList.remove('is-dragging');
// 절반을 넘겼으면 남은 거리를 관성으로 마저 달리고, 못 넘겼으면 제자리로 돌아온다
settle(offset > travel() / 2);
}
dtKnob.addEventListener('pointerdown', onDown);
dtKnob.addEventListener('pointermove', onMove);
dtKnob.addEventListener('pointerup', onUp);
dtKnob.addEventListener('pointercancel', onUp);
dtKnob.addEventListener('click', function () {
dtRoot.classList.remove('is-demo');
if (suppressClick) { suppressClick = false; return; } // 끌기 직후의 click 은 무시한다
settle(dtKnob.getAttribute('aria-checked') !== 'true');
});// InertiaDragToggle — vanilla 판(run/076/demo/09-real-inertia-drag-toggle/index.html) 의 React 포트.
// 끄는 동안은 손끝을 그대로 따라오고(transition 없음), 놓는 순간에만 관성이 붙는다.
// 값·키프레임은 vanilla 와 같다(다시 타이핑하지 않는다). 드래그·클릭 모두 미리보기 루프를 뗀다.
import { useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./InertiaDragToggle.module.scss";
export type InertiaDragToggleProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = { [k: string]: any };
type PtrEvt = ReactPointerEvent<HTMLButtonElement>;
const LABEL_TEXT = "잠금 해제";
const SWITCH_LABEL = "잠금 해제 스위치";
const HINT_TEXT = "끌어서 밀어 보세요";
const STATE_OFF = "잠김";
const STATE_ON = "열림";
export default function InertiaDragToggle({
duration = 300,
easing = "cubic-bezier(.2, 1.4, .4, 1)", // = $ease-pop
color = "#0a4fe8",
}: InertiaDragToggleProps) {
const [open, setOpen] = useState(false);
const [demo, setDemo] = useState(true);
const [dragging, setDragging] = useState(false);
const trackRef = useRef<HTMLDivElement>(null);
const knobRef = useRef<HTMLButtonElement>(null);
const drag = useRef({ on: false, startX: 0, startOffset: 0, offset: 0, moved: false });
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
const cls = [
styles.root,
demo ? styles.isDemo : "",
open ? styles.isOpen : "",
dragging ? styles.isDragging : "",
].filter(Boolean).join(" ");
function travel(): number {
// 노브가 갈 수 있는 최대 거리 = 트랙 안폭 - 노브 폭 (레일 여백 4px 씩)
const track = trackRef.current;
const knob = knobRef.current;
if (!track || !knob) return 0;
return track.clientWidth - knob.offsetWidth - 8;
}
function place(px: number) {
const knob = knobRef.current;
const d = drag.current;
if (!knob || !d) return;
d.offset = Math.max(0, Math.min(travel(), px));
knob.style.transform = "translateX(" + d.offset + "px)";
}
function settle(next: boolean) {
const knob = knobRef.current;
const d = drag.current;
if (knob) knob.style.transform = "";
setOpen(next);
if (d) d.offset = next ? travel() : 0;
}
function onDown(e: PtrEvt) {
const d = drag.current;
if (!d) return;
setDemo(false);
d.on = true;
d.moved = false;
d.startX = e.clientX;
d.startOffset = open ? travel() : 0;
setDragging(true);
e.currentTarget.setPointerCapture(e.pointerId);
}
function onMove(e: PtrEvt) {
const d = drag.current;
if (!d || !d.on) return;
// 끌기가 일어난 뒤 따라오는 click 은 토글이 아니라 끌기의 끝이다
if (Math.abs(e.clientX - d.startX) > 6) d.moved = true;
place(d.startOffset + (e.clientX - d.startX));
}
function onUp() {
const d = drag.current;
if (!d || !d.on) return;
d.on = false;
setDragging(false);
// 절반을 넘겼으면 남은 거리를 관성으로 마저 달리고, 못 넘겼으면 제자리로 돌아온다
settle(d.offset > travel() / 2);
}
function onKey() {
const d = drag.current;
setDemo(false);
if (d && d.moved) {
d.moved = false;
return; // 끌기 직후의 click 은 무시한다
}
settle(!open);
}
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<section className={styles.panel}>
<p className={styles.label}>{LABEL_TEXT}</p>
<div className={styles.track} ref={trackRef}>
<span className={styles.fill} aria-hidden="true"></span>
<button
className={styles.knob}
type="button"
role="switch"
aria-checked={open}
aria-label={SWITCH_LABEL}
ref={knobRef}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onClick={onKey}
>
<span className={styles.grip} aria-hidden="true"></span>
</button>
</div>
<p className={styles.hint}>{HINT_TEXT}</p>
<p className={styles.state} role="status">
<span className={styles.wordOff}>{STATE_OFF}</span>
<span className={styles.wordOn}>{STATE_ON}</span>
</p>
</section>
</div>
</main>
);
}09 끌었다 놓는 관성 토글 — 측정 누적면적 5.107% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · CSS 토글 버튼 9종 — 실물처럼 만드는 토글 스위치 UI
이 데모가 속한 글
CSS 토글 버튼 9종 — 실물처럼 만드는 토글 스위치 UIcss 토글 버튼은 화면 속에서 전원을 넣고 끄는 작은 스위치를 뜻합니다. 아홉 개를 라이브러리 없이 체크박스 하나로 만들었고, 아래 격자에서 지금 돌아갑니다. 클릭·Space·드래그 전부 실제로 움직입니다. 이 글의 데모 전부 보기

