スマホ ジェスチャー操作 9選 — 指で本当に動くUI
スマホ ジェスチャー操作とは、画面に触れた指が動いた距離と速さを読み取って表示を変える操作方法です。9つのうち2つは指を離した瞬間にはじめて結果が分かれ、1つは最後まで押し切らないと何も起きません。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ダブルタップズーム
- 02 スライドロック解除トラック
- 03 回して調節するダイヤル
- 04 スワ이プタブ切替
- 05 スワイプカードデッキ
- 06 エッジスワイプバック
- 07 ラバーバンド境界
- 08 引き上げるボトムシート
- 09 ピンチズーム
並び順は流行ではなく、指が画面に触れている時間の順です。最初は触れて離すだけの2回タップ(01)、次は端まで押し切ってはじめて終わる一方向の押し出し(02)です。続いて指が円を描いて値を変え(03)、画面を1枚まるごと横に送り(04)、押した距離で合格と不合格を分けます(05)。6番目からは指が動き始めた場所そのものが意味を持ちます。画面の端から始めれば戻る操作(06)になり、リストの端でさらに引けば抵抗が生まれ(07)、下から引き上げればシートが段階で止まります(08)。指が2本必要なのは最後の1つ(09)だけです。スマホ スワイプ メニューのような部品も含め、9つすべてが pointerdown・pointermove・pointerup の一組でマウスと指の両方を受け取り、どのジェスチャーにもボタンか矢印キーという代わりの操作が付いています。同じ動きをマウスに移した一覧はマウス カーソル エフェクトにあり、ドラッグが領域の幅を変える側は画面分割 css レイアウトへ続きます。
01ダブルタップズーム
「スマホ ジェスチャー」で何が見えるか
写真を2回続けてタップすると2回目に触れた位置を中心に拡大され、もう一度タップすると元の大きさに戻ります。

<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> 写真を2回タップして拡大</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
拡大の中心はタップ座標をそのまま 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
ハンドルをつかんだ瞬間に 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
角度はダイヤルの中心と指の座標から 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
基準は距離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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
傾きは引いた距離を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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
引いた距離がフレーム幅の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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
そのずれこそが「ここが端だ」という合図で、指を離すと弾むイージングで元の位置に戻ります。
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
投げたかどうかは直前2つのイベント間の速さで測り、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ピンチズーム
どんな動きか
2本の指の座標をそれぞれ覚えておき、2点間の距離の比率が拡大率に、傾きの差が回転角になります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
マウスには2本目の指がないので、ホイールで拡大し、ドラッグで移動できるように別に用意しました。
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幅で測ると、カード1枚が画面の右へ100px以上はみ出しており、ステージを囲む overflow: hidden はその値を減らしてくれませんでした。見えないことと、大きさに数えられないことは別の話です。直し方はデッキ自身に overflow: hidden と固定サイズを与え、カードがデッキの外へ出た瞬間に切り取られるようにすることで、同じアニメーションの変化ピクセル面積が29.6%から9.7%へ下がりました。飛ぶカードがステージの外まで描いていた分が、そっくり消えた値です。
次によく引っかかったのは回転です。03のダイヤルはつまみを rotate で回しますが、その中に入れたスピーカーのアイコンまで一緒に回り、値が大きくなるほど逆さまに立ちました。同じ角度を反対向きに回すアニメーションをアイコンだけに別途かけて、いつでもまっすぐ立つようにしています。ゲージの弧にも似た問題があり、stroke-dasharray に長さを1つだけ与えると円周がその長さより長い時点で繰り返され、弧がもつれました。270度だけ描くには、描く長さ197.92と空ける長さ66.06を並べて書く必要があります。指で引っ張る座標計算が点と線になるとフローチャート デザインのノード接続と同じ式に合流します。これらの修正をすべて反映した9本のソースは、圧縮パスワード 49t2bve3 を入れると開くzipの中にあり、バニラ版とReact版が同じ値を使います。
アクセシビリティ
9つとも、ジェスチャーが唯一の入口にならないようにしています。01はプラス・マイナスボタン、02は右矢印キー、03は4方向の矢印と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
タッチイベント javascript の代わりにポインターイベントだけでも大丈夫ですか
大丈夫です。9つとも touchstart や mousedown を別に登録せず、pointerdown・pointermove・pointerup の一組だけを使っています。ポインターイベントはマウス・指・ペンを同じ形で渡してくれるので、マウスで引いても指で押しても同じ関数がそのまま動きます。ただし09のように複数のポインターを扱うときは、e.pointerId をキーにして座標を別々に覚えておく必要があります。ジェスチャー一つで共有を終えるボタン集はシェアボタン デザインが実際のコピーまで実装しています。
ドラッグ中に指が要素の外へ出ると止まってしまいます
ポインターを押さえていないからです。既定の動作では、指が要素の境界を越えた瞬間にその要素はイベントを受け取らなくなります。02のように pointerdown の中で setPointerCapture(e.pointerId) を一度呼べば、指を離すまでイベントがその要素へ届き続け、素早いドラッグでもハンドルが途中で止まりません。
スワイプがページのスクロールとぶつかります
CSSの touch-action で、どちらの方向をブラウザに任せるかを決めてください。横だけに送る04は touch-action: pan-y にして縦のスクロールはブラウザに残し、横だけを受け取ります。07・08・09のように方向をすべて自分で扱うところは touch-action: none でブラウザ既定のスクロールを完全に止めます。この1行がないと、指1本で2つのことが同時に動きます。









