모바일 터치 제스처 9종 — 손가락 하나로 돌아가는 UI
모바일 터치 제스처는 손가락이 화면에 닿아 움직인 거리와 속도를 읽어 화면을 바꾸는 조작 방식입니다. 아홉 개 중 둘은 손을 떼는 순간에야 결과가 갈리고, 하나는 끝까지 밀지 않으면 아무 일도 일어나지 않습니다.
01 gesture-double-tap-zoom
02 gesture-slide-to-unlock-track
03 gesture-drag-dial-value
04 gesture-swipe-tab-switch
05 gesture-swipe-card-deck
06 gesture-edge-swipe-back
07 gesture-rubber-band-boundary
08 gesture-bottom-sheet-snap-drag
09 gesture-pinch-zoom-pan
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 더블탭 줌
- 02 슬라이드 잠금 해제 트랙
- 03 돌려서 조절하는 다이얼
- 04 스와이프 탭 전환
- 05 스와이프 카드 덱
- 06 가장자리 스와이프 백
- 07 러버밴드 경계 저항
- 08 끌어올리는 바텀시트
- 09 핀치 줌
아홉 개는 유행 순서가 아니라 손가락이 화면에 닿아 있는 시간 순서로 놓았습니다. 처음은 닿았다 떨어지기만 하는 탭 두 번(01)이고, 그다음은 끝까지 밀어야 끝나는 한 방향 밀기(02)입니다. 이어서 손가락이 원을 그리며 값을 바꾸고(03), 화면 한 장을 통째로 밀어 넘기고(04), 민 거리로 통과와 거절을 가릅니다(05). 여섯 번째부터는 손가락이 시작한 자리가 의미를 갖습니다. 화면 가장자리에서 시작하면 뒤로 가기(06)가 되고, 목록 끝에서 더 당기면 저항이 붙으며(07), 아래에서 끌어올리면 시트가 단계에 걸립니다(08). 마지막 하나(09)만 손가락이 둘인 모바일 핀치줌입니다. 아홉 개 전부 pointerdown·pointermove·pointerup 하나로 마우스와 손가락을 같이 받고, 제스처마다 버튼이나 화살표 키라는 대체 조작이 따로 들어 있습니다. 같은 동작을 마우스로 옮겨 놓은 목록은 마우스 이펙트에 있고, 드래그가 영역 너비를 바꾸는 쪽은 CSS 화면 분할 패널로 이어집니다.
01더블탭 줌
모바일 제스처에서 무엇이 보이나
사진을 두 번 연속 누르면 두 번째로 닿은 자리를 중심으로 확대되고, 한 번 더 누르면 원래 크기로 돌아옵니다.

<main class="stage">
<div class="dz is-demo">
<div class="dz__main">
<div class="dz__photo" id="dz-photo">
<svg viewBox="0 0 200 130" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect class="dz__sky" width="200" height="130"/>
<circle class="dz__sun" cx="150" cy="38" r="17"/>
<polygon class="dz__m1" points="0,130 62,52 124,130"/>
<polygon class="dz__m2" points="84,130 148,66 200,116 200,130"/>
<rect class="dz__lake" y="104" width="200" height="26"/>
</svg>
<span class="dz__ring" id="dz-ring" aria-hidden="true"></span>
</div>
<div class="dz__side">
<button class="dz__btn" type="button" id="dz-in" aria-label="한 단계 확대">
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8"/></g></svg>
</button>
<span class="dz__level" id="dz-level" aria-live="polite">1.0×</span>
<button class="dz__btn" type="button" id="dz-out" aria-label="한 단계 축소">
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8"/></g></svg>
</button>
</div>
</div>
<p class="dz__hint" id="dz-hint"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M216 148v36c0 24-8 40-8 40H72l-29.32-50a20 20 0 0 1 34.64-20L96 184V76a20 20 0 0 1 40 0v56a20 20 0 0 1 40 0v16a20 20 0 0 1 40 0" opacity=".2"/><path d="M56 76a60 60 0 0 1 120 0a8 8 0 0 1-16 0a44 44 0 0 0-88 0a8 8 0 1 1-16 0m140 44a27.9 27.9 0 0 0-13.36 3.39A28 28 0 0 0 144 106.7V76a28 28 0 0 0-56 0v80l-3.82-6.13a28 28 0 0 0-48.41 28.17l29.32 50a8 8 0 1 0 13.8-8.04L49.6 170a12 12 0 1 1 20.78-12l.14.23l18.68 30A8 8 0 0 0 104 184V76a12 12 0 0 1 24 0v68a8 8 0 1 0 16 0v-12a12 12 0 0 1 24 0v20a8 8 0 0 0 16 0v-4a12 12 0 0 1 24 0v36c0 21.61-7.1 36.3-7.16 36.42a8 8 0 0 0 3.58 10.73a7.9 7.9 0 0 0 3.58.85a8 8 0 0 0 7.16-4.42c.37-.73 8.85-18 8.85-43.58v-36A28 28 0 0 0 196 120"/></g></svg> 사진을 두 번 눌러 확대</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.dz {
display: flex;
flex-direction: column-reverse; // 힌트를 위로 — 확대된 사진이 힌트를 덮지 않게
align-items: center;
user-select: none;
-webkit-user-select: none;
}
.dz__main { display: flex; align-items: center; gap: $sp-4; }
.dz__photo {
position: relative;
width: 208px;
height: 132px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-sky;
transform: scale(1);
transition: transform $duration $easing;
cursor: zoom-in;
touch-action: manipulation;
}
.dz__photo svg { display: block; width: 100%; height: 100%; }
.dz__sky { fill: $subject-sky; }
.dz__sun { fill: $stage-orange; }
.dz__m1 { fill: $subject-ink; }
.dz__m2 { fill: $subject-lilac; }
.dz__lake { fill: $subject-blue; opacity: .55; }
.dz__ring {
position: absolute;
width: 36px; height: 36px;
margin: -18px 0 0 -18px;
border: 2px solid #fff;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
.dz__ring.is-pulse { animation: dz-pulse $dur-quick $ease-out; }
@keyframes dz-pulse {
0% { opacity: .9; transform: scale(.4); }
100% { opacity: 0; transform: scale(1.15); }
}
.dz__side { display: flex; flex-direction: column; align-items: center; gap: $sp-2; }
.dz__btn {
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 36px;
border: 1px solid rgba(23, 20, 26, .18);
background: #fff;
border-radius: $r-control;
color: $subject-ink;
cursor: pointer;
transition: background $dur-quick $easing, color $dur-quick $easing;
}
.dz__btn:hover { background: $color; color: #fff; }
.dz__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.dz__level {
font-size: 12px; line-height: 14px; font-weight: 700;
color: $subject-ink;
font-variant-numeric: tabular-nums;
}
.dz__hint {
position: relative;
z-index: 1;
display: flex; align-items: center; gap: $sp-1;
margin: 0 0 $sp-3;
font-size: 11px; line-height: 14px; font-weight: 600;
color: rgba(23, 20, 26, .72);
}
.dz__hint svg { flex: none; color: rgba(23, 20, 26, .72); }
// ── 자동 루프: 탭 두 번 → 확대 → 유지 → 복귀. 복귀는 90% 안에 끝난다 ──
.dz.is-demo .dz__photo {
transform-origin: 148px 42px; // 규칙8: 탭 좌표(148,42) 줌 앵커
animation: dz-zoom $dur-loop $easing infinite;
}
@keyframes dz-zoom {
0%, 100% { transform: scale(1); }
30% { transform: scale(1); }
44% { transform: scale(2.1); }
76% { transform: scale(2.1); }
88% { transform: scale(1); }
}
.dz.is-demo .dz__ring {
left: 148px; top: 42px;
animation: dz-taps $dur-loop $ease-out infinite;
}
@keyframes dz-taps {
0%, 8% { opacity: 0; transform: scale(.4); }
13% { opacity: .95; transform: scale(.85); }
19% { opacity: 0; transform: scale(1.15); }
21% { opacity: 0; transform: scale(.4); }
26% { opacity: .95; transform: scale(.85); }
32% { opacity: 0; transform: scale(1.15); }
100% { opacity: 0; transform: scale(1.15); }
}
@media (prefers-reduced-motion: reduce) {
.dz.is-demo .dz__photo,
.dz.is-demo .dz__ring,
.dz.is-demo .dz__level { animation: none !important; }
.dz__photo, .dz__btn { transition: none; }
.dz__photo { transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dz {
display: flex;
flex-direction: column-reverse;
align-items: center;
user-select: none;
-webkit-user-select: none;
}
.dz__main {
display: flex;
align-items: center;
gap: 16px;
}
.dz__photo {
position: relative;
width: 208px;
height: 132px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #6ec8ff;
transform: scale(1);
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
cursor: zoom-in;
touch-action: manipulation;
}
.dz__photo svg {
display: block;
width: 100%;
height: 100%;
}
.dz__sky {
fill: #6ec8ff;
}
.dz__sun {
fill: #ff4d1f;
}
.dz__m1 {
fill: #17141a;
}
.dz__m2 {
fill: #b9a0ff;
}
.dz__lake {
fill: #2f6df6;
opacity: 0.55;
}
.dz__ring {
position: absolute;
width: 36px;
height: 36px;
margin: -18px 0 0 -18px;
border: 2px solid #fff;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
.dz__ring.is-pulse {
animation: dz-pulse 220ms ease-out;
}
@keyframes dz-pulse {
0% {
opacity: 0.9;
transform: scale(0.4);
}
100% {
opacity: 0;
transform: scale(1.15);
}
}
.dz__side {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.dz__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid rgba(23, 20, 26, 0.18);
background: #fff;
border-radius: 12px;
color: #17141a;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dz__btn:hover {
background: #2f6df6;
color: #fff;
}
.dz__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.dz__level {
font-size: 12px;
line-height: 14px;
font-weight: 700;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.dz__hint {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 12px;
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
}
.dz__hint svg {
flex: none;
color: rgba(23, 20, 26, 0.72);
}
.dz.is-demo .dz__photo {
transform-origin: 148px 42px;
animation: dz-zoom 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes dz-zoom {
0%, 100% {
transform: scale(1);
}
30% {
transform: scale(1);
}
44% {
transform: scale(2.1);
}
76% {
transform: scale(2.1);
}
88% {
transform: scale(1);
}
}
.dz.is-demo .dz__ring {
left: 148px;
top: 42px;
animation: dz-taps 2s ease-out infinite;
}
@keyframes dz-taps {
0%, 8% {
opacity: 0;
transform: scale(0.4);
}
13% {
opacity: 0.95;
transform: scale(0.85);
}
19% {
opacity: 0;
transform: scale(1.15);
}
21% {
opacity: 0;
transform: scale(0.4);
}
26% {
opacity: 0.95;
transform: scale(0.85);
}
32% {
opacity: 0;
transform: scale(1.15);
}
100% {
opacity: 0;
transform: scale(1.15);
}
}
@media (prefers-reduced-motion: reduce) {
.dz.is-demo .dz__photo,
.dz.is-demo .dz__ring,
.dz.is-demo .dz__level {
animation: none !important;
}
.dz__photo, .dz__btn {
transition: none;
}
.dz__photo {
transform: none;
}
}// 실제 동작: 두 번 누르면 그 지점 중심으로 확대/축소, 버튼은 같은 단계를 밟는다.
var root = document.querySelector('.dz');
var photo = document.getElementById('dz-photo');
var ring = document.getElementById('dz-ring');
var level = document.getElementById('dz-level');
var STEPS = [1, 1.55, 2.1];
var at = 0, lastTap = 0;
function go(i, ox, oy) {
at = Math.max(0, Math.min(i, STEPS.length - 1));
if (ox !== undefined) photo.style.transformOrigin = ox + 'px ' + oy + 'px';
photo.style.transform = 'scale(' + STEPS[at] + ')';
level.textContent = STEPS[at].toFixed(1) + '×';
}
function pulse(x, y) {
ring.style.left = x + 'px';
ring.style.top = y + 'px';
ring.classList.remove('is-pulse');
void ring.offsetWidth;
ring.classList.add('is-pulse');
}
photo.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
var now = Date.now();
if (now - lastTap < 320) {
var r = photo.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
pulse(x, y);
go(at === STEPS.length - 1 ? 0 : at + 1, x, y);
lastTap = 0;
e.preventDefault();
} else { lastTap = now; }
});
document.getElementById('dz-in').addEventListener('click', function () { root.classList.remove('is-demo'); go(at + 1, 150, 44); });
document.getElementById('dz-out').addEventListener('click', function () { root.classList.remove('is-demo'); go(at - 1, 150, 44); });import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DoubleTapZoom.module.scss";
export type DoubleTapZoomProps = {
steps?: number[];
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; clientY: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type Box<T> = { current: T };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconPlus = () => (
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8"/></g></svg>
);
const IconMinus = () => (
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8"/></g></svg>
);
export default function DoubleTapZoom({
steps = [1, 1.55, 2.1],
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: DoubleTapZoomProps) {
const [at, setAt] = useState(0);
const [origin, setOrigin] = useState("50% 50%");
const [ring, setRing] = useState<{ x: number; y: number; k: number } | null>(null);
const lastTap = useRef(0) as Box<number>;
const photoRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function go(i: number, ox?: number, oy?: number) {
const next = Math.max(0, Math.min(i, steps.length - 1));
if (ox !== undefined && oy !== undefined) setOrigin(`${ox}px ${oy}px`);
setAt(next);
}
function onDown(e: PEvt) {
const now = Date.now();
if (now - lastTap.current < 320) {
const r = (photoRef.current as HTMLDivElement).getBoundingClientRect();
const x = e.clientX - r.left, y = e.clientY - r.top;
setRing({ x, y, k: now });
go(at === steps.length - 1 ? 0 : at + 1, x, y);
lastTap.current = 0;
} else {
lastTap.current = now;
}
}
return (
<div className={styles.dz} style={vars}>
<div
ref={photoRef}
className={styles.photo}
style={{ transform: `scale(${steps[at]})`, transformOrigin: origin }}
onPointerDown={onDown}
>
<svg viewBox="0 0 200 130" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect className={styles.sky} width="200" height="130" />
<circle className={styles.sun} cx="150" cy="38" r="17" />
<polygon className={styles.m1} points="0,130 62,52 124,130" />
<polygon className={styles.m2} points="84,130 148,66 200,116 200,130" />
<rect className={styles.lake} y="104" width="200" height="26" />
</svg>
{ring && (
<span key={ring.k} className={styles.ring} style={{ left: ring.x, top: ring.y }} aria-hidden="true" />
)}
</div>
<div className={styles.side}>
<button type="button" className={styles.btn} aria-label="Zoom in one step" onClick={(): void => go(at + 1, 150, 44)}>
<IconPlus />
</button>
<span className={styles.level} aria-live="polite">{steps[at].toFixed(1)}x</span>
<button type="button" className={styles.btn} aria-label="Zoom out one step" onClick={(): void => go(at - 1, 150, 44)}>
<IconMinus />
</button>
</div>
</div>
);
}01 · 더블탭 줌 — 측정 누적면적 60.957% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
확대 중심은 transformOrigin에 탭 좌표를 그대로 넣어 잡고, 옆의 더하기·빼기 버튼은 같은 단계 배열을 밟습니다.
photo.addEventListener('pointerdown', function (e) {
var now = Date.now();
if (now - lastTap < 320) {
var r = photo.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
pulse(x, y);
go(at === STEPS.length - 1 ? 0 : at + 1, x, y);
lastTap = 0;
e.preventDefault();
} else { lastTap = now; }
});
02슬라이드 잠금 해제 트랙
화면에서 일어나는 일
손잡이를 트랙 끝까지 밀어야만 잠금이 풀리고, 중간에 손을 떼면 제자리로 돌아옵니다.

<main class="stage">
<div class="su is-demo" id="su-root">
<div class="su__track" id="su-track">
<span class="su__label" aria-hidden="true"><span class="su__t1">밀어서 잠금 해제</span><span class="su__t2" id="su-lit">밀어서 잠금 해제</span></span>
<span class="su__lock" id="su-lock" aria-hidden="true">
<svg class="su__ic su__ic--closed" viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>
<svg class="su__ic su__ic--open" viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>
<svg class="su__ic su__ic--done" viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
</span>
<button class="su__handle" type="button" id="su-handle" aria-label="오른쪽 끝까지 밀어 잠금 해제">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>
</button>
</div>
<p class="su__state" id="su-state" aria-live="polite">잠김 — 끝까지 밀어 주세요</p>
<span hidden id="su-msg-done">잠금 해제됨 — 1.6초 뒤 다시 잠깁니다</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.su { user-select: none; -webkit-user-select: none; }
.su__track {
position: relative;
box-sizing: border-box;
width: min(248px, 84vw);
height: 48px;
background: $color;
border-radius: $r-pill;
box-shadow: $shadow-press;
}
.su__label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
padding-left: 48px;
font-size: 12px; line-height: 14px; font-weight: 700;
letter-spacing: .02em;
}
.su__t1 { color: rgba(255, 247, 230, .72); } // 먹 트랙 위 흐린 크림 2.79:1 → 9.2:1
.su__t2 {
position: absolute;
left: 48px; top: 50%;
transform: translateY(-50%);
width: 36px;
overflow: hidden;
white-space: nowrap;
color: $subject-cream;
}
.su__lock {
position: absolute;
right: $sp-3;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center; justify-content: center;
width: 28px; height: 28px;
border-radius: 50%;
background: rgba(255, 247, 230, .14);
color: rgba(255, 247, 230, .6);
transition: background $duration $easing, color $duration $easing, transform $duration $easing;
}
.su__ic { position: absolute; transition: opacity $dur-quick steps(1, end), transform $duration $easing; }
.su__ic--open, .su__ic--done { opacity: 0; }
.su__track.is-done .su__lock { background: $stage-yellow; color: $color; transform: translateY(-50%) scale(1.12); }
.su__track.is-done .su__ic--closed { opacity: 0; }
.su__track.is-done .su__ic--open { opacity: 0; }
.su__track.is-done .su__ic--done { opacity: 1; }
.su__handle {
position: absolute;
left: $sp-2;
top: 50%;
transform: translateY(-50%) translateX(0);
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 36px;
border: 0;
background: $subject-cream;
color: $color;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(23, 20, 26, .35);
cursor: grab;
touch-action: none;
transition: transform $duration $easing, box-shadow $duration $easing;
}
.su__handle.is-drag { box-shadow: 0 4px 10px rgba(23, 20, 26, .45); }
.su__handle:active { cursor: grabbing; }
.su__handle:focus-visible { outline: 3px solid rgba(23, 20, 26, .55); outline-offset: 2px; }
.su__state {
margin: $sp-3 0 0;
font-size: 11px; line-height: 14px; font-weight: 600;
color: rgba(23, 20, 26, .72);
}
// ── 자동 루프: 밀기 → 잠금 해제 → 유지 → 페이드아웃(보이지 않을 때 초기화) → 복귀 ──
.su.is-demo .su__handle { animation: su-slide $dur-loop $easing infinite; }
@keyframes su-slide {
0% { transform: translateY(-50%) translateX(0); }
34% { transform: translateY(-50%) translateX(204px); }
38% { transform: translateY(-50%) translateX(204px) scale(1.1, .9); }
42%, 66% { transform: translateY(-50%) translateX(204px); }
72% { transform: translateY(-50%) translateX(204px); opacity: 1; }
78% { transform: translateY(-50%) translateX(204px); opacity: 0; }
79% { transform: translateY(-50%) translateX(0); opacity: 0; }
88%, 100% { transform: translateY(-50%) translateX(0); opacity: 1; }
}
.su.is-demo .su__t2 { animation: su-lit $dur-loop linear infinite; }
@keyframes su-lit {
0% { width: 36px; }
34%, 78% { width: 240px; }
79% { width: 36px; }
100% { width: 36px; }
}
.su.is-demo .su__lock { animation: su-lock $dur-loop steps(1, end) infinite; }
@keyframes su-lock {
0%, 41% { background: rgba(255, 247, 230, .14); color: rgba(255, 247, 230, .6); }
42%, 72% { background: $stage-yellow; color: $subject-ink; }
73%, 100% { background: rgba(255, 247, 230, .14); color: rgba(255, 247, 230, .6); }
}
@media (prefers-reduced-motion: reduce) {
.su.is-demo .su__handle,
.su.is-demo .su__t2,
.su.is-demo .su__lock { animation: none !important; }
.su__handle, .su__lock { transition: none; }
}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);
}
.su {
user-select: none;
-webkit-user-select: none;
}
.su__track {
position: relative;
box-sizing: border-box;
width: min(248px, 84vw);
height: 48px;
background: #17141a;
border-radius: 999px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.su__label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
padding-left: 48px;
font-size: 12px;
line-height: 14px;
font-weight: 700;
letter-spacing: 0.02em;
}
.su__t1 {
color: rgba(255, 247, 230, 0.72);
}
.su__t2 {
position: absolute;
left: 48px;
top: 50%;
transform: translateY(-50%);
width: 36px;
overflow: hidden;
white-space: nowrap;
color: #fff7e6;
}
.su__lock {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 247, 230, 0.14);
color: rgba(255, 247, 230, 0.6);
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1), color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.su__ic {
position: absolute;
transition: opacity 220ms steps(1, end), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.su__ic--open, .su__ic--done {
opacity: 0;
}
.su__track.is-done .su__lock {
background: #ffd23f;
color: #17141a;
transform: translateY(-50%) scale(1.12);
}
.su__track.is-done .su__ic--closed {
opacity: 0;
}
.su__track.is-done .su__ic--open {
opacity: 0;
}
.su__track.is-done .su__ic--done {
opacity: 1;
}
.su__handle {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%) translateX(0);
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 0;
background: #fff7e6;
color: #17141a;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.35);
cursor: grab;
touch-action: none;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.su__handle.is-drag {
box-shadow: 0 4px 10px rgba(23, 20, 26, 0.45);
}
.su__handle:active {
cursor: grabbing;
}
.su__handle:focus-visible {
outline: 3px solid rgba(23, 20, 26, 0.55);
outline-offset: 2px;
}
.su__state {
margin: 12px 0 0;
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
}
.su.is-demo .su__handle {
animation: su-slide 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes su-slide {
0% {
transform: translateY(-50%) translateX(0);
}
34% {
transform: translateY(-50%) translateX(204px);
}
38% {
transform: translateY(-50%) translateX(204px) scale(1.1, 0.9);
}
42%, 66% {
transform: translateY(-50%) translateX(204px);
}
72% {
transform: translateY(-50%) translateX(204px);
opacity: 1;
}
78% {
transform: translateY(-50%) translateX(204px);
opacity: 0;
}
79% {
transform: translateY(-50%) translateX(0);
opacity: 0;
}
88%, 100% {
transform: translateY(-50%) translateX(0);
opacity: 1;
}
}
.su.is-demo .su__t2 {
animation: su-lit 2s linear infinite;
}
@keyframes su-lit {
0% {
width: 36px;
}
34%, 78% {
width: 240px;
}
79% {
width: 36px;
}
100% {
width: 36px;
}
}
.su.is-demo .su__lock {
animation: su-lock 2s steps(1, end) infinite;
}
@keyframes su-lock {
0%, 41% {
background: rgba(255, 247, 230, 0.14);
color: rgba(255, 247, 230, 0.6);
}
42%, 72% {
background: #ffd23f;
color: #17141a;
}
73%, 100% {
background: rgba(255, 247, 230, 0.14);
color: rgba(255, 247, 230, 0.6);
}
}
@media (prefers-reduced-motion: reduce) {
.su.is-demo .su__handle,
.su.is-demo .su__t2,
.su.is-demo .su__lock {
animation: none !important;
}
.su__handle, .su__lock {
transition: none;
}
}// 실제 동작: 손잡이를 끝까지 밀면 잠금 해제, 중간에 떼면 스프링으로 복귀. 화살표 키·Enter 도 같은 검사를 밟는다.
var root = document.getElementById('su-root');
var track = document.getElementById('su-track');
var handle = document.getElementById('su-handle');
var lit = document.getElementById('su-lit');
var state = document.getElementById('su-state');
var MAX, x = 0, dragging = false, sx = 0, x0 = 0, done = false;
var MSG_DONE = document.getElementById('su-msg-done').textContent;
var MSG_LOCKED = state.textContent;
function end() { MAX = track.clientWidth - handle.clientWidth - 8; }
end();
window.addEventListener('resize', end);
function paint(px) {
x = Math.max(0, Math.min(px, MAX));
handle.style.transform = 'translateX(' + x + 'px)';
handle.classList.toggle('is-drag', x > 0 && x < MAX);
lit.style.width = (x + 36) + 'px';
if (x >= MAX - 1 && !done) unlock();
}
function unlock() {
done = true;
track.classList.add('is-done');
state.textContent = MSG_DONE;
setTimeout(relock, 1600);
}
function relock() {
done = false;
track.classList.remove('is-done');
handle.style.transition = 'none';
paint(0);
void handle.offsetWidth;
handle.style.transition = '';
state.textContent = MSG_LOCKED;
}
handle.addEventListener('pointerdown', function (e) {
if (done) return;
root.classList.remove('is-demo');
dragging = true; sx = e.clientX; x0 = x;
handle.setPointerCapture(e.pointerId);
handle.style.transition = 'none';
e.preventDefault();
});
handle.addEventListener('pointermove', function (e) {
if (!dragging) return;
paint(x0 + (e.clientX - sx));
});
function drop() {
if (!dragging) return;
dragging = false;
handle.style.transition = '';
if (!done) paint(0);
}
handle.addEventListener('pointerup', drop);
handle.addEventListener('pointercancel', drop);
handle.addEventListener('keydown', function (e) {
if (done) return;
if (e.key === 'ArrowRight') { root.classList.remove('is-demo'); handle.style.transition = 'none'; paint(x + 12); window.setTimeout(function () { handle.style.transition = ''; }, 60); e.preventDefault(); }
else if (e.key === 'Enter' && x >= MAX - 14) { root.classList.remove('is-demo'); paint(MAX); e.preventDefault(); }
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SlideUnlock.module.scss";
export type SlideUnlockProps = {
label?: string;
doneLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; clientY: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconArrow = () => (
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>
);
const IconDone = () => (
<svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
const IconLock = () => (
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>
);
export default function SlideUnlock({
label = "Slide to unlock",
doneLabel = "Unlocked — relocks in 1.6s",
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: SlideUnlockProps) {
const trackRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLButtonElement>(null);
const [x, setX] = useState(0);
const [done, setDone] = useState(false);
const drag = useRef({ on: false, sx: 0, x0: 0 }) as Box<{ on: boolean; sx: number; x0: number }>;
const maxRef = useRef(204) as Box<number>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function measure() {
const t = trackRef.current, h = handleRef.current;
if (t && h) maxRef.current = t.clientWidth - h.clientWidth - 8;
}
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, []);
useEffect(() => {
if (!done) return;
const t = window.setTimeout(() => { setDone(false); setX(0); }, 1600);
return () => window.clearTimeout(t);
}, [done]);
function paint(px: number) {
const max = maxRef.current;
const nx = Math.max(0, Math.min(px, max));
setX(nx);
if (nx >= max - 1 && !done) setDone(true);
}
function onDown(e: PEvt) {
if (done) return;
drag.current = { on: true, sx: e.clientX, x0: x };
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onMove(e: PEvt) {
if (!drag.current.on) return;
paint(drag.current.x0 + (e.clientX - drag.current.sx));
}
function onUp() { drag.current.on = false; if (!done) paint(0); }
function onKey(e: KeyEvt) {
if (done) return;
if (e.key === "ArrowRight") { paint(x + 12); e.preventDefault(); }
else if (e.key === "Enter" && x >= maxRef.current - 14) { paint(maxRef.current); e.preventDefault(); }
}
return (
<div className={styles.su} style={vars}>
<div ref={trackRef} className={styles.track}>
<span className={styles.t1} aria-hidden="true">{label}</span>
<span className={styles.t2} aria-hidden="true" style={{ width: x + 36 }}>{label}</span>
<span className={done ? styles.lockDone : styles.lock} aria-hidden="true">
{done ? <IconDone /> : <IconLock />}
</span>
<button
ref={handleRef}
type="button"
className={styles.handle}
aria-label="Slide all the way right to unlock"
style={{ transform: `translate(-50%, -50%) translateX(${8 + x}px)` }}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onKeyDown={onKey}
>
<IconArrow />
</button>
</div>
<p className={styles.state} aria-live="polite">{done ? doneLabel : `${label} — hold and push right`}</p>
</div>
);
}02 · 슬라이드 잠금 해제 트랙 — 측정 누적면적 3.585% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
손잡이를 잡는 순간 setPointerCapture가 그 포인터를 붙들어, 손가락이 트랙 밖으로 나가도 이동이 끊기지 않습니다.
handle.addEventListener('pointerdown', function (e) {
if (done) return;
dragging = true; sx = e.clientX; x0 = x;
handle.setPointerCapture(e.pointerId);
handle.style.transition = 'none';
e.preventDefault();
});
handle.addEventListener('pointermove', function (e) {
if (!dragging) return;
paint(x0 + (e.clientX - sx));
});
03돌려서 조절하는 다이얼
어떤 움직임인가
원형 손잡이를 잡아 돌린 각도만큼 값이 오르내리고, 게이지 호가 그 각도까지 채워집니다.

<main class="stage">
<div class="dl is-demo" id="dl-root">
<div class="dl__dial" id="dl-dial" role="slider" tabindex="0" aria-label="볼륨" aria-valuemin="0" aria-valuemax="100" aria-valuenow="20" aria-orientation="vertical">
<svg class="dl__gauge" viewBox="0 0 100 100" aria-hidden="true">
<circle class="dl__track" cx="50" cy="50" r="42"/>
<circle class="dl__fill" id="dl-fill" cx="50" cy="50" r="42"/>
</svg>
<span class="dl__knob" id="dl-knob" aria-hidden="true">
<span class="dl__dot"></span>
<svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2"/><path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Z" /></g></svg>
</span>
</div>
<div class="dl__side">
<span class="dl__value" aria-live="polite"><span class="dl__n dl__n--a">20</span><span class="dl__n dl__n--b">60</span><span class="dl__n dl__n--c">95</span><span class="dl__unit">%</span></span>
<div class="dl__btns">
<button class="dl__btn" type="button" id="dl-dec" aria-label="볼륨 4 줄이기">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8"/></g></svg>
</button>
<button class="dl__btn" type="button" id="dl-inc" aria-label="볼륨 4 올리기">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8"/></g></svg>
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-orange);
.dl {
display: flex;
align-items: center;
gap: $sp-5;
user-select: none;
-webkit-user-select: none;
}
.dl__dial {
position: relative;
width: 128px;
height: 128px;
border-radius: 50%;
cursor: grab;
touch-action: none;
}
.dl__dial:active { cursor: grabbing; }
.dl__dial:focus-visible { outline: 3px solid $subject-cream; outline-offset: 4px; }
.dl__gauge { position: absolute; inset: 0; transform: rotate(135deg); }
.dl__track, .dl__fill {
fill: none;
stroke-width: 9;
stroke-linecap: round;
}
.dl__track { stroke: rgba(255, 247, 230, .45); stroke-dasharray: 197.92 66.06; }
.dl__fill {
stroke: $color;
stroke-dasharray: 197.92 66.06; // 270도만 그린다 — gap 을 안 주면 dash 가 반복돼 호가 꼬인다
stroke-dashoffset: 197.92;
transition: stroke-dashoffset $duration $easing;
}
.dl__knob svg { transition: transform $duration $easing; }
.dl__knob {
position: absolute;
inset: 17px;
display: flex;
align-items: center;
justify-content: center;
background: $subject-cream; // 노브 판은 크림, 게이지 호는 $color(잉크) — 서로 대비
border-radius: 50%;
box-shadow: $shadow-press;
color: $color;
transform: rotate(225deg);
transition: transform $duration $easing;
}
.dl__dot {
position: absolute;
top: $sp-2;
left: 50%;
width: 8px; height: 8px;
margin-left: -4px;
border-radius: 50%;
background: $stage-orange;
}
.dl__side { display: flex; flex-direction: column; align-items: center; gap: $sp-3; }
.dl__value {
position: relative;
height: 26px;
min-width: 72px;
text-align: center;
}
.dl__n {
position: absolute;
right: 18px;
font-size: 22px; line-height: 26px; font-weight: 800;
color: $subject-ink;
font-variant-numeric: tabular-nums;
opacity: 0;
transition: opacity $dur-quick steps(1, end);
}
.dl__n.is-on { opacity: 1; }
.dl__n--a.is-on { opacity: 1; }
.dl__unit {
position: absolute;
right: 0;
font-size: 12px; line-height: 30px; font-weight: 700;
color: $subject-ink; // 주황 무대 위 흐린 잉크 2.74:1 → 5.50:1
}
.dl__btns { display: flex; gap: $sp-2; }
.dl__btn {
display: inline-flex; align-items: center; justify-content: center;
width: 34px; height: 34px;
border: 1px solid rgba(255, 247, 230, .6);
background: rgba(255, 247, 230, .12);
border-radius: $r-control;
color: $subject-cream;
cursor: pointer;
transition: background $duration $easing;
}
.dl__btn:hover { background: $subject-cream; color: $stage-orange; }
.dl__btn:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// ── 자동 루프: 20 → 60 → 95 로 돌고 되돌아온다. 숫자 교체는 steps 컷 ──
.dl.is-demo .dl__knob { animation: dl-turn $dur-loop $easing infinite; }
.dl.is-demo .dl__knob svg { animation: dl-turn-back $dur-loop $easing infinite; }
@keyframes dl-turn-back {
0% { transform: rotate(-225deg); }
30%, 40% { transform: rotate(-387deg); }
66%, 76% { transform: rotate(-481.5deg); }
88%, 100% { transform: rotate(-225deg); }
}
@keyframes dl-turn {
0% { transform: rotate(225deg); }
30%, 40% { transform: rotate(387deg); }
66%, 76% { transform: rotate(481.5deg); }
88%, 100% { transform: rotate(225deg); }
}
.dl.is-demo .dl__fill { animation: dl-fill $dur-loop $easing infinite; }
@keyframes dl-fill {
0% { stroke-dashoffset: 197.92; }
30%, 40% { stroke-dashoffset: 79.17; }
66%, 76% { stroke-dashoffset: 9.9; }
88%, 100% { stroke-dashoffset: 197.92; }
}
.dl.is-demo .dl__n { transition: none; }
.dl.is-demo .dl__n--a { animation: dl-na $dur-loop steps(1, end) infinite; }
.dl.is-demo .dl__n--b { animation: dl-nb $dur-loop steps(1, end) infinite; }
.dl.is-demo .dl__n--c { animation: dl-nc $dur-loop steps(1, end) infinite; }
@keyframes dl-na { 0%, 29% { opacity: 1; } 30%, 100% { opacity: 0; } }
@keyframes dl-nb { 0%, 29% { opacity: 0; } 30%, 65% { opacity: 1; } 66%, 100% { opacity: 0; } }
@keyframes dl-nc { 0%, 65% { opacity: 0; } 66%, 87% { opacity: 1; } 88%, 100% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
.dl.is-demo .dl__knob,
.dl.is-demo .dl__fill,
.dl.is-demo .dl__n { animation: none !important; }
.dl.is-demo .dl__n--a { opacity: 1; }
.dl.is-demo .dl__n--b, .dl.is-demo .dl__n--c { opacity: 0; }
.dl__knob, .dl__fill { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dl {
display: flex;
align-items: center;
gap: 20px;
user-select: none;
-webkit-user-select: none;
}
.dl__dial {
position: relative;
width: 128px;
height: 128px;
border-radius: 50%;
cursor: grab;
touch-action: none;
}
.dl__dial:active {
cursor: grabbing;
}
.dl__dial:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 4px;
}
.dl__gauge {
position: absolute;
inset: 0;
transform: rotate(135deg);
}
.dl__track, .dl__fill {
fill: none;
stroke-width: 9;
stroke-linecap: round;
}
.dl__track {
stroke: rgba(255, 247, 230, 0.45);
stroke-dasharray: 197.92 66.06;
}
.dl__fill {
stroke: #17141a;
stroke-dasharray: 197.92 66.06;
stroke-dashoffset: 197.92;
transition: stroke-dashoffset 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dl__knob svg {
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dl__knob {
position: absolute;
inset: 17px;
display: flex;
align-items: center;
justify-content: center;
background: #fff7e6;
border-radius: 50%;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
transform: rotate(225deg);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dl__dot {
position: absolute;
top: 8px;
left: 50%;
width: 8px;
height: 8px;
margin-left: -4px;
border-radius: 50%;
background: #ff4d1f;
}
.dl__side {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.dl__value {
position: relative;
height: 26px;
min-width: 72px;
text-align: center;
}
.dl__n {
position: absolute;
right: 18px;
font-size: 22px;
line-height: 26px;
font-weight: 800;
color: #17141a;
font-variant-numeric: tabular-nums;
opacity: 0;
transition: opacity 220ms steps(1, end);
}
.dl__n.is-on {
opacity: 1;
}
.dl__n--a.is-on {
opacity: 1;
}
.dl__unit {
position: absolute;
right: 0;
font-size: 12px;
line-height: 30px;
font-weight: 700;
color: #17141a;
}
.dl__btns {
display: flex;
gap: 8px;
}
.dl__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: 1px solid rgba(255, 247, 230, 0.6);
background: rgba(255, 247, 230, 0.12);
border-radius: 12px;
color: #fff7e6;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dl__btn:hover {
background: #fff7e6;
color: #ff4d1f;
}
.dl__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.dl.is-demo .dl__knob {
animation: dl-turn 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.dl.is-demo .dl__knob svg {
animation: dl-turn-back 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes dl-turn-back {
0% {
transform: rotate(-225deg);
}
30%, 40% {
transform: rotate(-387deg);
}
66%, 76% {
transform: rotate(-481.5deg);
}
88%, 100% {
transform: rotate(-225deg);
}
}
@keyframes dl-turn {
0% {
transform: rotate(225deg);
}
30%, 40% {
transform: rotate(387deg);
}
66%, 76% {
transform: rotate(481.5deg);
}
88%, 100% {
transform: rotate(225deg);
}
}
.dl.is-demo .dl__fill {
animation: dl-fill 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes dl-fill {
0% {
stroke-dashoffset: 197.92;
}
30%, 40% {
stroke-dashoffset: 79.17;
}
66%, 76% {
stroke-dashoffset: 9.9;
}
88%, 100% {
stroke-dashoffset: 197.92;
}
}
.dl.is-demo .dl__n {
transition: none;
}
.dl.is-demo .dl__n--a {
animation: dl-na 2s steps(1, end) infinite;
}
.dl.is-demo .dl__n--b {
animation: dl-nb 2s steps(1, end) infinite;
}
.dl.is-demo .dl__n--c {
animation: dl-nc 2s steps(1, end) infinite;
}
@keyframes dl-na {
0%, 29% {
opacity: 1;
}
30%, 100% {
opacity: 0;
}
}
@keyframes dl-nb {
0%, 29% {
opacity: 0;
}
30%, 65% {
opacity: 1;
}
66%, 100% {
opacity: 0;
}
}
@keyframes dl-nc {
0%, 65% {
opacity: 0;
}
66%, 87% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.dl.is-demo .dl__knob,
.dl.is-demo .dl__fill,
.dl.is-demo .dl__n {
animation: none !important;
}
.dl.is-demo .dl__n--a {
opacity: 1;
}
.dl.is-demo .dl__n--b, .dl.is-demo .dl__n--c {
opacity: 0;
}
.dl__knob, .dl__fill {
transition: none;
}
}// 실제 동작: 각도(atan2)로 값 조절, 화살표 키·버튼은 4씩 계단식.
var root = document.getElementById('dl-root');
var dial = document.getElementById('dl-dial');
var knob = document.getElementById('dl-knob');
var fill = document.getElementById('dl-fill');
var icon = knob.querySelector('svg');
var nums = dial.parentElement.querySelectorAll('.dl__n');
var SPAN = 270, BASE = 225; // -135deg..+135deg
var value = 20;
var LEN = 2 * Math.PI * 42 * (SPAN / 360);
fill.style.strokeDasharray = LEN + ' ' + (2 * Math.PI * 42 - LEN);
function setValue(v) {
value = Math.max(0, Math.min(100, Math.round(v)));
var deg = BASE + (value / 100) * SPAN;
knob.style.transform = 'rotate(' + deg + 'deg)';
icon.style.transform = 'rotate(' + (-deg) + 'deg)'; // 아이콘은 노브와 반대로 — 늘 바로 선다
fill.style.strokeDashoffset = LEN * (1 - value / 100);
dial.setAttribute('aria-valuenow', value);
var near = value < 40 ? 0 : value < 78 ? 1 : 2;
nums.forEach(function (n, i) { n.classList.toggle('is-on', i === near); });
}
function fromPointer(e) {
var r = dial.getBoundingClientRect();
var dx = e.clientX - (r.left + r.width / 2);
var dy = e.clientY - (r.top + r.height / 2);
var deg = Math.atan2(dy, dx) * 180 / Math.PI; // -180..180 (0 = 오른쪽)
var rel = deg - BASE; // -135 기준 상대각
while (rel > 180) rel -= 360;
while (rel < -180) rel += 360;
if (rel < -SPAN / 2) rel = -SPAN / 2;
if (rel > SPAN / 2) rel = SPAN / 2;
setValue((rel + SPAN / 2) / SPAN * 100);
}
var dragging = false;
dial.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
dragging = true;
dial.setPointerCapture(e.pointerId);
fromPointer(e);
e.preventDefault();
});
dial.addEventListener('pointermove', function (e) { if (dragging) fromPointer(e); });
function drop() { dragging = false; }
dial.addEventListener('pointerup', drop);
dial.addEventListener('pointercancel', drop);
dial.addEventListener('keydown', function (e) {
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { root.classList.remove('is-demo'); setValue(value + 4); e.preventDefault(); }
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { root.classList.remove('is-demo'); setValue(value - 4); e.preventDefault(); }
else if (e.key === 'Home') { root.classList.remove('is-demo'); setValue(0); e.preventDefault(); }
else if (e.key === 'End') { root.classList.remove('is-demo'); setValue(100); e.preventDefault(); }
});
document.getElementById('dl-inc').addEventListener('click', function () { root.classList.remove('is-demo'); setValue(value + 4); });
document.getElementById('dl-dec').addEventListener('click', function () { root.classList.remove('is-demo'); setValue(value - 4); });
setValue(20);import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DragDial.module.scss";
export type DragDialProps = {
initial?: number;
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; clientY: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const SPAN = 270, BASE = 225;
const LEN = 2 * Math.PI * 42 * (SPAN / 360);
const IconSpeaker = () => (
<svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2"/><path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Z"/></g></svg>
);
const IconPlus = () => (
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8"/></g></svg>
);
const IconMinus = () => (
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8"/></g></svg>
);
export default function DragDial({
initial = 20,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: DragDialProps) {
const [value, setValue] = useState(initial);
const dialRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false) as Box<boolean>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function clamp(v: number) { return Math.max(0, Math.min(100, Math.round(v))); }
function fromPointer(e: PEvt) {
const el = dialRef.current as HTMLDivElement;
const r = el.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
let rel = Math.atan2(dy, dx) * 180 / Math.PI - BASE;
while (rel > 180) rel -= 360;
while (rel < -180) rel += 360;
rel = Math.max(-SPAN / 2, Math.min(SPAN / 2, rel));
setValue(((rel + SPAN / 2) / SPAN) * 100);
}
function onDown(e: PEvt) {
dragging.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
fromPointer(e);
e.preventDefault();
}
function onMove(e: PEvt) { if (dragging.current) fromPointer(e); }
function onUp() { dragging.current = false; }
function onKey(e: KeyEvt) {
if (e.key === "ArrowUp" || e.key === "ArrowRight") { setValue(clamp(value + 4)); e.preventDefault(); }
else if (e.key === "ArrowDown" || e.key === "ArrowLeft") { setValue(clamp(value - 4)); e.preventDefault(); }
else if (e.key === "Home") { setValue(0); e.preventDefault(); }
else if (e.key === "End") { setValue(100); e.preventDefault(); }
}
const deg = BASE + (value / 100) * SPAN;
return (
<div className={styles.dl} style={vars}>
<div
ref={dialRef}
className={styles.dial}
role="slider"
tabIndex={0}
aria-label="Volume"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(value)}
aria-orientation="vertical"
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onKeyDown={onKey}
>
<svg className={styles.gauge} viewBox="0 0 100 100" aria-hidden="true">
<circle className={styles.track} cx="50" cy="50" r="42" />
<circle
className={styles.fill}
cx="50" cy="50" r="42"
style={{ strokeDasharray: LEN, strokeDashoffset: LEN * (1 - value / 100) }}
/>
</svg>
<span className={styles.knob} style={{ transform: `rotate(${deg}deg)` }} aria-hidden="true">
<span className={styles.dot} />
<IconSpeaker />
</span>
</div>
<div className={styles.side}>
<span className={styles.value} aria-live="polite">{Math.round(value)}%</span>
<div className={styles.btns}>
<button type="button" className={styles.btn} aria-label="Volume down by 4" onClick={(): void => setValue(clamp(value - 4))}>
<IconMinus />
</button>
<button type="button" className={styles.btn} aria-label="Volume up by 4" onClick={(): void => setValue(clamp(value + 4))}>
<IconPlus />
</button>
</div>
</div>
</div>
);
}03 · 돌려서 조절하는 다이얼 — 측정 누적면적 3.827% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
각도는 다이얼 중심과 손가락 좌표 사이의 Math.atan2로 구하고, 시작 각도 −135도를 기준으로 270도 안에 가둡니다.
function fromPointer(e) {
var r = dial.getBoundingClientRect();
var dx = e.clientX - (r.left + r.width / 2);
var dy = e.clientY - (r.top + r.height / 2);
var deg = Math.atan2(dy, dx) * 180 / Math.PI;
var rel = deg - BASE;
while (rel > 180) rel -= 360;
while (rel < -180) rel += 360;
if (rel < -SPAN / 2) rel = -SPAN / 2;
if (rel > SPAN / 2) rel = SPAN / 2;
setValue((rel + SPAN / 2) / SPAN * 100);
}
04스와이프 탭 전환
무엇이 보이나
패널이 손가락을 실시간으로 따라오다가, 손을 떼는 순간 남은 거리와 속도를 같이 보고 마저 넘어갈지 제자리로 돌아올지 정합니다.

<main class="stage">
<div class="wt is-demo" id="wt-root">
<div class="wt__tabs" role="tablist" aria-label="통계 기간">
<span class="wt__pill" id="wt-pill" aria-hidden="true"></span>
<button class="wt__tab is-on" type="button" role="tab" id="wt-t0" aria-selected="true" aria-controls="wt-p0">주간</button>
<button class="wt__tab" type="button" role="tab" id="wt-t1" aria-selected="false" aria-controls="wt-p1" tabindex="-1">월간</button>
<button class="wt__tab" type="button" role="tab" id="wt-t2" aria-selected="false" aria-controls="wt-p2" tabindex="-1">연간</button>
</div>
<div class="wt__view" id="wt-view">
<div class="wt__track" id="wt-track">
<section class="wt__panel" role="tabpanel" id="wt-p0" aria-labelledby="wt-t0">
<strong class="wt__num">3.2만</strong>
<div class="wt__bars"><i style="height:34%"></i><i style="height:64%"></i><i style="height:48%"></i><i style="height:82%"></i><i style="height:58%"></i></div>
<span class="wt__cap">이번 주 방문</span>
</section>
<section class="wt__panel" role="tabpanel" id="wt-p1" aria-labelledby="wt-t1">
<strong class="wt__num">12.8만</strong>
<div class="wt__bars"><i style="height:52%"></i><i style="height:78%"></i><i style="height:66%"></i></div>
<span class="wt__cap">이번 달 방문</span>
</section>
<section class="wt__panel" role="tabpanel" id="wt-p2" aria-labelledby="wt-t2">
<strong class="wt__num">148만</strong>
<div class="wt__bars"><i style="height:40%"></i><i style="height:88%"></i><i style="height:60%"></i><i style="height:74%"></i></div>
<span class="wt__cap">올해 방문</span>
</section>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.wt { width: min(252px, 86vw); }
.wt__tabs {
position: relative;
display: flex;
gap: $sp-1;
padding: $sp-1;
background: rgba(255, 255, 255, .07);
border-radius: $r-pill;
}
.wt__pill {
position: absolute;
top: $sp-1;
left: $sp-1;
width: 68px;
height: 28px;
background: $color;
border-radius: $r-pill;
transform: translateX(0);
transition: transform $duration $easing;
}
.wt__tab {
position: relative;
z-index: 1;
flex: 0 0 auto;
box-sizing: border-box;
width: 68px;
height: 28px;
padding: 0;
border: 0;
background: none;
border-radius: $r-pill;
font-family: inherit;
font-size: 11px; line-height: 28px; font-weight: 700;
color: rgba(255, 255, 255, .55);
cursor: pointer;
transition: color $dur-quick steps(1, end);
}
.wt__tab.is-on { color: #fff; }
.wt__tab:focus-visible { outline: 2px solid $subject-cream; outline-offset: -3px; }
.wt__view {
margin-top: $sp-3;
overflow: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .05);
touch-action: pan-y;
}
.wt__track {
display: flex;
width: 100%;
transition: transform $duration $easing;
}
// 패널마다 바탕을 달리한다 — 같은 색이면 미는 중에 두 패널이 한 장처럼 뒤섞여 보인다
.wt__panel:nth-child(1) { background: rgba(47, 109, 246, .16); }
.wt__panel:nth-child(2) { background: rgba(255, 158, 187, .14); }
.wt__panel:nth-child(3) { background: rgba(76, 212, 166, .14); }
.wt__panel {
box-sizing: border-box;
flex: 0 0 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
padding: $sp-3;
}
.wt__num {
font-size: 24px; line-height: 26px; font-weight: 800;
color: $subject-cream;
font-variant-numeric: tabular-nums;
}
.wt__bars {
display: flex;
align-items: flex-end;
gap: $sp-2;
width: 148px;
height: 44px;
}
.wt__bars i {
display: block;
flex: 1;
background: $color;
border-radius: $r-xs $r-xs 0 0;
}
.wt__bars i:nth-child(2n) { background: $subject-pink; }
.wt__cap {
font-size: 11px; line-height: 14px; font-weight: 600;
color: rgba(255, 255, 255, .5);
}
// ── 자동 루프: 주간 → 월간 → 연간 → 되돌아오기. 패널·알약·탭색이 같은 박자 ──
.wt.is-demo .wt__track { animation: wt-slide $dur-loop $easing infinite; }
@keyframes wt-slide {
0%, 14% { transform: translateX(0); }
24%, 40% { transform: translateX(-100%); }
50%, 66% { transform: translateX(-200%); }
90%, 100% { transform: translateX(0); }
}
.wt.is-demo .wt__pill { animation: wt-pill $dur-loop $easing infinite; }
@keyframes wt-pill {
0%, 14% { transform: translateX(0); }
24%, 40% { transform: translateX(72px); }
50%, 66% { transform: translateX(144px); }
90%, 100% { transform: translateX(0); }
}
.wt.is-demo .wt__tab { transition: none; }
.wt.is-demo .wt__tab:nth-child(2) { animation: wt-t0 $dur-loop steps(1, end) infinite; }
.wt.is-demo .wt__tab:nth-child(3) { animation: wt-t1 $dur-loop steps(1, end) infinite; }
.wt.is-demo .wt__tab:nth-child(4) { animation: wt-t2 $dur-loop steps(1, end) infinite; }
@keyframes wt-t0 { 0%, 23% { color: #fff; } 24%, 89% { color: rgba(255, 255, 255, .55); } 90%, 100% { color: #fff; } }
@keyframes wt-t1 { 0%, 23% { color: rgba(255, 255, 255, .55); } 24%, 49% { color: #fff; } 50%, 100% { color: rgba(255, 255, 255, .55); } }
@media (prefers-reduced-motion: reduce) {
.wt.is-demo .wt__track,
.wt.is-demo .wt__pill,
.wt.is-demo .wt__tab { animation: none !important; }
.wt__track, .wt__pill { transition: none; }
}
@keyframes wt-t2 { 0%, 49% { color: rgba(255, 255, 255, .55); } 50%, 89% { color: #fff; } 90%, 100% { color: rgba(255, 255, 255, .55); } }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);
}
.wt {
width: min(252px, 86vw);
}
.wt__tabs {
position: relative;
display: flex;
gap: 4px;
padding: 4px;
background: rgba(255, 255, 255, 0.07);
border-radius: 999px;
}
.wt__pill {
position: absolute;
top: 4px;
left: 4px;
width: 68px;
height: 28px;
background: #2f6df6;
border-radius: 999px;
transform: translateX(0);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.wt__tab {
position: relative;
z-index: 1;
flex: 0 0 auto;
box-sizing: border-box;
width: 68px;
height: 28px;
padding: 0;
border: 0;
background: none;
border-radius: 999px;
font-family: inherit;
font-size: 11px;
line-height: 28px;
font-weight: 700;
color: rgba(255, 255, 255, 0.55);
cursor: pointer;
transition: color 220ms steps(1, end);
}
.wt__tab.is-on {
color: #fff;
}
.wt__tab:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: -3px;
}
.wt__view {
margin-top: 12px;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
touch-action: pan-y;
}
.wt__track {
display: flex;
width: 100%;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.wt__panel:nth-child(1) {
background: rgba(47, 109, 246, 0.16);
}
.wt__panel:nth-child(2) {
background: rgba(255, 158, 187, 0.14);
}
.wt__panel:nth-child(3) {
background: rgba(76, 212, 166, 0.14);
}
.wt__panel {
box-sizing: border-box;
flex: 0 0 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px;
}
.wt__num {
font-size: 24px;
line-height: 26px;
font-weight: 800;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.wt__bars {
display: flex;
align-items: flex-end;
gap: 8px;
width: 148px;
height: 44px;
}
.wt__bars i {
display: block;
flex: 1;
background: #2f6df6;
border-radius: 4px 4px 0 0;
}
.wt__bars i:nth-child(2n) {
background: #ff9ebb;
}
.wt__cap {
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
}
.wt.is-demo .wt__track {
animation: wt-slide 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes wt-slide {
0%, 14% {
transform: translateX(0);
}
24%, 40% {
transform: translateX(-100%);
}
50%, 66% {
transform: translateX(-200%);
}
90%, 100% {
transform: translateX(0);
}
}
.wt.is-demo .wt__pill {
animation: wt-pill 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes wt-pill {
0%, 14% {
transform: translateX(0);
}
24%, 40% {
transform: translateX(72px);
}
50%, 66% {
transform: translateX(144px);
}
90%, 100% {
transform: translateX(0);
}
}
.wt.is-demo .wt__tab {
transition: none;
}
.wt.is-demo .wt__tab:nth-child(2) {
animation: wt-t0 2s steps(1, end) infinite;
}
.wt.is-demo .wt__tab:nth-child(3) {
animation: wt-t1 2s steps(1, end) infinite;
}
.wt.is-demo .wt__tab:nth-child(4) {
animation: wt-t2 2s steps(1, end) infinite;
}
@keyframes wt-t0 {
0%, 23% {
color: #fff;
}
24%, 89% {
color: rgba(255, 255, 255, 0.55);
}
90%, 100% {
color: #fff;
}
}
@keyframes wt-t1 {
0%, 23% {
color: rgba(255, 255, 255, 0.55);
}
24%, 49% {
color: #fff;
}
50%, 100% {
color: rgba(255, 255, 255, 0.55);
}
}
@media (prefers-reduced-motion: reduce) {
.wt.is-demo .wt__track,
.wt.is-demo .wt__pill,
.wt.is-demo .wt__tab {
animation: none !important;
}
.wt__track, .wt__pill {
transition: none;
}
}
@keyframes wt-t2 {
0%, 49% {
color: rgba(255, 255, 255, 0.55);
}
50%, 89% {
color: #fff;
}
90%, 100% {
color: rgba(255, 255, 255, 0.55);
}
}// 실제 동작: 패널이 손가락을 따라오다가 놓으면 거리·속도로 확정. 탭·화살표 키도 같은 인덱스를 고른다.
var root = document.getElementById('wt-root');
var view = document.getElementById('wt-view');
var track = document.getElementById('wt-track');
var pill = document.getElementById('wt-pill');
var tabs = [].slice.call(document.querySelectorAll('.wt__tab'));
var W, at = 0;
function end() { W = view.clientWidth; }
end();
window.addEventListener('resize', end);
function paint(i, dx) {
track.style.transform = 'translateX(' + (-i * W + (dx || 0)) + 'px)';
pill.style.transform = 'translateX(' + (i * 72) + 'px)';
tabs.forEach(function (t, k) {
var on = k === i;
t.classList.toggle('is-on', on);
t.setAttribute('aria-selected', String(on));
t.tabIndex = on ? 0 : -1;
});
}
function go(i) { at = Math.max(0, Math.min(i, tabs.length - 1)); paint(at, 0); }
tabs.forEach(function (t, i) {
t.addEventListener('click', function () { root.classList.remove('is-demo'); go(i); });
});
document.querySelector('.wt__tabs').addEventListener('keydown', function (e) {
var k = e.key === 'ArrowRight' ? at + 1 : e.key === 'ArrowLeft' ? at - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : at;
if (k !== at) {
root.classList.remove('is-demo');
go(k);
tabs[k].focus({ preventScroll: true });
e.preventDefault();
}
});
var x0 = 0, v = 0, lastX = 0, lastT = 0, dragging = false;
view.addEventListener('pointerdown', function (e) {
if (e.target.closest('.wt__tabs')) return;
root.classList.remove('is-demo');
dragging = true; x0 = e.clientX; lastX = e.clientX; lastT = Date.now(); v = 0;
view.setPointerCapture(e.pointerId);
track.style.transition = 'none';
});
view.addEventListener('pointermove', function (e) {
if (!dragging) return;
var dx = e.clientX - x0;
var now = Date.now();
if (now - lastT > 0) { v = (e.clientX - lastX) / Math.max(1, now - lastT); lastX = e.clientX; lastT = now; }
if ((at === 0 && dx > 0) || (at === tabs.length - 1 && dx < 0)) dx *= .3; // 끝에서는 저항
paint(at, dx);
});
function drop() {
if (!dragging) return;
dragging = false;
track.style.transition = '';
var dx = lastX - x0;
if (dx < -44 || v < -.45) go(at + 1);
else if (dx > 44 || v > .45) go(at - 1);
else go(at);
}
view.addEventListener('pointerup', drop);
view.addEventListener('pointercancel', drop);
paint(0, 0);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SwipeTabs.module.scss";
export type SwipeTabsProps = {
tabs?: string[];
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; clientY: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SwipeTabs({
tabs = ["Weekly", "Monthly", "Yearly"],
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SwipeTabsProps) {
const [at, setAt] = useState(0);
const [dx, setDx] = useState(0);
const viewRef = useRef<HTMLDivElement>(null);
const drag = useRef({ on: false, x0: 0, lastX: 0, lastT: 0, v: 0 }) as Box<{ on: boolean; x0: number; lastX: number; lastT: number; v: number }>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function go(i: number) {
setAt(Math.max(0, Math.min(i, tabs.length - 1)));
setDx(0);
}
function onDown(e: PEvt) {
drag.current = { on: true, x0: e.clientX, lastX: e.clientX, lastT: Date.now(), v: 0 };
e.currentTarget.setPointerCapture(e.pointerId);
}
function onMove(e: PEvt) {
if (!drag.current.on) return;
const now = Date.now();
if (now - drag.current.lastT > 0) {
drag.current.v = (e.clientX - drag.current.lastX) / Math.max(1, now - drag.current.lastT);
drag.current.lastX = e.clientX;
drag.current.lastT = now;
}
let d = e.clientX - drag.current.x0;
if ((at === 0 && d > 0) || (at === tabs.length - 1 && d < 0)) d *= 0.3;
setDx(d);
}
function onUp() {
if (!drag.current.on) return;
drag.current.on = false;
const d = drag.current.lastX - drag.current.x0;
if (d < -44 || drag.current.v < -0.45) go(at + 1);
else if (d > 44 || drag.current.v > 0.45) go(at - 1);
else setDx(0);
}
function onKey(e: KeyEvt) {
const k = e.key === "ArrowRight" ? at + 1
: e.key === "ArrowLeft" ? at - 1
: e.key === "Home" ? 0
: e.key === "End" ? tabs.length - 1
: at;
if (k !== at) { go(k); e.preventDefault(); }
}
useEffect(() => {
const el = viewRef.current;
if (!el) return;
function wheel(e: WheelEvent) { e.preventDefault(); }
el.addEventListener("wheel", wheel, { passive: false });
return () => el.removeEventListener("wheel", wheel);
}, []);
return (
<div className={styles.wt} style={vars}>
<div className={styles.tabs} role="tablist" aria-label="Stats period" onKeyDown={onKey}>
<span className={styles.pill} style={{ transform: `translateX(${at * 72}px)` }} aria-hidden="true" />
{tabs.map((t, i) => (
<button
key={t}
type="button"
role="tab"
className={i === at ? styles.tabOn : styles.tab}
aria-selected={i === at}
aria-controls={`wt-p${i}`}
tabIndex={i === at ? 0 : -1}
onClick={(): void => go(i)}
>
{t}
</button>
))}
</div>
<div
ref={viewRef}
className={styles.view}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
>
<div
className={styles.track}
style={{ transform: `translateX(calc(${-at * 100}% + ${dx}px))` }}
>
{tabs.map((t, i) => (
<section key={t} role="tabpanel" id={`wt-p${i}`} className={styles.panel}>
<strong className={styles.num}>{[32000, 128000, 1480000][i].toLocaleString("en-US")}</strong>
<span className={styles.cap}>{`Visits ${t.toLowerCase()}`}</span>
</section>
))}
</div>
</div>
</div>
);
}04 · 스와이프 탭 전환 — 측정 누적면적 25.581% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
거리는 44px, 속도는 0.45px/ms가 기준선이고 둘 중 하나만 넘겨도 넘어갑니다.
function drop() {
if (!dragging) return;
dragging = false;
track.style.transition = '';
var dx = lastX - x0;
if (dx < -44 || v < -.45) go(at + 1);
else if (dx > 44 || v > .45) go(at - 1);
else go(at);
}
05스와이프 카드 덱
화면에서 일어나는 일
카드를 좌우로 끌면 기울기와 함께 통과·거절 스탬프가 진해지고, 64px을 넘기면 판정이 확정되며 카드가 화면 밖으로 날아갑니다.

<main class="stage">
<div class="cd is-demo" id="cd-root">
<div class="cd__deck" id="cd-deck">
<article class="cd__card is-back2" data-name="지원자 3" aria-hidden="true">
<span class="cd__avatar" style="background:#b9a0ff"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg></span>
<div class="cd__who"><strong>지원자 3</strong><span>데이터 분석</span></div>
</article>
<article class="cd__card is-back1" data-name="지원자 2" aria-hidden="true">
<span class="cd__avatar" style="background:#4cd4a6"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg></span>
<div class="cd__who"><strong>지원자 2</strong><span>프론트엔드</span></div>
</article>
<article class="cd__card is-top" id="cd-top" data-name="지원자 1">
<span class="cd__stamp cd__stamp--pass">통과</span>
<span class="cd__stamp cd__stamp--skip">거절</span>
<span class="cd__avatar" style="background:#2f6df6"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg></span>
<div class="cd__who"><strong>지원자 1</strong><span>프론트엔드 · 3년</span></div>
</article>
</div>
<div class="cd__acts">
<button class="cd__btn cd__btn--skip" type="button" id="cd-skip" aria-label="거절">
<svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg>
</button>
<span class="cd__count" id="cd-count" aria-live="polite">3장 남음</span>
<button class="cd__btn cd__btn--pass" type="button" id="cd-pass" aria-label="통과">
<svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg>
</button>
</div>
</div>
</main>
<template id="cd-src">
<article class="cd__card is-back2" data-name="지원자 3" aria-hidden="true">
<span class="cd__avatar" style="background:#b9a0ff"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg></span>
<div class="cd__who"><strong>지원자 3</strong><span>데이터 분석</span></div>
</article>
<article class="cd__card is-back1" data-name="지원자 2" aria-hidden="true">
<span class="cd__avatar" style="background:#4cd4a6"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg></span>
<div class="cd__who"><strong>지원자 2</strong><span>프론트엔드</span></div>
</article>
<article class="cd__card is-top">
<span class="cd__stamp cd__stamp--pass">통과</span>
<span class="cd__stamp cd__stamp--skip">거절</span>
<span class="cd__avatar" style="background:#2f6df6"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg></span>
<div class="cd__who"><strong>지원자 1</strong><span>프론트엔드 · 3년</span></div>
</article>
</template>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.cd {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
user-select: none;
-webkit-user-select: none;
}
// 카드가 화면 밖으로 날아가는 애니메이션이라 덱을 클립 상자로 감싼다.
// overflow:hidden 이어도 부모의 scrollWidth 는 날아간 카드까지 세므로(BD9) 클립은 덱 자신이 한다
.cd__deck {
position: relative;
width: 188px;
height: 100px;
overflow: hidden;
border-radius: $r-card;
}
.cd__card {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-3;
background: $subject-cream;
border: 1px solid rgba(23, 20, 26, .08);
border-radius: $r-card;
box-shadow: $shadow-raised;
transition: transform $duration $easing, opacity $dur-quick $easing;
}
// 뒷카드는 opacity 를 쓰지 않는다 — 반투명이면 앞 카드 글자가 비쳐 보인다(고스팅)
.cd__card.is-back1 { transform: translateY(-6px) scale(.94); }
.cd__card.is-back2 { transform: translateY(-12px) scale(.88); }
.cd__card.is-top { cursor: grab; touch-action: none; z-index: 2; }
.cd__card.is-top:active { cursor: grabbing; }
.cd__stamp {
position: absolute;
top: $sp-2;
padding: 0 $sp-2;
border-radius: $r-xs;
font-size: 12px; line-height: 16px; font-weight: 800;
letter-spacing: .06em;
opacity: 0;
border: 2px solid currentColor;
}
.cd__stamp--pass { left: $sp-2; color: $subject-mint; transform: rotate(-9deg); }
.cd__stamp--skip { right: $sp-2; color: $error; transform: rotate(9deg); }
.cd__avatar {
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 36px;
border-radius: 50%;
color: #fff;
font-size: 16px; line-height: 40px; font-weight: 800;
}
.cd__who { display: flex; flex-direction: column; gap: 0; }
.cd__who strong { font-size: 14px; line-height: 18px; font-weight: 800; color: $subject-ink; }
.cd__who span { font-size: 11px; line-height: 14px; font-weight: 600; color: rgba(23, 20, 26, .72); }
.cd__acts { display: flex; align-items: center; gap: $sp-4; }
.cd__btn {
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 36px;
border: 1px solid rgba(23, 20, 26, .14);
background: #fff;
border-radius: 50%;
cursor: pointer;
transition: transform $dur-quick $easing, background $dur-quick $easing, color $dur-quick $easing;
}
.cd__btn:hover { transform: scale(1.08); }
.cd__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.cd__btn--pass { color: $subject-mint; }
.cd__btn--skip { color: $error; }
.cd__count {
font-size: 12px; line-height: 14px; font-weight: 700;
color: rgba(23, 20, 26, .72);
font-variant-numeric: tabular-nums;
}
// ── 자동 루프: 오른쪽으로 끌기(통과 스탬프) → 비행 → 다음 카드 상승 → 페이드 리셋 ──
.cd.is-demo .cd__card.is-top { animation: cd-drag $dur-loop $easing infinite; }
@keyframes cd-drag {
0% { transform: translateX(0) rotate(0); }
26% { transform: translateX(88px) rotate(9deg); }
38% { transform: translateX(190px) rotate(20deg); opacity: 1; }
44% { transform: translateX(230px) rotate(24deg); opacity: 0; }
45%, 70% { transform: translateX(230px) rotate(24deg); opacity: 0; }
71% { transform: translateX(0) rotate(0); opacity: 0; }
86%, 100% { transform: translateX(0) rotate(0); opacity: 1; }
}
.cd.is-demo .cd__stamp--pass { animation: cd-stamp $dur-loop steps(1, end) infinite; }
@keyframes cd-stamp {
0%, 9% { opacity: 0; }
10%, 43% { opacity: 1; }
44%, 100% { opacity: 0; }
}
.cd.is-demo .cd__card.is-back1 { animation: cd-rise $dur-loop $easing infinite; }
@keyframes cd-rise {
0%, 43% { transform: translateY(-6px) scale(.94); }
54%, 70% { transform: translateY(0) scale(1); }
71% { transform: translateY(-6px) scale(.94); }
86%, 100% { transform: translateY(-6px) scale(.94); }
}
@media (prefers-reduced-motion: reduce) {
.cd.is-demo .cd__card.is-top,
.cd.is-demo .cd__stamp--pass,
.cd.is-demo .cd__card.is-back1 { animation: none !important; }
.cd__card { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
user-select: none;
-webkit-user-select: none;
}
.cd__deck {
position: relative;
width: 188px;
height: 100px;
overflow: hidden;
border-radius: 16px;
}
.cd__card {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #fff7e6;
border: 1px solid rgba(23, 20, 26, 0.08);
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cd__card.is-back1 {
transform: translateY(-6px) scale(0.94);
}
.cd__card.is-back2 {
transform: translateY(-12px) scale(0.88);
}
.cd__card.is-top {
cursor: grab;
touch-action: none;
z-index: 2;
}
.cd__card.is-top:active {
cursor: grabbing;
}
.cd__stamp {
position: absolute;
top: 8px;
padding: 0 8px;
border-radius: 4px;
font-size: 12px;
line-height: 16px;
font-weight: 800;
letter-spacing: 0.06em;
opacity: 0;
border: 2px solid currentColor;
}
.cd__stamp--pass {
left: 8px;
color: #4cd4a6;
transform: rotate(-9deg);
}
.cd__stamp--skip {
right: 8px;
color: #c2361a;
transform: rotate(9deg);
}
.cd__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
color: #fff;
font-size: 16px;
line-height: 40px;
font-weight: 800;
}
.cd__who {
display: flex;
flex-direction: column;
gap: 0;
}
.cd__who strong {
font-size: 14px;
line-height: 18px;
font-weight: 800;
color: #17141a;
}
.cd__who span {
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
}
.cd__acts {
display: flex;
align-items: center;
gap: 16px;
}
.cd__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid rgba(23, 20, 26, 0.14);
background: #fff;
border-radius: 50%;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cd__btn:hover {
transform: scale(1.08);
}
.cd__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.cd__btn--pass {
color: #4cd4a6;
}
.cd__btn--skip {
color: #c2361a;
}
.cd__count {
font-size: 12px;
line-height: 14px;
font-weight: 700;
color: rgba(23, 20, 26, 0.72);
font-variant-numeric: tabular-nums;
}
.cd.is-demo .cd__card.is-top {
animation: cd-drag 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes cd-drag {
0% {
transform: translateX(0) rotate(0);
}
26% {
transform: translateX(88px) rotate(9deg);
}
38% {
transform: translateX(190px) rotate(20deg);
opacity: 1;
}
44% {
transform: translateX(230px) rotate(24deg);
opacity: 0;
}
45%, 70% {
transform: translateX(230px) rotate(24deg);
opacity: 0;
}
71% {
transform: translateX(0) rotate(0);
opacity: 0;
}
86%, 100% {
transform: translateX(0) rotate(0);
opacity: 1;
}
}
.cd.is-demo .cd__stamp--pass {
animation: cd-stamp 2s steps(1, end) infinite;
}
@keyframes cd-stamp {
0%, 9% {
opacity: 0;
}
10%, 43% {
opacity: 1;
}
44%, 100% {
opacity: 0;
}
}
.cd.is-demo .cd__card.is-back1 {
animation: cd-rise 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes cd-rise {
0%, 43% {
transform: translateY(-6px) scale(0.94);
}
54%, 70% {
transform: translateY(0) scale(1);
}
71% {
transform: translateY(-6px) scale(0.94);
}
86%, 100% {
transform: translateY(-6px) scale(0.94);
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-demo .cd__card.is-top,
.cd.is-demo .cd__stamp--pass,
.cd.is-demo .cd__card.is-back1 {
animation: none !important;
}
.cd__card {
transition: none;
}
}// 실제 동작: 좌우 드래그 → 기울기·스탬프 → 임계값 넘으면 판정 확정·비행. 버튼·화살표 키도 같은 판정.
var root = document.getElementById('cd-root');
var deck = document.getElementById('cd-deck');
var count = document.getElementById('cd-count');
var left = 3, judging = false;
var top = null, x0 = 0, dragging = false;
function cards() { return [].slice.call(deck.querySelectorAll('.cd__card')); }
function restack() {
var cs = cards();
top = cs[cs.length - 1];
cs.forEach(function (c, i) {
var depth = cs.length - 1 - i;
c.classList.toggle('is-top', depth === 0);
c.classList.toggle('is-back1', depth === 1);
c.classList.toggle('is-back2', depth === 2);
c.setAttribute('aria-hidden', String(depth > 0));
});
count.textContent = count.textContent.replace(/\d+/, cs.length);
}
function verdict(pass) {
if (judging || !top) return;
judging = true;
top.style.transition = 'transform 420ms cubic-bezier(.2,.8,.2,1), opacity 420ms ease-out';
top.style.transform = 'translateX(' + (pass ? 150 : -150) + 'px) rotate(' + (pass ? 18 : -18) + 'deg)';
top.style.opacity = '0';
window.setTimeout(function () {
deck.removeChild(top);
left--;
if (!cards().length) {
reset();
} else { restack(); }
judging = false;
}, 380);
}
function reset() {
deck.innerHTML = document.getElementById('cd-src').innerHTML;
left = 3;
restack();
bind();
}
function bind() {
restack();
top.addEventListener('pointerdown', function (e) {
if (judging) return;
root.classList.remove('is-demo');
dragging = true; x0 = e.clientX;
top.setPointerCapture(e.pointerId);
top.style.transition = 'none';
e.preventDefault();
});
top.addEventListener('pointermove', function (e) {
if (!dragging) return;
var dx = e.clientX - x0;
var r = Math.max(-12, Math.min(12, dx / 9));
top.style.transform = 'translateX(' + dx + 'px) rotate(' + r + 'deg)';
top.querySelector('.cd__stamp--pass').style.opacity = Math.max(0, Math.min(1, dx / 64));
top.querySelector('.cd__stamp--skip').style.opacity = Math.max(0, Math.min(1, -dx / 64));
});
function drop() {
if (!dragging) return;
dragging = false;
var dx = parseFloat((top.style.transform.match(/translateX\((-?[\d.]+)px\)/) || [0, 0])[1]);
if (dx > 64) verdict(true);
else if (dx < -64) verdict(false);
else {
top.style.transition = '';
top.style.transform = '';
top.querySelector('.cd__stamp--pass').style.opacity = 0;
top.querySelector('.cd__stamp--skip').style.opacity = 0;
}
}
top.addEventListener('pointerup', drop);
top.addEventListener('pointercancel', drop);
}
document.getElementById('cd-pass').addEventListener('click', function () { root.classList.remove('is-demo'); verdict(true); });
document.getElementById('cd-skip').addEventListener('click', function () { root.classList.remove('is-demo'); verdict(false); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { root.classList.remove('is-demo'); verdict(true); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { root.classList.remove('is-demo'); verdict(false); e.preventDefault(); }
});
bind();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SwipeDeck.module.scss";
export type SwipeDeckProps = {
cards?: { name: string; role: string; hue: string }[];
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; clientY: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconHeart = () => (
<svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg>
);
const IconX = () => (
<svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg>
);
const IconUser = () => (
<svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
);
export default function SwipeDeck({
cards = [
{ name: "Applicant 1", role: "Frontend · 3 yrs", hue: "#2f6df6" },
{ name: "Applicant 2", role: "Frontend", hue: "#4cd4a6" },
{ name: "Applicant 3", role: "Data analysis", hue: "#b9a0ff" },
],
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SwipeDeckProps) {
const [rest, setRest] = useState(cards);
const [fly, setFly] = useState<null | { pass: boolean }>(null);
const [dx, setDx] = useState(0);
const drag = useRef({ on: false, x0: 0 }) as Box<{ on: boolean; x0: number }>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function next(pass: boolean) {
if (fly) return;
setFly({ pass });
window.setTimeout(() => {
setFly(null);
setDx(0);
setRest((r) => (r.length > 1 ? r.slice(1) : cards));
}, 380);
}
function onDown(e: PEvt) {
if (fly) return;
drag.current = { on: true, x0: e.clientX };
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onMove(e: PEvt) {
if (!drag.current.on) return;
setDx(e.clientX - drag.current.x0);
}
function onUp() {
if (!drag.current.on) return;
drag.current.on = false;
if (dx > 64) next(true);
else if (dx < -64) next(false);
else setDx(0);
}
const topDx = fly ? (fly.pass ? 190 : -190) : dx;
const topRot = fly ? (fly.pass ? 18 : -18) : Math.max(-12, Math.min(12, dx / 9));
const topOp = fly ? 0 : 1;
const passO = Math.max(0, Math.min(1, topDx / 64));
const skipO = Math.max(0, Math.min(1, -topDx / 64));
return (
<div className={styles.cd} style={vars}>
<div className={styles.deck}>
{rest.slice(0, 3).map((c, i) => {
const depth = i;
const isTop = depth === 0;
const cls = isTop ? styles.cardTop : depth === 1 ? styles.cardBack1 : styles.cardBack2;
if (isTop) {
return (
<article
key={c.name}
className={cls}
style={{ transform: `translateX(${topDx}px) rotate(${topRot}deg)`, opacity: topOp }}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onKeyDown={(e: KeyEvt): void => {
if (e.key === "ArrowRight") { next(true); e.preventDefault(); }
else if (e.key === "ArrowLeft") { next(false); e.preventDefault(); }
}}
tabIndex={0}
aria-label={`${c.name}, ${c.role}. Arrow keys judge.`}
>
<span className={styles.stampPass} style={{ opacity: passO }}>PASS</span>
<span className={styles.stampSkip} style={{ opacity: skipO }}>SKIP</span>
<span className={styles.avatar} style={{ background: c.hue }} aria-hidden="true"><IconUser /></span>
<div className={styles.who}><strong>{c.name}</strong><span>{c.role}</span></div>
</article>
);
}
return (
<article key={c.name} className={cls} aria-hidden="true">
<span className={styles.avatar} style={{ background: c.hue }}><IconUser /></span>
<div className={styles.who}><strong>{c.name}</strong><span>{c.role}</span></div>
</article>
);
})}
</div>
<div className={styles.acts}>
<button type="button" className={`${styles.btn} ${styles.skip}`} aria-label="Skip" onClick={(): void => next(false)}>
<IconX />
</button>
<span className={styles.count} aria-live="polite">{`${rest.length} cards left`}</span>
<button type="button" className={`${styles.btn} ${styles.pass}`} aria-label="Pass" onClick={(): void => next(true)}>
<IconHeart />
</button>
</div>
</div>
);
}05 · 스와이프 카드 덱 — 측정 누적면적 8.776% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
기울기는 끈 거리를 9로 나눈 값이고 최대 12도에서 멈춥니다.
top.addEventListener('pointermove', function (e) {
if (!dragging) return;
var dx = e.clientX - x0;
var r = Math.max(-12, Math.min(12, dx / 9));
top.style.transform = 'translateX(' + dx + 'px) rotate(' + r + 'deg)';
top.querySelector('.cd__stamp--pass').style.opacity = Math.max(0, Math.min(1, dx / 64));
top.querySelector('.cd__stamp--skip').style.opacity = Math.max(0, Math.min(1, -dx / 64));
});
06가장자리 스와이프 백
어떤 움직임인가
화면 왼쪽 가장자리 28px 안에서 시작한 드래그만 뒤로 가기로 인식하고, 그보다 안쪽에서 시작한 드래그는 그냥 무시합니다.

<main class="stage">
<div class="eb is-demo" id="eb-root">
<div class="eb__frame" id="eb-frame">
<div class="eb__prev" id="eb-prev" aria-hidden="true">
<span class="eb__ptitle">목록</span>
<i class="eb__ln"></i><i class="eb__ln"></i><i class="eb__ln"></i><i class="eb__ln"></i>
</div>
<div class="eb__cur" id="eb-cur">
<div class="eb__bar">
<button class="eb__back" type="button" id="eb-back" aria-label="뒤로 가기">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M112 56v144l-72-72Z" opacity=".2"/><path d="M216 120h-96V56a8 8 0 0 0-13.66-5.66l-72 72a8 8 0 0 0 0 11.32l72 72A8 8 0 0 0 120 200v-64h96a8 8 0 0 0 0-16m-112 60.69L51.31 128L104 75.31Z"/></g></svg>
</button>
<span class="eb__ctitle eb__ctitle--a">상세 페이지</span>
<span class="eb__ctitle eb__ctitle--b">목록</span>
</div>
<div class="eb__body">
<span class="eb__hero"></span>
<i class="eb__ln"></i><i class="eb__ln"></i><i class="eb__ln"></i>
</div>
</div>
<span class="eb__edge" aria-hidden="true"></span>
</div>
<p class="eb__hint">왼쪽 가장자리에서 오른쪽으로 당겨 보세요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.eb { display: flex; flex-direction: column; align-items: center; gap: $sp-3; user-select: none; -webkit-user-select: none; }
.eb__frame {
position: relative;
width: 236px;
height: 148px;
border-radius: $r-card;
overflow: hidden;
background: #101014;
touch-action: pan-y;
}
.eb__prev {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-3;
background: $subject-cream;
transform: translateX(-46%);
opacity: .45;
transition: transform $duration $easing, opacity $duration $easing;
}
.eb__ptitle { font-size: 13px; line-height: 16px; font-weight: 800; color: $subject-ink; }
.eb__ln { display: block; height: 8px; border-radius: $r-xs; background: rgba(23, 20, 26, .14); }
.eb__ln:nth-child(3) { width: 86%; }
.eb__ln:nth-child(4) { width: 68%; }
.eb__cur {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
background: #1d1a24;
border-radius: $r-card;
box-shadow: $shadow-raised;
transition: transform $duration $easing;
}
.eb__bar {
display: flex;
align-items: center;
gap: $sp-2;
height: 32px;
padding: 0 $sp-2;
border-bottom: 1px solid rgba(255, 255, 255, .08);
}
.eb__back {
display: inline-flex; align-items: center; justify-content: center;
width: 24px; height: 24px;
border: 0;
background: none;
border-radius: 50%;
color: $subject-cream;
cursor: pointer;
transition: background $dur-quick $easing;
}
.eb__back:hover { background: rgba(255, 255, 255, .14); }
.eb__back:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.eb__ctitle {
font-size: 12px; line-height: 14px; font-weight: 700;
color: #fff;
opacity: 0;
transition: opacity $dur-quick steps(1, end);
}
.eb__ctitle.is-on { opacity: 1; }
.eb__body { display: flex; flex-direction: column; gap: $sp-2; padding: $sp-3; }
.eb__hero {
display: block;
height: 44px;
border-radius: $r-control;
background: linear-gradient(120deg, $color, $subject-lilac);
}
.eb__cur .eb__ln { background: rgba(255, 255, 255, .12); }
.eb__cur .eb__ln:nth-child(3) { width: 74%; }
.eb__edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 20px;
background: linear-gradient(90deg, rgba(47, 109, 246, .5), transparent);
pointer-events: none;
}
.eb__hint {
margin: 0;
font-size: 11px; line-height: 14px; font-weight: 600;
color: rgba(255, 255, 255, .45);
}
// ── 자동 루프: 가장자리 당기기 → 확정(제목 교체) → 유지 → 되돌아가기 ──
.eb.is-demo .eb__prev { animation: eb-prev $dur-loop $easing infinite; }
@keyframes eb-prev {
0%, 10% { transform: translateX(-46%); opacity: .45; }
36%, 62% { transform: translateX(0); opacity: 1; }
86%, 100% { transform: translateX(-46%); opacity: .45; }
}
.eb.is-demo .eb__cur { animation: eb-cur $dur-loop $easing infinite; }
@keyframes eb-cur {
0%, 10% { transform: translateX(0); }
36%, 62% { transform: translateX(38%); }
86%, 100% { transform: translateX(0); }
}
.eb.is-demo .eb__ctitle { transition: none; }
.eb.is-demo .eb__ctitle--a { animation: eb-ta $dur-loop steps(1, end) infinite; }
.eb.is-demo .eb__ctitle--b { animation: eb-tb $dur-loop steps(1, end) infinite; }
@keyframes eb-ta { 0%, 35% { opacity: 1; } 36%, 85% { opacity: 0; } 86%, 100% { opacity: 1; } }
@keyframes eb-tb { 0%, 35% { opacity: 0; } 36%, 85% { opacity: 1; } 86%, 100% { opacity: 0; } }
.eb.is-demo .eb__edge { animation: eb-edge $dur-loop ease-in-out infinite; }
@keyframes eb-edge {
0%, 6% { opacity: 1; }
14%, 62% { opacity: 0; }
86%, 100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.eb.is-demo .eb__prev,
.eb.is-demo .eb__cur,
.eb.is-demo .eb__ctitle,
.eb.is-demo .eb__edge { animation: none !important; }
.eb__prev, .eb__cur { transition: none; }
}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);
}
.eb {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
user-select: none;
-webkit-user-select: none;
}
.eb__frame {
position: relative;
width: 236px;
height: 148px;
border-radius: 16px;
overflow: hidden;
background: #101014;
touch-action: pan-y;
}
.eb__prev {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: #fff7e6;
transform: translateX(-46%);
opacity: 0.45;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.eb__ptitle {
font-size: 13px;
line-height: 16px;
font-weight: 800;
color: #17141a;
}
.eb__ln {
display: block;
height: 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.14);
}
.eb__ln:nth-child(3) {
width: 86%;
}
.eb__ln:nth-child(4) {
width: 68%;
}
.eb__cur {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
background: #1d1a24;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.eb__bar {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.eb__back {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 0;
background: none;
border-radius: 50%;
color: #fff7e6;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.eb__back:hover {
background: rgba(255, 255, 255, 0.14);
}
.eb__back:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.eb__ctitle {
font-size: 12px;
line-height: 14px;
font-weight: 700;
color: #fff;
opacity: 0;
transition: opacity 220ms steps(1, end);
}
.eb__ctitle.is-on {
opacity: 1;
}
.eb__body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
}
.eb__hero {
display: block;
height: 44px;
border-radius: 12px;
background: linear-gradient(120deg, #2f6df6, #b9a0ff);
}
.eb__cur .eb__ln {
background: rgba(255, 255, 255, 0.12);
}
.eb__cur .eb__ln:nth-child(3) {
width: 74%;
}
.eb__edge {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 20px;
background: linear-gradient(90deg, rgba(47, 109, 246, 0.5), transparent);
pointer-events: none;
}
.eb__hint {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.45);
}
.eb.is-demo .eb__prev {
animation: eb-prev 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes eb-prev {
0%, 10% {
transform: translateX(-46%);
opacity: 0.45;
}
36%, 62% {
transform: translateX(0);
opacity: 1;
}
86%, 100% {
transform: translateX(-46%);
opacity: 0.45;
}
}
.eb.is-demo .eb__cur {
animation: eb-cur 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes eb-cur {
0%, 10% {
transform: translateX(0);
}
36%, 62% {
transform: translateX(38%);
}
86%, 100% {
transform: translateX(0);
}
}
.eb.is-demo .eb__ctitle {
transition: none;
}
.eb.is-demo .eb__ctitle--a {
animation: eb-ta 2s steps(1, end) infinite;
}
.eb.is-demo .eb__ctitle--b {
animation: eb-tb 2s steps(1, end) infinite;
}
@keyframes eb-ta {
0%, 35% {
opacity: 1;
}
36%, 85% {
opacity: 0;
}
86%, 100% {
opacity: 1;
}
}
@keyframes eb-tb {
0%, 35% {
opacity: 0;
}
36%, 85% {
opacity: 1;
}
86%, 100% {
opacity: 0;
}
}
.eb.is-demo .eb__edge {
animation: eb-edge 2s ease-in-out infinite;
}
@keyframes eb-edge {
0%, 6% {
opacity: 1;
}
14%, 62% {
opacity: 0;
}
86%, 100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.eb.is-demo .eb__prev,
.eb.is-demo .eb__cur,
.eb.is-demo .eb__ctitle,
.eb.is-demo .eb__edge {
animation: none !important;
}
.eb__prev, .eb__cur {
transition: none;
}
}// 실제 동작: 왼쪽 가장자리에서 당기면 이전 페이지가 반투명하게 따라 나오고, 끝까지 당겨야 확정.
var root = document.getElementById('eb-root');
var frame = document.getElementById('eb-frame');
var prev = document.getElementById('eb-prev');
var cur = document.getElementById('eb-cur');
var back = document.getElementById('eb-back');
var TITLES = ['eb__ctitle--a', 'eb__ctitle--b'];
var atDetail = true, x0 = 0, dragging = false;
function paint(p) { // p: 0..1
prev.style.transform = 'translateX(' + (-46 + p * 46) + '%)';
prev.style.opacity = String(.45 + p * .55);
cur.style.transform = 'translateX(' + (p * 38) + '%)';
}
function settle() { paint(atDetail ? 0 : 1); }
frame.addEventListener('pointerdown', function (e) {
var r = frame.getBoundingClientRect();
if (e.clientX - r.left > 28) return; // 가장자리 잡기 영역
root.classList.remove('is-demo');
dragging = true; x0 = e.clientX;
frame.setPointerCapture(e.pointerId);
prev.style.transition = 'none';
cur.style.transition = 'none';
e.preventDefault();
});
frame.addEventListener('pointermove', function (e) {
if (!dragging) return;
var p = Math.max(0, Math.min(1, (e.clientX - x0) / (frame.clientWidth * .55)));
paint(p);
});
function drop(e) {
if (!dragging) return;
dragging = false;
prev.style.transition = '';
cur.style.transition = '';
var p = Math.max(0, Math.min(1, (e.clientX - x0) / (frame.clientWidth * .55)));
if (p > .72) {
atDetail = false;
document.querySelectorAll('.eb__ctitle').forEach(function (t) { t.classList.toggle('is-on', t.classList.contains(TITLES[atDetail ? 0 : 1])); });
settle();
} else { settle(); }
}
frame.addEventListener('pointerup', drop);
frame.addEventListener('pointercancel', drop);
back.addEventListener('click', function () {
root.classList.remove('is-demo');
atDetail = !atDetail;
document.querySelectorAll('.eb__ctitle').forEach(function (t) { t.classList.toggle('is-on', t.classList.contains(TITLES[atDetail ? 0 : 1])); });
prev.style.transition = '';
cur.style.transition = '';
settle();
});
document.querySelector('.eb__ctitle--a').classList.add('is-on');
settle();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./EdgeSwipeBack.module.scss";
export type EdgeSwipeBackProps = {
currentLabel?: string;
backLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; clientY: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type Box<T> = { current: T };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const IconBack = () => (
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M112 56v144l-72-72Z" opacity=".2"/><path d="M216 120h-96V56a8 8 0 0 0-13.66-5.66l-72 72a8 8 0 0 0 0 11.32l72 72A8 8 0 0 0 120 200v-64h96a8 8 0 0 0 0-16m-112 60.69L51.31 128L104 75.31Z"/></g></svg>
);
export default function EdgeSwipeBack({
currentLabel = "Detail page",
backLabel = "List",
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: EdgeSwipeBackProps) {
const [p, setP] = useState(0); // 0..1 진행
const [atDetail, setAtDetail] = useState(true);
const frameRef = useRef<HTMLDivElement>(null);
const drag = useRef({ on: false, x0: 0 }) as Box<{ on: boolean; x0: number }>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const shown = atDetail ? p : 1 - p; // 화면에 보이는 당김 정도
function onDown(e: PEvt) {
const r = (frameRef.current as HTMLDivElement).getBoundingClientRect();
if (e.clientX - r.left > 28) return;
drag.current = { on: true, x0: e.clientX };
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onMove(e: PEvt) {
if (!drag.current.on) return;
const r = (frameRef.current as HTMLDivElement).getBoundingClientRect();
setP(Math.max(0, Math.min(1, (e.clientX - drag.current.x0) / (r.width * 0.55))));
}
function onUp() {
if (!drag.current.on) return;
drag.current.on = false;
if (p > 0.72) setAtDetail(false);
setP(0);
}
function goBack() {
setAtDetail((d) => !d);
setP(0);
}
return (
<div className={styles.eb} style={vars}>
<div
ref={frameRef}
className={styles.frame}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
>
<div
className={styles.prev}
style={{ transform: `translateX(${-46 + shown * 46}%)`, opacity: 0.45 + shown * 0.55 }}
aria-hidden="true"
>
<span className={styles.ptitle}>{backLabel}</span>
<i className={styles.ln} /><i className={`${styles.ln} ${styles.lnS}`} /><i className={`${styles.ln} ${styles.lnM}`} /><i className={styles.ln} />
</div>
<div className={styles.cur} style={{ transform: `translateX(${shown * 38}%)` }}>
<div className={styles.bar}>
<button type="button" className={styles.back} aria-label="Go back" onClick={goBack}>
<IconBack />
</button>
<span className={styles.ctitle}>{atDetail ? currentLabel : backLabel}</span>
</div>
<div className={styles.body}>
<span className={styles.hero} />
<i className={`${styles.ln} ${styles.lnDark}`} /><i className={`${styles.ln} ${styles.lnDark} ${styles.lnS}`} />
</div>
</div>
<span className={styles.edge} aria-hidden="true" style={{ opacity: 1 - shown }} />
</div>
<p className={styles.hint}>Drag in from the left edge</p>
</div>
);
}06 · 가장자리 스와이프 백 — 측정 누적면적 15.798% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
끌린 거리가 프레임 폭의 55%를 기준으로 잰 진행률에서 72%를 넘어야 이동이 확정되고, 못 미치면 미리 나온 이전 화면이 다시 들어갑니다.
frame.addEventListener('pointerdown', function (e) {
var r = frame.getBoundingClientRect();
if (e.clientX - r.left > 28) return;
dragging = true; x0 = e.clientX;
frame.setPointerCapture(e.pointerId);
prev.style.transition = 'none';
cur.style.transition = 'none';
e.preventDefault();
});
07러버밴드 경계 저항
무엇이 보이나
목록 끝을 넘겨 당기면 넘어간 거리의 3분의 1만 따라와 손가락과 화면이 어긋나기 시작하고, 그 어긋남이 곧 "여기가 끝"이라는 신호가 됩니다.

<main class="stage">
<div class="rb is-demo" id="rb-root">
<div class="rb__view" id="rb-view" tabindex="0" role="group" aria-label="공지 목록 — 화살표 키로 끝 확인">
<span class="rb__end rb__end--top" id="rb-top" aria-hidden="true">이전 공지가 없습니다</span>
<div class="rb__inner" id="rb-inner">
<div class="rb__row"><span class="rb__dot"></span><strong>10월 점검 안내</strong><time>10-02</time></div>
<div class="rb__row"><span class="rb__dot rb__dot--pink"></span><strong>이벤트 당첨자 발표</strong><time>09-28</time></div>
<div class="rb__row"><span class="rb__dot rb__dot--blue"></span><strong>새 기능 출시</strong><time>09-21</time></div>
<div class="rb__row"><span class="rb__dot rb__dot--lilac"></span><strong>휴무일 안내</strong><time>09-18</time></div>
<div class="rb__row"><span class="rb__dot"></span><strong>이용약관 개정</strong><time>09-10</time></div>
</div>
<span class="rb__end rb__end--bottom" id="rb-bottom" aria-hidden="true">마지막 공지입니다</span>
</div>
<p class="rb__hint">끝에서 더 당기면 저항을 느끼고 손을 떼면 튕겨 돌아옵니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-pop;
$color: $subject-ink;
@include stage($stage-yellow);
.rb { display: flex; flex-direction: column; align-items: center; gap: $sp-3; user-select: none; -webkit-user-select: none; }
.rb__view {
position: relative;
width: min(228px, 84vw);
height: 132px;
overflow: hidden;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-press;
touch-action: none;
cursor: grab;
}
.rb__view:active { cursor: grabbing; }
.rb__view:focus-visible { outline: 3px solid rgba(23, 20, 26, .5); outline-offset: 2px; }
.rb__inner {
transition: transform $duration $easing;
will-change: transform;
}
.rb__row {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
height: 30px;
padding: 0 $sp-3;
border-bottom: 1px solid rgba(23, 20, 26, .07);
background: #fff;
}
.rb__row strong { flex: 1; font-size: 12px; line-height: 14px; font-weight: 700; color: $subject-ink; }
.rb__row time { font-size: 10px; line-height: 12px; font-weight: 600; color: rgba(23, 20, 26, .72); }
.rb__dot { width: 7px; height: 7px; border-radius: 50%; background: $stage-orange; }
.rb__dot--pink { background: $subject-pink; }
.rb__dot--blue { background: $subject-blue; }
.rb__dot--lilac { background: $subject-lilac; }
.rb__end {
position: absolute;
left: 0; right: 0;
display: flex;
align-items: center;
justify-content: center;
height: 24px;
background: $subject-ink;
color: $subject-cream;
font-size: 10px; line-height: 12px; font-weight: 700;
opacity: 0;
transform: translateY(-100%);
transition: opacity $dur-quick steps(1, end);
pointer-events: none;
}
.rb__end--top { top: 0; }
.rb__end--bottom { bottom: 0; transform: translateY(100%); }
.rb__end.is-on { opacity: 1; }
.rb__hint {
margin: 0;
font-size: 11px; line-height: 14px; font-weight: 600;
color: rgba(23, 20, 26, .72);
}
// ── 자동 루프: 위 끝 당김(저항) → 튕겨 복귀 → 아래 끝 당김 → 복귀. 튕김은 ease-pop ──
.rb.is-demo .rb__inner { animation: rb-band $dur-loop infinite; }
@keyframes rb-band {
0%, 8% { transform: translateY(0); }
22% { transform: translateY(13px); }
30% { transform: translateY(-5px); }
36%, 46% { transform: translateY(0); }
60% { transform: translateY(-13px); }
68% { transform: translateY(5px); }
74%, 100% { transform: translateY(0); }
}
.rb.is-demo .rb__end { transition: none; }
.rb.is-demo .rb__end--top { animation: rb-endt $dur-loop steps(1, end) infinite; }
.rb.is-demo .rb__end--bottom { animation: rb-endb $dur-loop steps(1, end) infinite; }
@keyframes rb-endt { 0%, 21% { opacity: 0; } 22%, 34% { opacity: 1; } 35%, 100% { opacity: 0; } }
@keyframes rb-endb { 0%, 59% { opacity: 0; } 60%, 72% { opacity: 1; } 73%, 100% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
.rb.is-demo .rb__inner,
.rb.is-demo .rb__end { animation: none !important; }
.rb__inner { transition: none; }
}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);
}
.rb {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
user-select: none;
-webkit-user-select: none;
}
.rb__view {
position: relative;
width: min(228px, 84vw);
height: 132px;
overflow: hidden;
background: #fff;
border-radius: 16px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
touch-action: none;
cursor: grab;
}
.rb__view:active {
cursor: grabbing;
}
.rb__view:focus-visible {
outline: 3px solid rgba(23, 20, 26, 0.5);
outline-offset: 2px;
}
.rb__inner {
transition: transform 600ms cubic-bezier(0.2, 1.4, 0.4, 1);
will-change: transform;
}
.rb__row {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
height: 30px;
padding: 0 12px;
border-bottom: 1px solid rgba(23, 20, 26, 0.07);
background: #fff;
}
.rb__row strong {
flex: 1;
font-size: 12px;
line-height: 14px;
font-weight: 700;
color: #17141a;
}
.rb__row time {
font-size: 10px;
line-height: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
}
.rb__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #ff4d1f;
}
.rb__dot--pink {
background: #ff9ebb;
}
.rb__dot--blue {
background: #2f6df6;
}
.rb__dot--lilac {
background: #b9a0ff;
}
.rb__end {
position: absolute;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
height: 24px;
background: #17141a;
color: #fff7e6;
font-size: 10px;
line-height: 12px;
font-weight: 700;
opacity: 0;
transform: translateY(-100%);
transition: opacity 220ms steps(1, end);
pointer-events: none;
}
.rb__end--top {
top: 0;
}
.rb__end--bottom {
bottom: 0;
transform: translateY(100%);
}
.rb__end.is-on {
opacity: 1;
}
.rb__hint {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
}
.rb.is-demo .rb__inner {
animation: rb-band 2s infinite;
}
@keyframes rb-band {
0%, 8% {
transform: translateY(0);
}
22% {
transform: translateY(13px);
}
30% {
transform: translateY(-5px);
}
36%, 46% {
transform: translateY(0);
}
60% {
transform: translateY(-13px);
}
68% {
transform: translateY(5px);
}
74%, 100% {
transform: translateY(0);
}
}
.rb.is-demo .rb__end {
transition: none;
}
.rb.is-demo .rb__end--top {
animation: rb-endt 2s steps(1, end) infinite;
}
.rb.is-demo .rb__end--bottom {
animation: rb-endb 2s steps(1, end) infinite;
}
@keyframes rb-endt {
0%, 21% {
opacity: 0;
}
22%, 34% {
opacity: 1;
}
35%, 100% {
opacity: 0;
}
}
@keyframes rb-endb {
0%, 59% {
opacity: 0;
}
60%, 72% {
opacity: 1;
}
73%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.rb.is-demo .rb__inner,
.rb.is-demo .rb__end {
animation: none !important;
}
.rb__inner {
transition: none;
}
}// 실제 동작: 끝에서 더 당기면 점점 저항(거리/3)이 붙고, 떼면 탄력으로 복귀. 화살표 키도 같은 반발을 보여준다.
var root = document.getElementById('rb-root');
var view = document.getElementById('rb-view');
var inner = document.getElementById('rb-inner');
var topEnd = document.getElementById('rb-top');
var bottomEnd = document.getElementById('rb-bottom');
var y0 = 0, dragging = false, cur = 0;
function bounds() {
return { top: 0, bottom: Math.max(0, inner.offsetHeight - view.clientHeight + 2) };
}
function paint(y) {
var b = bounds();
var yd = y;
if (yd < b.top) yd = b.top + (yd - b.top) / 3; // 위 끝: 거리의 1/3만 따라오는 저항
if (yd > b.bottom) yd = b.bottom + (yd - b.bottom) / 3;
cur = y;
inner.style.transform = 'translateY(' + (-yd) + 'px)';
topEnd.classList.toggle('is-on', y < -6);
bottomEnd.classList.toggle('is-on', y > b.bottom + 6);
}
view.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
dragging = true; y0 = e.clientY + cur;
view.setPointerCapture(e.pointerId);
inner.style.transition = 'none';
e.preventDefault();
});
view.addEventListener('pointermove', function (e) {
if (!dragging) return;
paint(y0 - e.clientY); // 손가락을 아래로 당기면 콘텐츠도 아래로 늘어난다
});
function drop() {
if (!dragging) return;
dragging = false;
inner.style.transition = '';
var b = bounds();
paint(Math.max(b.top, Math.min(b.bottom, cur)));
}
view.addEventListener('pointerup', drop);
view.addEventListener('pointercancel', drop);
view.addEventListener('keydown', function (e) {
var b = bounds();
if (e.key === 'ArrowUp') { root.classList.remove('is-demo'); bounce(b.top - 26); e.preventDefault(); }
else if (e.key === 'ArrowDown') { root.classList.remove('is-demo'); bounce(b.bottom + 26); e.preventDefault(); }
});
function bounce(y) {
inner.style.transition = 'none';
paint(y);
window.setTimeout(function () {
inner.style.transition = '';
var b = bounds();
paint(Math.max(b.top, Math.min(b.bottom, y)));
}, 240);
}
paint(0);import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RubberBand.module.scss";
export type RubberBandProps = {
rows?: string[];
topLabel?: string;
bottomLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; clientY: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function RubberBand({
rows = ["October maintenance", "Event winners announced", "New feature released", "Holiday notice", "Terms updated"],
topLabel = "No earlier notices",
bottomLabel = "This is the last notice",
duration = 600,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#17141a",
}: RubberBandProps) {
const [y, setY] = useState(0);
const [bounce, setBounce] = useState(false);
const viewRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const drag = useRef({ on: false, y0: 0 }) as Box<{ on: boolean; y0: number }>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function bounds() {
const v = viewRef.current, i = innerRef.current;
const bottom = v && i ? Math.max(0, i.offsetHeight - v.clientHeight + 2) : 0;
return { top: 0, bottom };
}
function display(yv: number) {
const b = bounds();
let yd = yv;
if (yd < b.top) yd = b.top + (yd - b.top) / 3;
if (yd > b.bottom) yd = b.bottom + (yd - b.bottom) / 3;
return yd;
}
function onDown(e: PEvt) {
drag.current = { on: true, y0: e.clientY + y };
e.currentTarget.setPointerCapture(e.pointerId);
setBounce(false);
e.preventDefault();
}
function onMove(e: PEvt) {
if (!drag.current.on) return;
setY(drag.current.y0 - e.clientY);
}
function onUp() {
if (!drag.current.on) return;
drag.current.on = false;
const b = bounds();
setBounce(true);
setY(Math.max(b.top, Math.min(b.bottom, y)));
}
function onKey(e: KeyEvt) {
const b = bounds();
if (e.key === "ArrowUp") { setBounce(false); setY(b.top - 26); window.setTimeout((): void => { setBounce(true); setY(0); }, 240); e.preventDefault(); }
else if (e.key === "ArrowDown") { setBounce(false); setY(b.bottom + 26); window.setTimeout((): void => { setBounce(true); setY(b.bottom); }, 240); e.preventDefault(); }
}
return (
<div className={styles.rb} style={vars}>
<div
ref={viewRef}
className={styles.view}
tabIndex={0}
role="group"
aria-label="Notice list — arrow keys check the ends"
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onKeyDown={onKey}
>
<span className={y < -6 ? styles.endOn : styles.endTop} aria-hidden="true">{topLabel}</span>
<div ref={innerRef} className={bounce ? styles.innerBounce : styles.inner} style={{ transform: `translateY(${-display(y)}px)` }}>
{rows.map((r, i) => (
<div key={r} className={styles.row}>
<span className={i % 4 === 0 ? styles.dot : i % 4 === 1 ? styles.dotPink : i % 4 === 2 ? styles.dotBlue : styles.dotLilac} />
<strong>{r}</strong>
<time>{`10-0${(5 - i)}`}</time>
</div>
))}
</div>
<span className={y > bounds().bottom + 6 ? styles.endOn : styles.endBottom} aria-hidden="true">{bottomLabel}</span>
</div>
<p className={styles.hint}>Past the end each pixel drags harder, and releasing springs it back</p>
</div>
);
}07 · 러버밴드 경계 저항 — 측정 누적면적 11.048% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
손을 떼면 튀는 이징으로 원래 자리에 돌아갑니다.
function paint(y) {
var b = bounds();
var yd = y;
if (yd < b.top) yd = b.top + (yd - b.top) / 3;
if (yd > b.bottom) yd = b.bottom + (yd - b.bottom) / 3;
cur = y;
inner.style.transform = 'translateY(' + (-yd) + 'px)';
topEnd.classList.toggle('is-on', y < -6);
bottomEnd.classList.toggle('is-on', y > b.bottom + 6);
}
08끌어올리는 바텀시트
화면에서 일어나는 일
시트가 손가락을 따라 오르내리다가 놓으면 가장 가까운 단계로 붙고, 아래로 세게 던지면 거리와 상관없이 닫힙니다.

<main class="stage">
<div class="bs is-demo" id="bs-root">
<div class="bs__frame" id="bs-frame">
<div class="bs__map" aria-hidden="true">
<i class="bs__road bs__road--h"></i>
<i class="bs__road bs__road--v"></i>
<i class="bs__road bs__road--h2"></i>
<span class="bs__pin"></span>
<span class="bs__pin bs__pin--b"></span>
</div>
<section class="bs__sheet" id="bs-sheet" tabindex="0" aria-label="주변 카페 목록 시트">
<span class="bs__grab" aria-hidden="true"></span>
<header class="bs__head">
<strong>주변 카페</strong>
<span class="bs__steps">
<button class="bs__step" type="button" id="bs-down" aria-label="한 단계 내리기">
<svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
</button>
<button class="bs__step" type="button" id="bs-up" aria-label="한 단계 올리기">
<svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 160H48l80-80Z" opacity=".2"/><path d="m213.66 154.34l-80-80a8 8 0 0 0-11.32 0l-80 80A8 8 0 0 0 48 168h160a8 8 0 0 0 5.66-13.66M67.31 152L128 91.31L188.69 152Z"/></g></svg>
</button>
</span>
</header>
<ul class="bs__list">
<li><span class="bs__cafe" style="background:#2f6df6"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88v48a88 88 0 0 1-51.3 80H83.3A88 88 0 0 1 32 136V88Z" opacity=".2"/><path d="M224 120v8a40 40 0 0 1-37.51 39.91a96.6 96.6 0 0 1-27 40.09H208a8 8 0 0 1 0 16H32a8 8 0 0 1 0-16h24.54A96.3 96.3 0 0 1 24 136V88a8 8 0 0 1 8-8h176a40 40 0 0 1 40 40m-48-24H40v40a80.27 80.27 0 0 0 45.12 72h69.76A80.27 80.27 0 0 0 200 136Z"/></g></svg></span><div><strong>모찌 카페</strong><span>도보 3분 · 4.8</span></div></li>
<li><span class="bs__cafe" style="background:#ff4d1f"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88v48a88 88 0 0 1-51.3 80H83.3A88 88 0 0 1 32 136V88Z" opacity=".2"/><path d="M224 120v8a40 40 0 0 1-37.51 39.91a96.6 96.6 0 0 1-27 40.09H208a8 8 0 0 1 0 16H32a8 8 0 0 1 0-16h24.54A96.3 96.3 0 0 1 24 136V88a8 8 0 0 1 8-8h176a40 40 0 0 1 40 40m-48-24H40v40a80.27 80.27 0 0 0 45.12 72h69.76A80.27 80.27 0 0 0 200 136Z"/></g></svg></span><div><strong>주스 가게</strong><span>도보 5분 · 4.6</span></div></li>
<li><span class="bs__cafe" style="background:#4cd4a6"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88v48a88 88 0 0 1-51.3 80H83.3A88 88 0 0 1 32 136V88Z" opacity=".2"/><path d="M224 120v8a40 40 0 0 1-37.51 39.91a96.6 96.6 0 0 1-27 40.09H208a8 8 0 0 1 0 16H32a8 8 0 0 1 0-16h24.54A96.3 96.3 0 0 1 24 136V88a8 8 0 0 1 8-8h176a40 40 0 0 1 40 40m-48-24H40v40a80.27 80.27 0 0 0 45.12 72h69.76A80.27 80.27 0 0 0 200 136Z"/></g></svg></span><div><strong>녹차 하우스</strong><span>도보 7분 · 4.9</span></div></li>
</ul>
</section>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.bs { display: flex; justify-content: center; }
.bs__frame {
position: relative;
width: 232px;
height: 168px;
border-radius: $r-card;
overflow: hidden;
background: #23202b;
}
.bs__map { position: absolute; inset: 0; }
.bs__road { position: absolute; background: rgba(255, 255, 255, .08); border-radius: $r-pill; }
.bs__road--h { left: -8px; right: 40px; top: 44px; height: 10px; }
.bs__road--v { top: -8px; bottom: 60px; left: 60px; width: 10px; }
.bs__road--h2 { left: 56px; right: -8px; top: 108px; height: 7px; }
.bs__pin {
position: absolute;
width: 12px; height: 12px;
border-radius: 50%;
background: $stage-orange;
box-shadow: 0 0 0 4px rgba(255, 77, 31, .22);
}
.bs__pin { top: 24px; left: 104px; }
.bs__pin--b { top: 84px; left: 148px; background: $subject-blue; box-shadow: 0 0 0 4px rgba(47, 109, 246, .22); }
.bs__sheet {
position: absolute;
left: 0; right: 0; bottom: 0;
box-sizing: border-box;
height: 150px;
background: $subject-cream;
border-radius: $r-card $r-card 0 0;
box-shadow: 0 -8px 20px rgba(0, 0, 0, .35);
transform: translateY(106px);
transition: transform $duration $easing;
touch-action: none;
cursor: grab;
}
.bs__sheet:active { cursor: grabbing; }
.bs__sheet:focus-visible { outline: 3px solid $color; outline-offset: -4px; }
.bs__grab {
display: block;
width: 36px; height: 4px;
margin: $sp-2 auto 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .22);
}
.bs__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-2 $sp-3 0;
}
.bs__head strong { font-size: 13px; line-height: 16px; font-weight: 800; color: $subject-ink; }
.bs__steps { display: flex; gap: $sp-1; }
.bs__step {
display: inline-flex; align-items: center; justify-content: center;
width: 24px; height: 24px;
border: 0;
background: rgba(23, 20, 26, .08);
border-radius: 50%;
color: $subject-ink;
cursor: pointer;
transition: background $dur-quick $easing;
}
.bs__step:hover { background: rgba(23, 20, 26, .18); }
.bs__step:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.bs__list { margin: $sp-2 0 0; padding: 0 $sp-2; list-style: none; }
.bs__list li {
display: flex;
align-items: center;
gap: $sp-2;
height: 30px;
padding: 0 $sp-2;
border-radius: $r-sm;
}
.bs__list li:nth-child(2) { background: rgba(47, 109, 246, .1); }
.bs__cafe {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px;
border-radius: 50%;
color: #fff;
font-size: 10px; line-height: 22px; font-weight: 800;
}
.bs__list div { display: flex; flex-direction: column; }
.bs__list strong { font-size: 11px; line-height: 13px; font-weight: 700; color: $subject-ink; }
.bs__list span { font-size: 9px; line-height: 11px; font-weight: 600; color: rgba(23, 20, 26, .72); }
// ── 자동 루프: peek → half → full → 아래로 던져 닫힘(peek). 끝=처음이라 이어진다 ──
.bs.is-demo .bs__sheet { animation: bs-snap $dur-loop $easing infinite; }
@keyframes bs-snap {
0%, 10% { transform: translateY(106px); }
22%, 38% { transform: translateY(54px); }
50%, 66% { transform: translateY(0); }
74%, 100% { transform: translateY(106px); }
}
.bs.is-demo .bs__list li:nth-child(2),
.bs.is-demo .bs__list li:nth-child(3) { animation: bs-reveal $dur-loop steps(1, end) infinite; }
@keyframes bs-reveal {
0%, 49% { opacity: 1; }
50%, 73% { opacity: 0; }
74%, 100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.bs.is-demo .bs__sheet,
.bs.is-demo .bs__list li { animation: none !important; }
.bs.is-demo .bs__sheet { transform: translateY(54px); }
.bs__sheet { transition: none; }
}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);
}
.bs {
display: flex;
justify-content: center;
}
.bs__frame {
position: relative;
width: 232px;
height: 168px;
border-radius: 16px;
overflow: hidden;
background: #23202b;
}
.bs__map {
position: absolute;
inset: 0;
}
.bs__road {
position: absolute;
background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
}
.bs__road--h {
left: -8px;
right: 40px;
top: 44px;
height: 10px;
}
.bs__road--v {
top: -8px;
bottom: 60px;
left: 60px;
width: 10px;
}
.bs__road--h2 {
left: 56px;
right: -8px;
top: 108px;
height: 7px;
}
.bs__pin {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background: #ff4d1f;
box-shadow: 0 0 0 4px rgba(255, 77, 31, 0.22);
}
.bs__pin {
top: 24px;
left: 104px;
}
.bs__pin--b {
top: 84px;
left: 148px;
background: #2f6df6;
box-shadow: 0 0 0 4px rgba(47, 109, 246, 0.22);
}
.bs__sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
box-sizing: border-box;
height: 150px;
background: #fff7e6;
border-radius: 16px 16px 0 0;
box-shadow: 0 -8px 20px rgba(0, 0, 0, 0.35);
transform: translateY(106px);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
touch-action: none;
cursor: grab;
}
.bs__sheet:active {
cursor: grabbing;
}
.bs__sheet:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: -4px;
}
.bs__grab {
display: block;
width: 36px;
height: 4px;
margin: 8px auto 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.22);
}
.bs__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px 0;
}
.bs__head strong {
font-size: 13px;
line-height: 16px;
font-weight: 800;
color: #17141a;
}
.bs__steps {
display: flex;
gap: 4px;
}
.bs__step {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 0;
background: rgba(23, 20, 26, 0.08);
border-radius: 50%;
color: #17141a;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bs__step:hover {
background: rgba(23, 20, 26, 0.18);
}
.bs__step:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.bs__list {
margin: 8px 0 0;
padding: 0 8px;
list-style: none;
}
.bs__list li {
display: flex;
align-items: center;
gap: 8px;
height: 30px;
padding: 0 8px;
border-radius: 8px;
}
.bs__list li:nth-child(2) {
background: rgba(47, 109, 246, 0.1);
}
.bs__cafe {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
color: #fff;
font-size: 10px;
line-height: 22px;
font-weight: 800;
}
.bs__list div {
display: flex;
flex-direction: column;
}
.bs__list strong {
font-size: 11px;
line-height: 13px;
font-weight: 700;
color: #17141a;
}
.bs__list span {
font-size: 9px;
line-height: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
}
.bs.is-demo .bs__sheet {
animation: bs-snap 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes bs-snap {
0%, 10% {
transform: translateY(106px);
}
22%, 38% {
transform: translateY(54px);
}
50%, 66% {
transform: translateY(0);
}
74%, 100% {
transform: translateY(106px);
}
}
.bs.is-demo .bs__list li:nth-child(2),
.bs.is-demo .bs__list li:nth-child(3) {
animation: bs-reveal 2s steps(1, end) infinite;
}
@keyframes bs-reveal {
0%, 49% {
opacity: 1;
}
50%, 73% {
opacity: 0;
}
74%, 100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.bs.is-demo .bs__sheet,
.bs.is-demo .bs__list li {
animation: none !important;
}
.bs.is-demo .bs__sheet {
transform: translateY(54px);
}
.bs__sheet {
transition: none;
}
}// 실제 동작: 시트가 손가락을 따라 오르내리다 놓으면 가까운 단계로 스냅, 아래로 던지면 닫힌다.
var root = document.getElementById('bs-root');
var frame = document.getElementById('bs-frame');
var sheet = document.getElementById('bs-sheet');
var H = 150; // 시트 높이
var SNAPS = [H - 44, H - 96, 0]; // peek, half, full (translateY 값)
var at = 0, dragging = false, startY = 0, lastY = 0, t0 = 0, v0 = 0, startPos = 0;
function paint(ty) { sheet.style.transform = 'translateY(' + ty + 'px)'; }
function go(i) {
at = Math.max(0, Math.min(SNAPS.length - 1, i));
paint(SNAPS[at]);
}
sheet.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
dragging = true;
startY = lastY = e.clientY; t0 = Date.now(); v0 = 0; startPos = SNAPS[at];
sheet.setPointerCapture(e.pointerId);
sheet.style.transition = 'none';
// e.preventDefault(); — 버튼 클릭을 막지 않게 놔둔다
});
sheet.addEventListener('pointermove', function (e) {
if (!dragging) return;
var now = Date.now();
if (now - t0 > 0) { v0 = (e.clientY - lastY) / Math.max(1, now - t0); lastY = e.clientY; t0 = now; }
paint(Math.max(0, startPos + (e.clientY - startY))); // 손가락을 따라 실시간으로
});
function drop() {
if (!dragging) return;
dragging = false;
sheet.style.transition = '';
var cur2 = startPos + (lastY - startY);
if (v0 > .5) { go(0); return; } // 아래로 세게 던짐 → 닫힘
if (v0 < -.5) { go(SNAPS.length - 1); return; } // 위로 세게 덗침 → 전체
var best = 0, dist = 1e9;
SNAPS.forEach(function (s, i) {
var d = Math.abs(cur2 - s);
if (d < dist) { dist = d; best = i; }
});
go(best);
}
sheet.addEventListener('pointerup', drop);
sheet.addEventListener('pointercancel', drop);
document.getElementById('bs-up').addEventListener('click', function () { root.classList.remove('is-demo'); go(at + 1); });
document.getElementById('bs-down').addEventListener('click', function () { root.classList.remove('is-demo'); go(at - 1); });
sheet.addEventListener('keydown', function (e) {
if (e.key === 'ArrowUp') { root.classList.remove('is-demo'); go(at + 1); e.preventDefault(); }
else if (e.key === 'ArrowDown' || e.key === 'Escape') { root.classList.remove('is-demo'); go(at - 1); e.preventDefault(); }
});
go(0);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BottomSheet.module.scss";
export type BottomSheetProps = {
title?: string;
rows?: { name: string; meta: string; hue: string }[];
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; clientY: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const H = 150;
const SNAPS = [H - 44, H - 96, 0]; // peek · half · full
const IconUp = () => (
<svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 160H48l80-80Z" opacity=".2"/><path d="m213.66 154.34l-80-80a8 8 0 0 0-11.32 0l-80 80A8 8 0 0 0 48 168h160a8 8 0 0 0 5.66-13.66M67.31 152L128 91.31L188.69 152Z"/></g></svg>
);
const IconDown = () => (
<svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
);
const IconCoffee = () => (
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88v48a88 88 0 0 1-51.3 80H83.3A88 88 0 0 1 32 136V88Z" opacity=".2"/><path d="M224 120v8a40 40 0 0 1-37.51 39.91a96.6 96.6 0 0 1-27 40.09H208a8 8 0 0 1 0 16H32a8 8 0 0 1 0-16h24.54A96.3 96.3 0 0 1 24 136V88a8 8 0 0 1 8-8h176a40 40 0 0 1 40 40m-48-24H40v40a80.27 80.27 0 0 0 45.12 72h69.76A80.27 80.27 0 0 0 200 136Z"/></g></svg>
);
export default function BottomSheet({
title = "Nearby cafes",
rows = [
{ name: "Mochi Cafe", meta: "3-min walk · 4.8", hue: "#2f6df6" },
{ name: "Juice Stand", meta: "5-min walk · 4.6", hue: "#ff4d1f" },
{ name: "Tea House", meta: "7-min walk · 4.9", hue: "#4cd4a6" },
],
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: BottomSheetProps) {
const [at, setAt] = useState(0);
const [ty, setTy] = useState(SNAPS[0]);
const [dragging, setDragging] = useState(false);
const drag = useRef({ startY: 0, lastY: 0, t0: 0, v: 0, startPos: 0 }) as Box<{ startY: number; lastY: number; t0: number; v: number; startPos: number }>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function go(i: number) {
const n = Math.max(0, Math.min(SNAPS.length - 1, i));
setAt(n);
setTy(SNAPS[n]);
}
function onDown(e: PEvt) {
drag.current = { startY: e.clientY, lastY: e.clientY, t0: Date.now(), v: 0, startPos: SNAPS[at] };
setDragging(true);
e.currentTarget.setPointerCapture(e.pointerId);
}
function onMove(e: PEvt) {
if (!dragging) return;
const now = Date.now();
if (now - drag.current.t0 > 0) {
drag.current.v = (e.clientY - drag.current.lastY) / Math.max(1, now - drag.current.t0);
drag.current.lastY = e.clientY;
drag.current.t0 = now;
}
setTy(Math.max(0, drag.current.startPos + (e.clientY - drag.current.startY)));
}
function onUp() {
if (!dragging) return;
setDragging(false);
const cur = drag.current.startPos + (drag.current.lastY - drag.current.startY);
if (drag.current.v > 0.5) { go(0); return; }
if (drag.current.v < -0.5) { go(SNAPS.length - 1); return; }
let best = 0, dist = 1e9;
SNAPS.forEach((s, i) => {
const d = Math.abs(cur - s);
if (d < dist) { dist = d; best = i; }
});
go(best);
}
function onKey(e: KeyEvt) {
if (e.key === "ArrowUp") { go(at + 1); e.preventDefault(); }
else if (e.key === "ArrowDown" || e.key === "Escape") { go(at - 1); e.preventDefault(); }
}
useEffect(() => {
const el = document.documentElement;
function wheel(e: WheelEvent) { if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) e.preventDefault(); }
el.addEventListener("wheel", wheel, { passive: false });
return () => el.removeEventListener("wheel", wheel);
}, []);
return (
<div className={styles.bs} style={vars}>
<div className={styles.frame}>
<div className={styles.map} aria-hidden="true">
<i className={styles.roadH} /><i className={styles.roadV} /><i className={styles.roadH2} />
<span className={styles.pin} /><span className={`${styles.pin} ${styles.pinB}`} />
</div>
<section
className={dragging ? styles.sheetRaw : styles.sheet}
style={{ transform: `translateY(${ty}px)` }}
tabIndex={0}
aria-label={`${title} sheet`}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onKeyDown={onKey}
>
<span className={styles.grab} aria-hidden="true" />
<header className={styles.head}>
<strong>{title}</strong>
<span className={styles.steps}>
<button type="button" className={styles.step} aria-label="Down one step" onClick={(): void => go(at - 1)}>
<IconDown />
</button>
<button type="button" className={styles.step} aria-label="Up one step" onClick={(): void => go(at + 1)}>
<IconUp />
</button>
</span>
</header>
<ul className={styles.list}>
{rows.map((r, i) => (
<li key={r.name} className={i === 1 ? styles.rowOn : undefined}>
<span className={styles.cafe} style={{ background: r.hue }} aria-hidden="true"><IconCoffee /></span>
<div><strong>{r.name}</strong><span>{r.meta}</span></div>
</li>
))}
</ul>
</section>
</div>
</div>
);
}08 · 끌어올리는 바텀시트 — 측정 누적면적 22.508% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
던졌는지는 마지막 두 이벤트 사이의 속도로 재고 0.5px/ms가 경계입니다.
function drop() {
if (!dragging) return;
dragging = false;
sheet.style.transition = '';
var cur2 = startPos + (lastY - startY);
if (v0 > .5) { go(0); return; }
if (v0 < -.5) { go(SNAPS.length - 1); return; }
var best = 0, dist = 1e9;
SNAPS.forEach(function (s, i) {
var d = Math.abs(cur2 - s);
if (d < dist) { dist = d; best = i; }
});
go(best);
}
09핀치 줌
어떤 움직임인가
손가락 두 개의 좌표를 각각 기억해 두고, 둘 사이 거리의 비율로 확대율을, 기울기 차이로 회전 각도를 만듭니다.

<main class="stage">
<div class="pz is-demo" id="pz-root">
<div class="pz__frame" id="pz-frame">
<div class="pz__photo" id="pz-photo">
<svg viewBox="0 0 190 128" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect class="pz__bg" width="190" height="128"/>
<circle class="pz__moon" cx="138" cy="36" r="15"/>
<polygon class="pz__t1" points="0,128 55,58 110,128"/>
<polygon class="pz__t2" points="76,128 138,70 190,118 190,128"/>
<rect class="pz__field" y="102" width="190" height="26"/>
<circle class="pz__tree" cx="30" cy="96" r="7"/>
<circle class="pz__tree pz__tree--b" cx="163" cy="92" r="5"/>
</svg>
</div>
<span class="pz__finger pz__finger--a" id="pz-fa" aria-hidden="true"></span>
<span class="pz__finger pz__finger--b" id="pz-fb" aria-hidden="true"></span>
</div>
<div class="pz__side">
<button class="pz__btn" type="button" id="pz-in" aria-label="확대">
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72m112 0a8 8 0 0 1-8 8h-24v24a8 8 0 0 1-16 0v-24H80a8 8 0 0 1 0-16h24V80a8 8 0 0 1 16 0v24h24a8 8 0 0 1 8 8"/></g></svg>
</button>
<span class="pz__zoom" id="pz-zoom" aria-live="polite"><span class="pz__z pz__z--a">100%</span><span class="pz__z pz__z--b">162%</span></span>
<button class="pz__btn" type="button" id="pz-out" aria-label="축소">
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72m112 0a8 8 0 0 1-8 8H80a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8"/></g></svg>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
.pz {
display: flex;
align-items: center;
gap: $sp-4;
user-select: none;
-webkit-user-select: none;
}
.pz__frame {
position: relative;
width: 190px;
height: 128px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-lilac;
touch-action: none;
cursor: grab;
}
.pz__frame:active { cursor: grabbing; }
.pz__photo {
width: 100%;
height: 100%;
transition: transform $duration $easing;
will-change: transform;
}
.pz__photo svg { display: block; width: 100%; height: 100%; }
.pz__bg { fill: $subject-lilac; }
.pz__moon { fill: $subject-cream; }
.pz__t1 { fill: $subject-ink; }
.pz__t2 { fill: $stage-orange; }
.pz__field { fill: $subject-ink; opacity: .8; }
.pz__tree { fill: $subject-ink; }
.pz__tree--b { fill: $subject-cream; }
.pz__finger {
position: absolute;
left: -8px;
top: -8px;
width: 16px; height: 16px;
box-sizing: border-box;
border: 3px solid $subject-cream;
border-radius: 50%;
background: rgba(23, 20, 26, .5);
box-shadow: 0 0 0 4px rgba(255, 247, 230, .25);
pointer-events: none;
}
.pz__side { display: flex; flex-direction: column; align-items: center; gap: $sp-2; }
.pz__btn {
display: inline-flex; align-items: center; justify-content: center;
width: 34px; height: 34px;
border: 1px solid rgba(255, 247, 230, .55);
background: rgba(255, 247, 230, .12);
border-radius: $r-control;
color: $color;
cursor: pointer;
transition: background $dur-quick $easing, color $dur-quick $easing;
}
.pz__btn:hover { background: $color; color: $stage-orange; }
.pz__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.pz__zoom {
position: relative;
display: block;
width: 44px;
height: 14px;
font-size: 12px; line-height: 14px; font-weight: 700;
color: $subject-ink;
font-variant-numeric: tabular-nums;
text-align: center;
}
// 자동 루프에서는 두 라벨을 steps(1,end) 로 컷 교체한다 — 크로스페이드는 두 숫자가 겹쳐 보인다
.pz__z { position: absolute; left: 0; right: 0; }
.pz__z--b { opacity: 0; }
.pz.is-demo .pz__z--a { animation: pz-za $dur-loop steps(1, end) infinite; }
.pz.is-demo .pz__z--b { animation: pz-zb $dur-loop steps(1, end) infinite; }
@keyframes pz-za { 0%, 41% { opacity: 1; } 42%, 78% { opacity: 0; } 79%, 100% { opacity: 1; } }
@keyframes pz-zb { 0%, 41% { opacity: 0; } 42%, 78% { opacity: 1; } 79%, 100% { opacity: 0; } }
// ── 자동 루프: 두 손가락 벌리기(확대+회전) → 유지 → 손 떼기 → 복귀 ──
.pz.is-demo .pz__photo { animation: pz-zoom $dur-loop $easing infinite; }
@keyframes pz-zoom {
0%, 10% { transform: translate(0, 0) scale(1) rotate(0deg); }
42%, 64% { transform: translate(-7px, 5px) scale(1.62) rotate(7deg); }
90%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); }
}
.pz.is-demo .pz__finger--a { animation: pz-fa $dur-loop $easing infinite; }
@keyframes pz-fa {
0%, 8% { transform: translate(76px, 64px); opacity: 0; }
12% { opacity: 1; }
42%, 64% { transform: translate(24px, 74px); opacity: 1; }
70% { opacity: 0; }
100% { transform: translate(76px, 64px); opacity: 0; }
}
.pz.is-demo .pz__finger--b { animation: pz-fb $dur-loop $easing infinite; }
@keyframes pz-fb {
0%, 8% { transform: translate(104px, 64px); opacity: 0; }
12% { opacity: 1; }
42%, 64% { transform: translate(158px, 50px); opacity: 1; }
70% { opacity: 0; }
100% { transform: translate(104px, 64px); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.pz.is-demo .pz__photo,
.pz.is-demo .pz__finger,
.pz.is-demo .pz__z { animation: none !important; }
.pz__photo { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pz {
display: flex;
align-items: center;
gap: 16px;
user-select: none;
-webkit-user-select: none;
}
.pz__frame {
position: relative;
width: 190px;
height: 128px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #b9a0ff;
touch-action: none;
cursor: grab;
}
.pz__frame:active {
cursor: grabbing;
}
.pz__photo {
width: 100%;
height: 100%;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
will-change: transform;
}
.pz__photo svg {
display: block;
width: 100%;
height: 100%;
}
.pz__bg {
fill: #b9a0ff;
}
.pz__moon {
fill: #fff7e6;
}
.pz__t1 {
fill: #17141a;
}
.pz__t2 {
fill: #ff4d1f;
}
.pz__field {
fill: #17141a;
opacity: 0.8;
}
.pz__tree {
fill: #17141a;
}
.pz__tree--b {
fill: #fff7e6;
}
.pz__finger {
position: absolute;
left: -8px;
top: -8px;
width: 16px;
height: 16px;
box-sizing: border-box;
border: 3px solid #fff7e6;
border-radius: 50%;
background: rgba(23, 20, 26, 0.5);
box-shadow: 0 0 0 4px rgba(255, 247, 230, 0.25);
pointer-events: none;
}
.pz__side {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.pz__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: 1px solid rgba(255, 247, 230, 0.55);
background: rgba(255, 247, 230, 0.12);
border-radius: 12px;
color: #fff7e6;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pz__btn:hover {
background: #fff7e6;
color: #ff4d1f;
}
.pz__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.pz__zoom {
position: relative;
display: block;
width: 44px;
height: 14px;
font-size: 12px;
line-height: 14px;
font-weight: 700;
color: #17141a;
font-variant-numeric: tabular-nums;
text-align: center;
}
.pz__z {
position: absolute;
left: 0;
right: 0;
}
.pz__z--b {
opacity: 0;
}
.pz.is-demo .pz__z--a {
animation: pz-za 2s steps(1, end) infinite;
}
.pz.is-demo .pz__z--b {
animation: pz-zb 2s steps(1, end) infinite;
}
@keyframes pz-za {
0%, 41% {
opacity: 1;
}
42%, 78% {
opacity: 0;
}
79%, 100% {
opacity: 1;
}
}
@keyframes pz-zb {
0%, 41% {
opacity: 0;
}
42%, 78% {
opacity: 1;
}
79%, 100% {
opacity: 0;
}
}
.pz.is-demo .pz__photo {
animation: pz-zoom 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes pz-zoom {
0%, 10% {
transform: translate(0, 0) scale(1) rotate(0deg);
}
42%, 64% {
transform: translate(-7px, 5px) scale(1.62) rotate(7deg);
}
90%, 100% {
transform: translate(0, 0) scale(1) rotate(0deg);
}
}
.pz.is-demo .pz__finger--a {
animation: pz-fa 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes pz-fa {
0%, 8% {
transform: translate(76px, 64px);
opacity: 0;
}
12% {
opacity: 1;
}
42%, 64% {
transform: translate(24px, 74px);
opacity: 1;
}
70% {
opacity: 0;
}
100% {
transform: translate(76px, 64px);
opacity: 0;
}
}
.pz.is-demo .pz__finger--b {
animation: pz-fb 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes pz-fb {
0%, 8% {
transform: translate(104px, 64px);
opacity: 0;
}
12% {
opacity: 1;
}
42%, 64% {
transform: translate(158px, 50px);
opacity: 1;
}
70% {
opacity: 0;
}
100% {
transform: translate(104px, 64px);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.pz.is-demo .pz__photo,
.pz.is-demo .pz__finger,
.pz.is-demo .pz__z {
animation: none !important;
}
.pz__photo {
transition: none;
}
}// 실제 동작: 두 손가락 거리→확대, 비틀림→회전. 마우스는 휠(확대)·드래그(이동), 버튼·화살표 키도 같은 동작.
var root = document.getElementById('pz-root');
var frame = document.getElementById('pz-frame');
var photo = document.getElementById('pz-photo');
var zoom = document.getElementById('pz-zoom');
var fa = document.getElementById('pz-fa');
var fb = document.getElementById('pz-fb');
var ptrs = {}; // pointerId -> {x, y}
var scale = 1, rot = 0, tx = 0, ty = 0;
var start = null; // 드래그/핀치 시작 스냅샷
function paint() {
photo.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ') rotate(' + rot + 'deg)';
// 자동 루프를 끈 뒤(사람이 만진 뒤)에만 숫자를 직접 쓴다 — 루프 중에는 두 라벨이 steps 로 교체된다
if (!root.classList.contains('is-demo')) zoom.textContent = Math.round(scale * 100) + '%';
}
function pinchInfo() {
var ks = Object.keys(ptrs);
if (ks.length < 2) return null;
var a = ptrs[ks[0]], b = ptrs[ks[1]];
var r = frame.getBoundingClientRect();
var ax = a.x - r.left, ay = a.y - r.top, bx = b.x - r.left, by = b.y - r.top;
fa.style.transform = 'translate(' + ax + 'px,' + ay + 'px)';
fb.style.transform = 'translate(' + bx + 'px,' + by + 'px)';
return {
d: Math.hypot(ax - bx, ay - by),
ang: Math.atan2(by - ay, bx - ax) * 180 / Math.PI,
mx: (ax + bx) / 2, my: (ay + by) / 2
};
}
frame.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
frame.setPointerCapture(e.pointerId);
ptrs[e.pointerId] = { x: e.clientX, y: e.clientY };
var info = pinchInfo();
start = { scale: scale, rot: rot, tx: tx, ty: ty,
d: info ? info.d : 0, ang: info ? info.ang : 0,
px: e.clientX, py: e.clientY };
e.preventDefault();
});
frame.addEventListener('pointermove', function (e) {
if (!(e.pointerId in ptrs)) return;
ptrs[e.pointerId] = { x: e.clientX, y: e.clientY };
var info = pinchInfo();
if (info && start && start.d > 0) {
scale = Math.max(.6, Math.min(2.4, start.scale * info.d / start.d));
var da = info.ang - start.ang;
while (da > 180) da -= 360;
while (da < -180) da += 360;
rot = Math.max(-24, Math.min(24, start.rot + da * .5));
} else if (start) {
if (scale > 1.05) { // 확대된 상태에서 한 손가락 이동(팬)
tx = start.tx + (e.clientX - start.px);
ty = start.ty + (e.clientY - start.py);
}
}
paint();
});
function up(e) {
delete ptrs[e.pointerId];
var ks = Object.keys(ptrs);
if (ks.length === 1) { // 남은 한 손가락 기준으로 팬 시작점 다시 잡기
var p = ptrs[ks[0]];
start = { scale: scale, rot: rot, tx: tx, ty: ty, d: 0, ang: 0, px: p.x, py: p.y };
}
}
frame.addEventListener('pointerup', up);
frame.addEventListener('pointercancel', up);
frame.addEventListener('wheel', function (e) {
root.classList.remove('is-demo');
scale = Math.max(.6, Math.min(2.4, scale * (e.deltaY < 0 ? 1.12 : .89)));
paint();
e.preventDefault();
}, { passive: false });
function step(di) {
root.classList.remove('is-demo');
scale = Math.max(.6, Math.min(2.4, scale + di));
paint();
}
document.getElementById('pz-in').addEventListener('click', function () { step(.2); });
document.getElementById('pz-out').addEventListener('click', function () { step(-.2); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { root.classList.remove('is-demo'); tx += 8; paint(); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { root.classList.remove('is-demo'); tx -= 8; paint(); e.preventDefault(); }
else if (e.key === 'ArrowDown') { root.classList.remove('is-demo'); ty += 8; paint(); e.preventDefault(); }
else if (e.key === 'ArrowUp') { root.classList.remove('is-demo'); ty -= 8; paint(); e.preventDefault(); }
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PinchZoom.module.scss";
export type PinchZoomProps = {
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; clientY: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type WEvt = { deltaY: number; preventDefault(): void };
type Box<T> = { current: T };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Pt = { x: number; y: number };
const IconIn = () => (
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72m112 0a8 8 0 0 1-8 8h-24v24a8 8 0 0 1-16 0v-24H80a8 8 0 0 1 0-16h24V80a8 8 0 0 1 16 0v24h24a8 8 0 0 1 8 8"/></g></svg>
);
const IconOut = () => (
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72m112 0a8 8 0 0 1-8 8H80a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8"/></g></svg>
);
export default function PinchZoom({
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: PinchZoomProps) {
const [tf, setTf] = useState({ scale: 1, rot: 0, tx: 0, ty: 0 });
const frameRef = useRef<HTMLDivElement>(null);
const ptrs = useRef<Record<number, Pt>>({}) as Box<Record<number, Pt>>;
const start = useRef({ scale: 1, rot: 0, tx: 0, ty: 0, d: 0, ang: 0, px: 0, py: 0 }) as Box<{ scale: number; rot: number; tx: number; ty: number; d: number; ang: number; px: number; py: number }>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function pinchInfo() {
const ks = Object.keys(ptrs.current);
if (ks.length < 2) return null;
const r = (frameRef.current as HTMLDivElement).getBoundingClientRect();
const a = ptrs.current[Number(ks[0])], b = ptrs.current[Number(ks[1])];
return {
d: Math.hypot(a.x - b.x, a.y - b.y),
ang: Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI,
};
}
function clampScale(s: number) { return Math.max(0.6, Math.min(2.4, s)); }
function onDown(e: PEvt) {
e.currentTarget.setPointerCapture(e.pointerId);
ptrs.current[e.pointerId] = { x: e.clientX, y: e.clientY };
const info = pinchInfo();
start.current = { scale: tf.scale, rot: tf.rot, tx: tf.tx, ty: tf.ty,
d: info ? info.d : 0, ang: info ? info.ang : 0, px: e.clientX, py: e.clientY };
e.preventDefault();
}
function onMove(e: PEvt) {
if (!(e.pointerId in ptrs.current)) return;
ptrs.current[e.pointerId] = { x: e.clientX, y: e.clientY };
const info = pinchInfo();
if (info && start.current.d > 0) {
let da = info.ang - start.current.ang;
while (da > 180) da -= 360;
while (da < -180) da += 360;
setTf((t) => ({
...t,
scale: clampScale(start.current.scale * info.d / start.current.d),
rot: Math.max(-24, Math.min(24, start.current.rot + da * 0.5)),
}));
} else if (tf.scale > 1.05) {
setTf((t) => ({ ...t, tx: start.current.tx + (e.clientX - start.current.px), ty: start.current.ty + (e.clientY - start.current.py) }));
}
}
function onUp(e: PEvt) {
delete ptrs.current[e.pointerId];
const ks = Object.keys(ptrs.current);
if (ks.length === 1) {
const p = ptrs.current[Number(ks[0])];
start.current = { ...start.current, d: 0, ang: 0, px: p.x, py: p.y };
}
}
function onWheel(e: WEvt) {
setTf((t) => ({ ...t, scale: clampScale(t.scale * (e.deltaY < 0 ? 1.12 : 0.89)) }));
e.preventDefault();
}
function step(di: number) {
setTf((t) => ({ ...t, scale: clampScale(t.scale + di) }));
}
function onKey(e: KeyEvt) {
if (e.key === "ArrowRight") { setTf((t) => ({ ...t, tx: t.tx + 8 })); e.preventDefault(); }
else if (e.key === "ArrowLeft") { setTf((t) => ({ ...t, tx: t.tx - 8 })); e.preventDefault(); }
else if (e.key === "ArrowDown") { setTf((t) => ({ ...t, ty: t.ty + 8 })); e.preventDefault(); }
else if (e.key === "ArrowUp") { setTf((t) => ({ ...t, ty: t.ty - 8 })); e.preventDefault(); }
}
return (
<div className={styles.pz} style={vars} onKeyDown={onKey} tabIndex={0} aria-label="Pinch zoom viewer">
<div
ref={frameRef}
className={styles.frame}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onWheel={onWheel}
>
<div
className={styles.photo}
style={{ transform: `translate(${tf.tx}px, ${tf.ty}px) scale(${tf.scale}) rotate(${tf.rot}deg)` }}
>
<svg viewBox="0 0 190 128" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect className={styles.bg} width="190" height="128" />
<circle className={styles.moon} cx="138" cy="36" r="15" />
<polygon className={styles.t1} points="0,128 55,58 110,128" />
<polygon className={styles.t2} points="76,128 138,70 190,118 190,128" />
<rect className={styles.field} y="102" width="190" height="26" />
<circle className={styles.tree} cx="30" cy="96" r="7" />
<circle className={styles.treeB} cx="163" cy="92" r="5" />
</svg>
</div>
</div>
<div className={styles.side}>
<button type="button" className={styles.btn} aria-label="Zoom in" onClick={(): void => step(0.2)}>
<IconIn />
</button>
<span className={styles.zoom} aria-live="polite">{`${Math.round(tf.scale * 100)}%`}</span>
<button type="button" className={styles.btn} aria-label="Zoom out" onClick={(): void => step(-0.2)}>
<IconOut />
</button>
</div>
</div>
);
}09 · 핀치 줌 — 측정 누적면적 7.376% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
마우스에는 두 번째 손가락이 없으므로 휠로 확대하고 드래그로 이동하게 따로 열어 두었습니다.
function pinchInfo() {
var ks = Object.keys(ptrs);
if (ks.length < 2) return null;
var a = ptrs[ks[0]], b = ptrs[ks[1]];
var r = frame.getBoundingClientRect();
var ax = a.x - r.left, ay = a.y - r.top, bx = b.x - r.left, by = b.y - r.top;
return {
d: Math.hypot(ax - bx, ay - by),
ang: Math.atan2(by - ay, bx - ax) * 180 / Math.PI
};
}
어디서 깨지는가 — 함정
제스처 데모의 진짜 함정은 손가락 쪽이 아니라 화면 밖으로 나간 요소가 문서 크기에 그대로 남는다는 점이었습니다. 05번 카드 덱은 판정이 끝난 카드를 translateX(230px)로 날려 보내는데, 카드가 눈에 보이지 않게 된 뒤에도 그 위치가 문서 폭에 계속 잡힙니다. 320px 폭으로 재어 보니 카드 한 장이 화면 오른쪽으로 100px 넘게 튀어나와 있었고, 무대를 감싼 overflow: hidden은 그 값을 줄여 주지 않았습니다. 보이지 않는 것과 크기에 안 잡히는 것은 다른 일입니다. 고친 방법은 덱 자신에게 overflow: hidden과 고정 크기를 줘서 카드가 덱 밖으로 나가는 순간 잘려 나가게 한 것이고, 그러자 같은 애니메이션에서 변한 픽셀 면적이 29.6%에서 9.7%로 내려갔습니다. 날아가던 카드가 무대 밖까지 그리던 만큼이 통째로 빠진 값입니다.
그다음으로 자주 걸린 것은 회전입니다. 03번 다이얼은 손잡이를 rotate로 돌리는데, 손잡이 안에 넣은 스피커 아이콘도 같이 돌아 값이 커질수록 거꾸로 섰습니다. 같은 각도만큼 반대로 돌리는 애니메이션을 아이콘에만 따로 걸어 세워 두었습니다. 게이지 호도 처음에는 stroke-dasharray에 길이 하나만 줬다가 그 길이가 원 둘레를 넘도록 반복되면서 호가 꼬였습니다. 270도만 그리려면 그리는 길이 197.92와 남기는 길이 66.06을 함께 적어야 합니다. 손끝으로 잡아 당기는 좌표 계산이 점과 선이 되면 플로우차트 UI의 노드 연결과 같은 수식으로 합쳐집니다. 이런 자잘한 수정까지 다 반영한 아홉 벌 소스는 압축 비밀번호 49t2bve3를 넣으면 열리는 zip 안에 들어 있고, 바닐라와 React 판이 같은 값을 씁니다.
접근성
아홉 개 전부 제스처가 유일한 길이 되지 않게 만들었습니다. 01번은 더하기·빼기 버튼, 02번은 화살표 오른쪽 키, 03번은 화살표 네 방향과 Home·End, 04번은 탭 버튼과 좌우 화살표에 Home·End 까지, 05번은 통과·거절 버튼과 좌우 화살표, 06번은 뒤로 가기 버튼, 07번은 위아래 화살표, 08번은 단계 버튼과 Escape, 09번은 확대·축소 버튼과 화살표 이동이 각각 같은 동작을 합니다. 값을 다루는 03번에는 role="slider"와 aria-valuenow가 붙어 지금 값을 읽어 주고, 상태가 바뀌는 01·02·03·05·09번의 표시 영역에는 aria-live="polite"를 걸어 화면을 못 보는 사람도 결과를 듣습니다. prefers-reduced-motion: reduce에서는 자동 재생 루프만 끄고 실제 조작과 상태 값은 그대로 남습니다. 포인터 이벤트로 마우스와 터치를 한 벌로 받는 방식은 MDN의 포인터 이벤트 문서에 정리돼 있습니다.
손가락으로 누르는 부품을 더 찾는다면 내비게이션 카테고리와 스크롤 카테고리에 같은 계열이 모여 있습니다.
FAQ
모바일 터치 이벤트 대신 포인터 이벤트만 써도 되나요?
됩니다. 아홉 개 전부 touchstart나 mousedown을 따로 쓰지 않고 pointerdown·pointermove·pointerup 한 벌만 씁니다. 포인터 이벤트는 마우스·손가락·펜을 같은 모양으로 넘겨주기 때문에, 마우스로 끌든 손가락으로 밀든 같은 함수가 그대로 돌아갑니다. 다만 두 손가락을 같이 다루는 09번처럼 포인터를 여러 개 받으려면 e.pointerId를 키로 좌표를 따로 기억해 둬야 합니다. 제스처 한 번으로 공유를 끝내는 버튼 모음은 공유하기 버튼 UI가 실제 복사까지 구현해 둡니다.
드래그하다 손가락이 요소 밖으로 나가면 왜 멈추나요?
포인터를 붙잡지 않았기 때문입니다. 기본 동작에서는 손가락이 요소 경계를 넘는 순간 그 요소가 이벤트를 더 받지 못합니다. 02번처럼 pointerdown에서 setPointerCapture(e.pointerId)를 한 번 부르면 그 포인터가 떨어질 때까지 이벤트가 계속 그 요소로 오고, 빠른 드래그에서도 손잡이가 멈추지 않습니다.
스와이프가 페이지 스크롤과 자꾸 겹칩니다
CSS touch-action으로 어느 방향을 브라우저에 넘길지 정해야 합니다. 가로로만 미는 04번은 touch-action: pan-y라서 세로 스크롤은 그대로 두고 가로만 가져가고, 07·08·09번처럼 방향을 전부 직접 다루는 곳은 touch-action: none으로 브라우저 기본 스크롤을 아예 끕니다. 이 한 줄이 없으면 손가락 하나로 두 가지가 동시에 움직입니다.









