カード スワイプ ui 9パターン — 物理計算で仕上げる
このカード スワイプ ui には9パターンあります。指を離す速度が02番の基準に届かない瞬間、カードは飛ばずに03番のようにばねで元の位置へ戻る。最後の一枚をめくった先には08番の空のデッキだけが残る。
01 swipe-deck-precision-drag-rotate-lerp
02 swipe-deck-precision-velocity-fling-threshold
03 swipe-deck-precision-elastic-snapback
04 swipe-deck-precision-stack-depth-perspective
05 swipe-deck-precision-stamp-opacity-drag
06 swipe-deck-precision-undo-reverse-interp
07 swipe-deck-precision-keyboard-fallback
08 swipe-deck-precision-empty-state-refill
09 swipe-deck-precision-hero-deck-scene
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ドラッグ距離に補間するカードの回転
- 02 離す速度で分かれるスワイプ判定
- 03 ばねオーバーシュートで戻るカード
- 04 遠近投影で積み重なるカードスタック
- 05 ドラッグ距離でにじむいいね・ノーの判子
- 06 逆補間で戻す最後のカード
- 07 矢印キー・ボタンでも同じ判定を出す代替経路
- 08 ばねで跳ねて現れる空スタックカード
- 09 回転・スプリング・奥行きスタックを一つのデッキに合わせたヒーロー場面
カード スワイプ ui 9パターン — ドラッグ値ひとつが判定をどう変えるか
以下の9つはすべて、ポインタードラッグから取れる二つの数値だけを読み取ります — どれだけ動いたか、そして離した瞬間どれだけ速かったかです。見た目の変化はすべて、その二つの値を別々の計算式に渡した結果です。01番は移動距離を回転角に変え、02番は速度をフリングか復帰かの判定に変えます。07番ではポインターすら使わず、押しっぱなしのキーが同じ判定をゼロから充電します。09番は回転・スプリング・遠近という三つの計算を一つのデッキに重ねます。順番に読むと、同じドラッグ値がだんだん多くの仕事を任されていく様子がわかります。
01ドラッグ距離に補間するカードの回転
カードを横に引くと、移動距離に比例して回転角が滑らかに補間されながら傾きます — 固定角へスナップすることはありません。ROTATE_LERP_RATIO は、ドラッグ1ピクセルあたりどれだけ傾きを足すかを決める定数で、値を上げると短いドラッグでも急な傾きに見え、下げるとデッキから外れる直前までほぼ平らなままになります。

<main class="stage">
<div class="dr is-demo" id="dr">
<p class="dr__caption">ドラッグ距離で補間されるカード回転</p>
<div class="dr__scene" id="drScene">
<div class="dr__card" id="drCard" tabindex="0" role="group" aria-roledescription="スワイプカード">
<p class="dr__label" id="drLabel">朝のルーティン</p>
</div>
<button class="dr__btn dr__btn--nope" id="drNope" type="button" aria-label="いいえ">✕</button>
<button class="dr__btn dr__btn--like" id="drLike" type="button" aria-label="いいね">♥</button>
</div>
<p class="dr__status" id="drStatus" aria-live="polite">待機中</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 드래그 이동 거리(카드 폭 대비 %) 에 회전각이 lerp 되어 카드가 기울어진다.
// 해제 속도가 SWIPE_VELOCITY_THRESHOLD_PX_MS 를 넘으면 날아가고, 못 넘으면 lerp 로 되돌아온다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.dr { display: grid; gap: $sp-2; justify-items: center; width: 100%; --tone: 0; }
.dr__caption { margin: 0; font-size: 12px; font-weight: 600; color: $subject-ink; }
.dr__scene {
position: relative;
width: 220px;
height: 190px;
border-radius: $r-card;
background: $subject-cream;
display: grid;
place-items: center;
}
.dr__card {
position: relative;
width: 160px;
height: 180px;
border-radius: $r-card;
background: linear-gradient(160deg, $subject-blue, $subject-sky);
box-shadow: $shadow-raised;
display: grid;
place-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
}
.dr__card:active { cursor: grabbing; }
.dr__card:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
.dr__label {
margin: 0;
font-size: 14px;
font-weight: 700;
color: $stage-paper;
padding: 0 $sp-3;
text-align: center;
}
.dr__btn {
position: absolute;
bottom: $sp-3;
width: 34px;
height: 34px;
border-radius: $r-pill;
border: 0;
background: $stage-paper;
box-shadow: $shadow-press;
font-size: 15px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.dr__btn--nope { left: $sp-3; color: $error; }
.dr__btn--like { right: $sp-3; color: $subject-blue-deep; }
.dr__btn:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.dr__status { margin: 0; font-size: 11px; color: $subject-ink; font-weight: 600; }
@media (max-width: 340px) {
.dr { gap: $sp-2; }
.dr__scene { width: 150px; height: 108px; }
.dr__card { width: 78px; height: 92px; }
.dr__label { font-size: 10px; padding: 0 $sp-1; }
.dr__btn { width: 22px; height: 22px; font-size: 11px; bottom: $sp-1; }
.dr__btn--nope { left: $sp-1; }
.dr__btn--like { right: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.dr__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);
}
.dr {
display: grid;
gap: 8px;
justify-items: center;
width: 100%;
--tone: 0;
}
.dr__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.dr__scene {
position: relative;
width: 220px;
height: 190px;
border-radius: 16px;
background: #fff7e6;
display: grid;
place-items: center;
}
.dr__card {
position: relative;
width: 160px;
height: 180px;
border-radius: 16px;
background: linear-gradient(160deg, #2f6df6, #6ec8ff);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: grid;
place-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
}
.dr__card:active {
cursor: grabbing;
}
.dr__card:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.dr__label {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #ffffff;
padding: 0 12px;
text-align: center;
}
.dr__btn {
position: absolute;
bottom: 12px;
width: 34px;
height: 34px;
border-radius: 999px;
border: 0;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-size: 15px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.dr__btn--nope {
left: 12px;
color: #c2361a;
}
.dr__btn--like {
right: 12px;
color: #0a4fe8;
}
.dr__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.dr__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.dr {
gap: 8px;
}
.dr__scene {
width: 150px;
height: 108px;
}
.dr__card {
width: 78px;
height: 92px;
}
.dr__label {
font-size: 10px;
padding: 0 4px;
}
.dr__btn {
width: 22px;
height: 22px;
font-size: 11px;
bottom: 4px;
}
.dr__btn--nope {
left: 4px;
}
.dr__btn--like {
right: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.dr__card {
transition: none;
}
}(function () {
"use strict";
// 값 하나: 드래그 이동 거리(카드 폭 대비 %)에 회전각이 lerp 되어 기울어진다.
var ROTATE_LERP_RATIO = 0.55;
var ROTATE_MAX_DEG = 26;
// 변형 3: 01 도 해제 판정을 가진다 — 속도가 이 값을 넘으면 날아가고 못 넘으면 되돌아온다.
var SWIPE_VELOCITY_THRESHOLD_PX_MS = 0.3;
var SETTLE_RATE = 0.24;
var EXIT_PCT = 165;
var CYCLE_S = 2;
var root = document.getElementById("dr");
var card = document.getElementById("drCard");
var label = document.getElementById("drLabel");
var status = document.getElementById("drStatus");
var btnLike = document.getElementById("drLike");
var btnNope = document.getElementById("drNope");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var CARDS = ["朝のルーティン", "夕方の散歩", "週末のカフェ", "宅トレ記録", "読書リスト", "旅行メモ"];
var rand = seedRand(11);
var idx = Math.floor(rand() * CARDS.length);
var dragging = false;
var startX = 0, lastX = 0, lastT = 0, vx = 0;
var pctX = 0;
var phase = "idle"; // idle | exit | settle
var exitDir = 1, exitPct = 0;
function setCard() { label.textContent = CARDS[idx % CARDS.length]; }
setCard();
function render() {
var rot = clamp(pctX * ROTATE_LERP_RATIO, -ROTATE_MAX_DEG, ROTATE_MAX_DEG);
card.style.transform = "translateX(" + pctX.toFixed(2) + "%) rotate(" + rot.toFixed(2) + "deg)";
root.style.setProperty("--tone", clamp(Math.abs(pctX) / 42, 0, 1).toFixed(2));
}
function nextCard() {
idx += 1; setCard();
pctX = 0; phase = "idle";
card.style.transform = "translateX(0%) rotate(0deg)";
root.style.setProperty("--tone", "0");
status.textContent = "待機中";
}
function judge(velocity, dir) {
root.classList.remove("is-demo");
var flung = Math.abs(velocity) >= SWIPE_VELOCITY_THRESHOLD_PX_MS;
if (reduce) {
if (flung) { status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み"; nextCard(); }
else { pctX = 0; render(); status.textContent = "元の位置に戻った"; phase = "idle"; }
return;
}
if (flung) {
phase = "exit"; exitDir = dir; exitPct = pctX;
status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み";
} else {
phase = "settle";
status.textContent = "元の位置に戻った";
}
}
function startDrag(clientX) {
root.classList.remove("is-demo");
dragging = true; phase = "idle";
startX = clientX; lastX = clientX; lastT = performance.now(); vx = 0;
}
function moveDrag(clientX) {
if (!dragging) return;
var rect = card.getBoundingClientRect();
var now = performance.now();
var dt = Math.max(now - lastT, 1);
vx = (clientX - lastX) / dt;
lastX = clientX; lastT = now;
pctX = ((clientX - startX) / rect.width) * 100;
render();
}
function endDrag() {
if (!dragging) return;
dragging = false;
judge(vx, pctX >= 0 ? 1 : -1);
}
card.addEventListener("pointerdown", function (e) { card.setPointerCapture(e.pointerId); startDrag(e.clientX); });
card.addEventListener("pointermove", function (e) { moveDrag(e.clientX); });
card.addEventListener("pointerup", endDrag);
card.addEventListener("pointercancel", endDrag);
function keyVerdict(dir) {
root.classList.remove("is-demo");
pctX = dir * 60;
render();
judge(SWIPE_VELOCITY_THRESHOLD_PX_MS + 0.2, dir);
}
card.addEventListener("keydown", function (e) {
if (e.key === "ArrowRight") { e.preventDefault(); keyVerdict(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); keyVerdict(-1); }
});
btnLike.addEventListener("click", function () { keyVerdict(1); });
btnNope.addEventListener("click", function () { keyVerdict(-1); });
function step(dt) {
if (phase === "exit") {
exitPct += exitDir * dt * 0.46;
pctX = exitPct;
render();
if (Math.abs(exitPct) > EXIT_PCT) nextCard();
} else if (phase === "settle") {
pctX += (0 - pctX) * SETTLE_RATE;
render();
if (Math.abs(pctX) < 0.3) { pctX = 0; phase = "idle"; render(); }
}
}
// .is-demo 자동 시연: 실제 pointer 없이 pctX·judge() 를 같은 경로로 호출한다.
var start = null, lastNow = 0, prevT = 0, synthPhase = "wait";
function synth(t) {
if (t > 0.5 && t < 0.85 && synthPhase === "wait") synthPhase = "dragLike";
if (synthPhase === "dragLike") {
var p = clamp((t - 0.5) / 0.35, 0, 1);
pctX = p * 46; render();
if (t >= 0.85) { synthPhase = "afterLike"; judge(SWIPE_VELOCITY_THRESHOLD_PX_MS + 0.3, 1); }
}
if (t > 1.25 && t < 1.55 && synthPhase === "afterLike") synthPhase = "dragWeak";
if (synthPhase === "dragWeak") {
var q = clamp((t - 1.25) / 0.3, 0, 1);
pctX = q * 9; render();
if (t >= 1.55) { synthPhase = "done"; judge(0.05, 1); }
}
}
function loop(now) {
if (start === null) { start = now; lastNow = now; lastT = now; }
var dt = Math.min(now - lastNow, 48);
lastNow = now;
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhase = "wait";
prevT = t;
if (!reduce && root.classList.contains("is-demo")) synth(t);
if (!reduce) step(dt);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// DragRotateLerpSwipeCard — vanilla 판(run/422/demo/01-swipe-deck-precision-drag-rotate-lerp/index.html) 의 React 포트.
// 값 하나: 놓는 순간 속도(px/ms) 가 SWIPE_VELOCITY_THRESHOLD_PX_MS 를 넘는지로 스와이프를 판정한다.
// 드래그 중 각도는 카드 폭 대비 이동 비율(pctX)에 ROTATE_LERP_RATIO 를 곱해 정한다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./DragRotateLerpSwipeCard.module.scss";
type Box<T> = { current: T };
const SWIPE_VELOCITY_THRESHOLD_PX_MS = 0.3;
const ROTATE_LERP_RATIO = 0.55;
const ROTATE_MAX_DEG = 26;
const SETTLE_RATE = 0.24;
const EXIT_PCT = 165;
const CARD_WIDTH = 160;
const CYCLE_S = 2;
const CARDS = ["아침 루틴", "저녁 산책", "주말 카페", "홈트 기록", "독서 목록", "여행 메모"];
function seedRand(seed: number) {
let s = seed >>> 0;
return function (): number {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function DragRotateLerpSwipeCard() {
const cardRef = useRef<HTMLDivElement | null>(null);
const pctXRef = useRef<number>(0) as Box<number>;
const vxRef = useRef<number>(0) as Box<number>;
const phaseRef = useRef<string>("idle") as Box<string>;
const exitDirRef = useRef<number>(1) as Box<number>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const startXRef = useRef<number>(0) as Box<number>;
const lastXRef = useRef<number>(0) as Box<number>;
const lastTRef = useRef<number>(0) as Box<number>;
const idxRef = useRef<number>(Math.floor(seedRand(11)() * CARDS.length)) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [label, setLabel] = useState(CARDS[idxRef.current % CARDS.length]);
const [status, setStatus] = useState("대기 중");
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(): void {
const card = cardRef.current;
if (!card) return;
const rot = clamp(pctXRef.current * ROTATE_LERP_RATIO, -ROTATE_MAX_DEG, ROTATE_MAX_DEG);
const xPx = (pctXRef.current / 100) * CARD_WIDTH;
card.style.transform = "translateX(" + xPx.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
}
function nextCard(): void {
idxRef.current += 1;
setLabel(CARDS[idxRef.current % CARDS.length]);
pctXRef.current = 0; vxRef.current = 0; phaseRef.current = "idle";
render();
setStatus("대기 중");
}
function judge(velocity: number, dir: number): void {
setDemo(false);
const flung = Math.abs(velocity) >= SWIPE_VELOCITY_THRESHOLD_PX_MS;
if (reduce) {
if (flung) { setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨"); nextCard(); }
else { pctXRef.current = 0; render(); setStatus("제자리로 돌아옴"); phaseRef.current = "idle"; }
return;
}
if (flung) {
phaseRef.current = "exit"; exitDirRef.current = dir;
setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨");
} else {
phaseRef.current = "settle";
setStatus("제자리로 돌아옴");
}
}
function startDrag(clientX: number): void {
setDemo(false);
draggingRef.current = true; phaseRef.current = "idle";
startXRef.current = clientX; lastXRef.current = clientX; lastTRef.current = performance.now(); vxRef.current = 0;
}
function moveDrag(clientX: number): void {
if (!draggingRef.current) return;
const now = performance.now();
const dt = Math.max(now - lastTRef.current, 1);
vxRef.current = (clientX - lastXRef.current) / dt;
lastXRef.current = clientX; lastTRef.current = now;
pctXRef.current = ((clientX - startXRef.current) / CARD_WIDTH) * 100;
render();
}
function endDrag(): void {
if (!draggingRef.current) return;
draggingRef.current = false;
judge(vxRef.current, pctXRef.current >= 0 ? 1 : -1);
}
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>): void {
e.currentTarget.setPointerCapture(e.pointerId);
startDrag(e.clientX);
}
function onPointerMove(e: ReactPointerEvent<HTMLDivElement>): void { moveDrag(e.clientX); }
function onPointerUp(): void { endDrag(); }
function keyVerdict(dir: number): void {
setDemo(false);
pctXRef.current = dir * 40; render();
judge(dir * (SWIPE_VELOCITY_THRESHOLD_PX_MS + 0.2), dir);
}
function onKeyDown(e: any): void {
if (e.key === "ArrowRight") { e.preventDefault(); keyVerdict(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); keyVerdict(-1); }
}
function step(dt: number): void {
if (phaseRef.current === "exit") {
pctXRef.current += exitDirRef.current * Math.max(Math.abs(vxRef.current) * 100, SWIPE_VELOCITY_THRESHOLD_PX_MS * 140) * (dt / 6);
render();
if (Math.abs(pctXRef.current) > EXIT_PCT) nextCard();
} else if (phaseRef.current === "settle") {
pctXRef.current += (0 - pctXRef.current) * SETTLE_RATE;
render();
if (Math.abs(pctXRef.current) < 0.6) { pctXRef.current = 0; phaseRef.current = "idle"; render(); }
}
}
function synth(t: number, synthPhaseRef: Box<string>): void {
if (t > 0.5 && t < 0.85 && synthPhaseRef.current === "wait") synthPhaseRef.current = "dragLike";
if (synthPhaseRef.current === "dragLike") {
const p = clamp((t - 0.5) / 0.35, 0, 1);
pctXRef.current = p * 70; render();
if (t >= 0.85) { synthPhaseRef.current = "afterLike"; judge(SWIPE_VELOCITY_THRESHOLD_PX_MS + 0.35, 1); }
}
if (t > 1.25 && t < 1.55 && synthPhaseRef.current === "afterLike") synthPhaseRef.current = "dragWeak";
if (synthPhaseRef.current === "dragWeak") {
const q = clamp((t - 1.25) / 0.3, 0, 1);
pctXRef.current = -q * 30; render();
if (t >= 1.55) { synthPhaseRef.current = "done"; judge(0.05, -1); }
}
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0, lastNow = 0, prevT = 0;
const synthPhaseRef: Box<string> = { current: "wait" };
function loop(now: number): void {
if (start === 0) { start = now; lastNow = now; }
const dt = Math.min(now - lastNow, 48);
lastNow = now;
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhaseRef.current = "wait";
prevT = t;
if (!reduce && demo) synth(t, synthPhaseRef);
if (!reduce) step(dt);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
return (
<main className={styles.stage}>
<div className={styles.dr}>
<p className={styles.caption}>드래그 비율에 회전을 곱하는 카드</p>
<div className={styles.scene}>
<div
className={styles.card}
ref={cardRef}
tabIndex={0}
role="group"
aria-roledescription="스와이프 카드"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
>
<p className={styles.label}>{label}</p>
</div>
<button className={styles.btnNope} type="button" aria-label="싫어요" onClick={() => keyVerdict(-1)}>✕</button>
<button className={styles.btnLike} type="button" aria-label="좋아요" onClick={() => keyVerdict(1)}>♥</button>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
</div>
</main>
);
}01 · ドラッグ距離に補間するカードの回転 — 計測 累積面積 49.342% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function render() {
var rot = clamp(pctX * ROTATE_LERP_RATIO, -ROTATE_MAX_DEG, ROTATE_MAX_DEG);
card.style.transform = "translateX(" + pctX.toFixed(2) + "%) rotate(" + rot.toFixed(2) + "deg)";
root.style.setProperty("--tone", clamp(Math.abs(pctX) / 42, 0, 1).toFixed(2));
}
function nextCard() {
idx += 1; setCard();
pctX = 0; phase = "idle";
card.style.transform = "translateX(0%) rotate(0deg)";
root.style.setProperty("--tone", "0");
status.textContent = "待機中";
}
function judge(velocity, dir) {
root.classList.remove("is-demo");
var flung = Math.abs(velocity) >= SWIPE_VELOCITY_THRESHOLD_PX_MS;
計測ではこの回転カードが画面の49.3%を占め、平均強度は120.0、動きの検出は6/23フレームです。
02離す速度で分かれるスワイプ判定
離す瞬間の速度が基準値に届かないと、カードは飛んでいきません — それまでどれだけ引っ張っていたかは関係ありません。FLING_VELOCITY_MIN は、judge() が離した瞬間の速度と比較してフリングか復帰かを決める唯一の数値です。

<main class="stage">
<div class="vf is-demo" id="vf">
<p class="vf__caption">離す速度で分かれるスワイプ判定</p>
<div class="vf__scene" id="vfScene">
<div class="vf__card" id="vfCard" tabindex="0" role="group" aria-roledescription="スワイプカード">
<p class="vf__label" id="vfLabel">夕方の散歩</p>
<p class="vf__hint" id="vfHint">速度 0.00 px/ms</p>
</div>
<button class="vf__btn vf__btn--nope" id="vfNope" type="button" aria-label="いいえ">✕</button>
<button class="vf__btn vf__btn--like" id="vfLike" type="button" aria-label="いいね">♥</button>
</div>
<p class="vf__status" id="vfStatus" aria-live="polite">待機中</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 놓는 순간 속도(px/ms)가 FLING_VELOCITY_MIN 을 넘지 못하면 카드는 날아가지 않는다.
// 드래그 중에는 SWIPE_ROTATE_LERP_RATIO(px 당 도) 만큼 살짝 기운다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-orange-deep;
@include stage($stage-yellow);
.vf { display: grid; gap: $sp-2; justify-items: center; width: 100%; }
.vf__caption { margin: 0; font-size: 12px; font-weight: 600; color: $subject-ink; }
.vf__scene {
position: relative;
width: 220px;
height: 190px;
border-radius: $r-card;
background: $subject-cream;
display: grid;
place-items: center;
overflow: hidden;
}
.vf__card {
position: relative;
width: 160px;
height: 180px;
border-radius: $r-card;
background: linear-gradient(160deg, $stage-orange, #e6d6cb);
box-shadow: $shadow-raised;
display: grid;
gap: $sp-2;
place-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
}
.vf__card:active { cursor: grabbing; }
.vf__card:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
.vf__label { margin: 0; font-size: 14px; font-weight: 700; color: $subject-ink; padding: 0 $sp-3; text-align: center; }
.vf__hint { margin: 0; font-size: 10px; color: $subject-ink; opacity: .8; }
.vf__btn {
position: absolute;
bottom: $sp-3;
width: 34px;
height: 34px;
border-radius: $r-pill;
border: 0;
background: $stage-paper;
box-shadow: $shadow-press;
font-size: 15px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.vf__btn--nope { left: $sp-3; color: $error; }
.vf__btn--like { right: $sp-3; color: $subject-orange-deep; }
.vf__btn:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.vf__status { margin: 0; font-size: 11px; color: $subject-ink; font-weight: 600; }
@media (max-width: 340px) {
.vf { gap: $sp-2; }
.vf__scene { width: 150px; height: 108px; }
.vf__card { width: 78px; height: 92px; gap: $sp-1; }
.vf__label { font-size: 10px; padding: 0 $sp-1; }
.vf__hint { display: none; }
.vf__btn { width: 22px; height: 22px; font-size: 11px; bottom: $sp-1; }
.vf__btn--nope { left: $sp-1; }
.vf__btn--like { right: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.vf__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vf {
display: grid;
gap: 8px;
justify-items: center;
width: 100%;
}
.vf__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.vf__scene {
position: relative;
width: 220px;
height: 190px;
border-radius: 16px;
background: #fff7e6;
display: grid;
place-items: center;
overflow: hidden;
}
.vf__card {
position: relative;
width: 160px;
height: 180px;
border-radius: 16px;
background: linear-gradient(160deg, #ff4d1f, #e6d6cb);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: grid;
gap: 8px;
place-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
}
.vf__card:active {
cursor: grabbing;
}
.vf__card:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.vf__label {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #17141a;
padding: 0 12px;
text-align: center;
}
.vf__hint {
margin: 0;
font-size: 10px;
color: #17141a;
opacity: 0.8;
}
.vf__btn {
position: absolute;
bottom: 12px;
width: 34px;
height: 34px;
border-radius: 999px;
border: 0;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-size: 15px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.vf__btn--nope {
left: 12px;
color: #c2361a;
}
.vf__btn--like {
right: 12px;
color: #c93000;
}
.vf__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.vf__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.vf {
gap: 8px;
}
.vf__scene {
width: 150px;
height: 108px;
}
.vf__card {
width: 78px;
height: 92px;
gap: 4px;
}
.vf__label {
font-size: 10px;
padding: 0 4px;
}
.vf__hint {
display: none;
}
.vf__btn {
width: 22px;
height: 22px;
font-size: 11px;
bottom: 4px;
}
.vf__btn--nope {
left: 4px;
}
.vf__btn--like {
right: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.vf__card {
transition: none;
}
}(function () {
"use strict";
// 변형 3: 02 폴더에 SWIPE_ROTATE_LERP_RATIO 를 실재시킨다 — 드래그 중 카드가 이 비율로 살짝 기운다.
var SWIPE_ROTATE_LERP_RATIO = 0.05; // deg per px
var ROTATE_MAX_DEG = 14;
// 값 하나: 놓는 순간의 速度(px/ms)가 이 값을 넘지 못하면 카드는 날아가지 않는다.
var FLING_VELOCITY_MIN = 0.42;
var INERTIA_DECAY = 0.85;
var SETTLE_RATE = 0.2;
var EXIT_PX = 320;
var CYCLE_S = 2;
var root = document.getElementById("vf");
var card = document.getElementById("vfCard");
var label = document.getElementById("vfLabel");
var hint = document.getElementById("vfHint");
var status = document.getElementById("vfStatus");
var btnLike = document.getElementById("vfLike");
var btnNope = document.getElementById("vfNope");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var CARDS = ["夕方の散歩", "週末のカフェ", "宅トレ記録", "読書リスト", "旅行メモ", "朝のルーティン"];
var rand = seedRand(23);
var idx = Math.floor(rand() * CARDS.length);
var dragging = false;
var startX = 0, lastX = 0, lastT = 0, vx = 0;
var x = 0, phase = "idle", exitDir = 1;
function setCard() { label.textContent = CARDS[idx % CARDS.length]; }
setCard();
function render() {
var rot = clamp(x * SWIPE_ROTATE_LERP_RATIO, -ROTATE_MAX_DEG, ROTATE_MAX_DEG);
card.style.transform = "translateX(" + x.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
}
function nextCard() {
idx += 1; setCard();
x = 0; vx = 0; phase = "idle";
render();
status.textContent = "待機中";
}
function judge(velocity, dir) {
root.classList.remove("is-demo");
hint.textContent = "速度 " + Math.abs(velocity).toFixed(2) + " px/ms";
var flung = Math.abs(velocity) >= FLING_VELOCITY_MIN;
if (reduce) {
if (flung) { status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み"; nextCard(); }
else { x = 0; render(); status.textContent = "基準速度未満 — 元に戻った"; phase = "idle"; }
return;
}
if (flung) {
phase = "exit"; exitDir = dir;
status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み";
} else {
phase = "settle";
status.textContent = "基準速度未満 — 元に戻った";
}
}
function startDrag(clientX) {
root.classList.remove("is-demo");
dragging = true;
startX = clientX; lastX = clientX; lastT = performance.now(); vx = 0;
}
function moveDrag(clientX) {
if (!dragging) return;
var now = performance.now();
var dt = Math.max(now - lastT, 1);
vx = (clientX - lastX) / dt;
lastX = clientX; lastT = now;
x = clientX - startX;
render();
}
function endDrag() {
if (!dragging) return;
dragging = false;
judge(vx, x >= 0 ? 1 : -1);
}
card.addEventListener("pointerdown", function (e) { card.setPointerCapture(e.pointerId); startDrag(e.clientX); });
card.addEventListener("pointermove", function (e) { moveDrag(e.clientX); });
card.addEventListener("pointerup", endDrag);
card.addEventListener("pointercancel", endDrag);
function keyVerdict(dir) {
root.classList.remove("is-demo");
x = dir * 40; render();
judge(dir * (FLING_VELOCITY_MIN + 0.15), dir);
}
card.addEventListener("keydown", function (e) {
if (e.key === "ArrowRight") { e.preventDefault(); keyVerdict(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); keyVerdict(-1); }
});
btnLike.addEventListener("click", function () { keyVerdict(1); });
btnNope.addEventListener("click", function () { keyVerdict(-1); });
function step(dt) {
if (phase === "exit") {
var speed = Math.max(Math.abs(vx), FLING_VELOCITY_MIN * 1.4);
x += exitDir * speed * dt;
render();
if (Math.abs(x) > EXIT_PX) nextCard();
} else if (phase === "settle") {
x += vx * dt;
vx *= INERTIA_DECAY;
x += (0 - x) * SETTLE_RATE;
render();
if (Math.abs(x) < 0.5 && Math.abs(vx) < 0.01) { x = 0; phase = "idle"; render(); }
}
}
// .is-demo 자동 시연: 실제 pointer 없이 x·judge() 를 같은 경로로 호출한다.
var start = null, lastNow = 0, prevT = 0, synthPhase = "wait";
function synth(t) {
if (t > 0.45 && t < 0.7 && synthPhase === "wait") synthPhase = "weak";
if (synthPhase === "weak") {
var p = clamp((t - 0.45) / 0.25, 0, 1);
x = p * 30; render();
if (t >= 0.7) { synthPhase = "afterWeak"; judge(0.15, 1); }
}
if (t > 1.05 && t < 1.35 && synthPhase === "afterWeak") synthPhase = "strong";
if (synthPhase === "strong") {
var q = clamp((t - 1.05) / 0.3, 0, 1);
x = q * 130; render();
if (t >= 1.35) { synthPhase = "done"; judge(FLING_VELOCITY_MIN + 0.5, 1); }
}
}
function loop(now) {
if (start === null) { start = now; lastNow = now; lastT = now; }
var dt = Math.min(now - lastNow, 48);
lastNow = now;
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhase = "wait";
prevT = t;
if (!reduce && root.classList.contains("is-demo")) synth(t);
if (!reduce) step(dt);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// VelocityFlingThresholdSwipeCard — vanilla 판(run/422/demo/02-swipe-deck-precision-velocity-fling-threshold/index.html) 의 React 포트.
// 값 하나: 놓는 순간 속도(px/ms) 가 FLING_VELOCITY_MIN 을 넘지 못하면 카드는 날아가지 않는다.
// 드래그 중에는 SWIPE_ROTATE_LERP_RATIO(px 당 도) 만큼 살짝 기운다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./VelocityFlingThresholdSwipeCard.module.scss";
type Box<T> = { current: T };
const SWIPE_ROTATE_LERP_RATIO = 0.05;
const ROTATE_MAX_DEG = 14;
const FLING_VELOCITY_MIN = 0.42;
const INERTIA_DECAY = 0.85;
const SETTLE_RATE = 0.2;
const EXIT_PX = 320;
const CYCLE_S = 2;
const CARDS = ["저녁 산책", "주말 카페", "홈트 기록", "독서 목록", "여행 메모", "아침 루틴"];
function seedRand(seed: number) {
let s = seed >>> 0;
return function (): number {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function VelocityFlingThresholdSwipeCard() {
const cardRef = useRef<HTMLDivElement | null>(null);
const xRef = useRef<number>(0) as Box<number>;
const vxRef = useRef<number>(0) as Box<number>;
const phaseRef = useRef<string>("idle") as Box<string>;
const exitDirRef = useRef<number>(1) as Box<number>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const startXRef = useRef<number>(0) as Box<number>;
const lastXRef = useRef<number>(0) as Box<number>;
const lastTRef = useRef<number>(0) as Box<number>;
const idxRef = useRef<number>(Math.floor(seedRand(23)() * CARDS.length)) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [label, setLabel] = useState(CARDS[idxRef.current % CARDS.length]);
const [hint, setHint] = useState("속도 0.00 px/ms");
const [status, setStatus] = useState("대기 중");
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(): void {
const card = cardRef.current;
if (!card) return;
const rot = clamp(xRef.current * SWIPE_ROTATE_LERP_RATIO, -ROTATE_MAX_DEG, ROTATE_MAX_DEG);
card.style.transform = "translateX(" + xRef.current.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
}
function nextCard(): void {
idxRef.current += 1;
setLabel(CARDS[idxRef.current % CARDS.length]);
xRef.current = 0; vxRef.current = 0; phaseRef.current = "idle";
render();
setStatus("대기 중");
}
function judge(velocity: number, dir: number): void {
setDemo(false);
setHint("속도 " + Math.abs(velocity).toFixed(2) + " px/ms");
const flung = Math.abs(velocity) >= FLING_VELOCITY_MIN;
if (reduce) {
if (flung) { setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨"); nextCard(); }
else { xRef.current = 0; render(); setStatus("기준 속도 미달 — 되돌아옴"); phaseRef.current = "idle"; }
return;
}
if (flung) {
phaseRef.current = "exit"; exitDirRef.current = dir;
setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨");
} else {
phaseRef.current = "settle";
setStatus("기준 속도 미달 — 되돌아옴");
}
}
function startDrag(clientX: number): void {
setDemo(false);
draggingRef.current = true;
startXRef.current = clientX; lastXRef.current = clientX; lastTRef.current = performance.now(); vxRef.current = 0;
}
function moveDrag(clientX: number): void {
if (!draggingRef.current) return;
const now = performance.now();
const dt = Math.max(now - lastTRef.current, 1);
vxRef.current = (clientX - lastXRef.current) / dt;
lastXRef.current = clientX; lastTRef.current = now;
xRef.current = clientX - startXRef.current;
render();
}
function endDrag(): void {
if (!draggingRef.current) return;
draggingRef.current = false;
judge(vxRef.current, xRef.current >= 0 ? 1 : -1);
}
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>): void {
e.currentTarget.setPointerCapture(e.pointerId);
startDrag(e.clientX);
}
function onPointerMove(e: ReactPointerEvent<HTMLDivElement>): void { moveDrag(e.clientX); }
function onPointerUp(): void { endDrag(); }
function keyVerdict(dir: number): void {
setDemo(false);
xRef.current = dir * 40; render();
judge(dir * (FLING_VELOCITY_MIN + 0.15), dir);
}
function onKeyDown(e: any): void {
if (e.key === "ArrowRight") { e.preventDefault(); keyVerdict(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); keyVerdict(-1); }
}
function step(dt: number): void {
if (phaseRef.current === "exit") {
const speed = Math.max(Math.abs(vxRef.current), FLING_VELOCITY_MIN * 1.4);
xRef.current += exitDirRef.current * speed * dt;
render();
if (Math.abs(xRef.current) > EXIT_PX) nextCard();
} else if (phaseRef.current === "settle") {
xRef.current += vxRef.current * dt;
vxRef.current *= INERTIA_DECAY;
xRef.current += (0 - xRef.current) * SETTLE_RATE;
render();
if (Math.abs(xRef.current) < 0.5 && Math.abs(vxRef.current) < 0.01) { xRef.current = 0; phaseRef.current = "idle"; render(); }
}
}
function synth(t: number, synthPhaseRef: Box<string>): void {
if (t > 0.45 && t < 0.7 && synthPhaseRef.current === "wait") synthPhaseRef.current = "weak";
if (synthPhaseRef.current === "weak") {
const p = clamp((t - 0.45) / 0.25, 0, 1);
xRef.current = p * 30; render();
if (t >= 0.7) { synthPhaseRef.current = "afterWeak"; judge(0.15, 1); }
}
if (t > 1.05 && t < 1.35 && synthPhaseRef.current === "afterWeak") synthPhaseRef.current = "strong";
if (synthPhaseRef.current === "strong") {
const q = clamp((t - 1.05) / 0.3, 0, 1);
xRef.current = q * 130; render();
if (t >= 1.35) { synthPhaseRef.current = "done"; judge(FLING_VELOCITY_MIN + 0.5, 1); }
}
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0, lastNow = 0, prevT = 0;
const synthPhaseRef: Box<string> = { current: "wait" };
function loop(now: number): void {
if (start === 0) { start = now; lastNow = now; }
const dt = Math.min(now - lastNow, 48);
lastNow = now;
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhaseRef.current = "wait";
prevT = t;
if (!reduce && demo) synth(t, synthPhaseRef);
if (!reduce) step(dt);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
return (
<main className={styles.stage}>
<div className={styles.vf}>
<p className={styles.caption}>놓는 속도로 갈리는 스와이프 판정</p>
<div className={styles.scene}>
<div
className={styles.card}
ref={cardRef}
tabIndex={0}
role="group"
aria-roledescription="스와이프 카드"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
>
<p className={styles.label}>{label}</p>
<p className={styles.hint}>{hint}</p>
</div>
<button className={styles.btnNope} type="button" aria-label="싫어요" onClick={() => keyVerdict(-1)}>✕</button>
<button className={styles.btnLike} type="button" aria-label="좋아요" onClick={() => keyVerdict(1)}>♥</button>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
</div>
</main>
);
}02 · 離す速度で分かれるスワイプ判定 — 計測 累積面積 8.493% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function judge(velocity, dir) {
root.classList.remove("is-demo");
hint.textContent = "速度 " + Math.abs(velocity).toFixed(2) + " px/ms";
var flung = Math.abs(velocity) >= FLING_VELOCITY_MIN;
if (reduce) {
if (flung) { status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み"; nextCard(); }
else { x = 0; render(); status.textContent = "基準速度未満 — 元に戻った"; phase = "idle"; }
return;
}
if (flung) {
phase = "exit"; exitDir = dir;
status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み";
} else {
phase = "settle";
status.textContent = "基準速度未満 — 元に戻った";
}
}
計測ではこの判定切り替えが画面の8.5%とやや控えめで、平均強度は76.5、動きの検出は6/23フレームです。
03ばねオーバーシュートで戻るカード
基準速度に届かないと、カードはただ元へ戻るのではなく、いったん中心を通り越してからばねで揺れ戻り、そこに落ち着きます。integrate() は毎フレーム SPRING_STIFFNESS と SPRING_DAMPING の組をカードの位置に対して計算しており、この二つの定数が戻り方を弾力のある動きにするか死んだ動きにするかを決めます。

<main class="stage">
<div class="es is-demo" id="es">
<p class="es__caption">スプリングオーバーシュートで戻るカード</p>
<div class="es__scene" id="esScene">
<div class="es__peek" id="esPeek" aria-hidden="true"></div>
<div class="es__card" id="esCard" tabindex="0" role="group" aria-roledescription="スワイプカード">
<p class="es__label" id="esLabel">週末のカフェ</p>
</div>
<button class="es__btn es__btn--nope" id="esNope" type="button" aria-label="いいえ">✕</button>
<button class="es__btn es__btn--like" id="esLike" type="button" aria-label="いいね">♥</button>
</div>
<p class="es__status" id="esStatus" aria-live="polite">待機中</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 해제 속도가 SPRING_FLING_VELOCITY_MIN 에 못 미치면 스프링(강성·감쇠)을 적분해
// 오버슛하며 원래 자리로 되돌아온다. 뒤 카드는 SWIPE_STACK_DEPTH_SCALE 만큼 줄어 비친다.
$duration: $dur-loop;
$easing: $ease-elastic;
$color: $subject-ink;
@include stage($stage-ink);
.es { display: grid; gap: $sp-2; justify-items: center; width: 100%; }
.es__caption { margin: 0; font-size: 12px; font-weight: 600; color: $subject-cream; }
.es__scene {
position: relative;
width: 220px;
height: 190px;
display: grid;
place-items: center;
}
.es__peek {
position: absolute;
width: 160px;
height: 180px;
border-radius: $r-card;
background: linear-gradient(160deg, $subject-lilac, $subject-blue);
opacity: .7;
}
.es__card {
position: relative;
width: 160px;
height: 180px;
border-radius: $r-card;
background: linear-gradient(160deg, $subject-mint, $subject-sky);
box-shadow: $shadow-raised;
display: grid;
place-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
}
.es__card:active { cursor: grabbing; }
.es__card:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
.es__label { margin: 0; font-size: 14px; font-weight: 700; color: $subject-ink; padding: 0 $sp-3; text-align: center; }
.es__btn {
position: absolute;
bottom: $sp-3;
width: 34px;
height: 34px;
border-radius: $r-pill;
border: 0;
background: $stage-paper;
box-shadow: $shadow-press;
font-size: 15px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
z-index: 2;
}
.es__btn--nope { left: $sp-3; color: $error; }
.es__btn--like { right: $sp-3; color: $subject-blue-deep; }
.es__btn:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.es__status { margin: 0; font-size: 11px; color: $subject-cream; font-weight: 600; }
@media (max-width: 340px) {
.es { gap: $sp-2; }
.es__scene { width: 150px; height: 108px; }
.es__peek { width: 78px; height: 92px; }
.es__card { width: 78px; height: 92px; }
.es__label { font-size: 10px; padding: 0 $sp-1; }
.es__btn { width: 22px; height: 22px; font-size: 11px; bottom: $sp-1; }
.es__btn--nope { left: $sp-1; }
.es__btn--like { right: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.es__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.es {
display: grid;
gap: 8px;
justify-items: center;
width: 100%;
}
.es__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #fff7e6;
}
.es__scene {
position: relative;
width: 220px;
height: 190px;
display: grid;
place-items: center;
}
.es__peek {
position: absolute;
width: 160px;
height: 180px;
border-radius: 16px;
background: linear-gradient(160deg, #b9a0ff, #2f6df6);
opacity: 0.7;
}
.es__card {
position: relative;
width: 160px;
height: 180px;
border-radius: 16px;
background: linear-gradient(160deg, #4cd4a6, #6ec8ff);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: grid;
place-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
}
.es__card:active {
cursor: grabbing;
}
.es__card:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.es__label {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #17141a;
padding: 0 12px;
text-align: center;
}
.es__btn {
position: absolute;
bottom: 12px;
width: 34px;
height: 34px;
border-radius: 999px;
border: 0;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-size: 15px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
z-index: 2;
}
.es__btn--nope {
left: 12px;
color: #c2361a;
}
.es__btn--like {
right: 12px;
color: #0a4fe8;
}
.es__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.es__status {
margin: 0;
font-size: 11px;
color: #fff7e6;
font-weight: 600;
}
@media (max-width: 340px) {
.es {
gap: 8px;
}
.es__scene {
width: 150px;
height: 108px;
}
.es__peek {
width: 78px;
height: 92px;
}
.es__card {
width: 78px;
height: 92px;
}
.es__label {
font-size: 10px;
padding: 0 4px;
}
.es__btn {
width: 22px;
height: 22px;
font-size: 11px;
bottom: 4px;
}
.es__btn--nope {
left: 4px;
}
.es__btn--like {
right: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.es__card {
transition: none;
}
}(function () {
"use strict";
// 변형 3: 03 폴더에 SWIPE_STACK_DEPTH_SCALE 를 실재시킨다 — 뒤 카드 한 장을 이 비율만큼 줄여 살짝 비친다.
var SWIPE_STACK_DEPTH_SCALE = 0.04;
// 값 하나: 해제 속도가 이 값에 못 미치면 스프링(강성·감쇠)을 적분해 오버슛하며 되돌아온다.
var SPRING_FLING_VELOCITY_MIN = 0.5;
var SPRING_STIFFNESS = 120;
var SPRING_DAMPING = 14;
var EXIT_PX = 320;
var CYCLE_S = 2;
var root = document.getElementById("es");
var card = document.getElementById("esCard");
var peek = document.getElementById("esPeek");
var label = document.getElementById("esLabel");
var status = document.getElementById("esStatus");
var btnLike = document.getElementById("esLike");
var btnNope = document.getElementById("esNope");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
peek.style.transform = "translateY(" + (SWIPE_STACK_DEPTH_SCALE * 100).toFixed(1) + "%) scale(" + (1 - SWIPE_STACK_DEPTH_SCALE).toFixed(3) + ")";
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var CARDS = ["週末のカフェ", "宅トレ記録", "読書リスト", "旅行メモ", "朝のルーティン", "夕方の散歩"];
var rand = seedRand(37);
var idx = Math.floor(rand() * CARDS.length);
var dragging = false;
var startX = 0, lastX = 0, lastT = 0, vxRaw = 0;
var pos = 0, vel = 0, phase = "idle", exitDir = 1;
function setCard() { label.textContent = CARDS[idx % CARDS.length]; }
setCard();
function render() {
var rot = clamp(pos * 0.06, -18, 18);
card.style.transform = "translateX(" + pos.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
}
function nextCard() {
idx += 1; setCard();
pos = 0; vel = 0; phase = "idle";
render();
status.textContent = "待機中";
}
function judge(velocityPxMs, dir) {
root.classList.remove("is-demo");
var flung = Math.abs(velocityPxMs) >= SPRING_FLING_VELOCITY_MIN;
if (reduce) {
if (flung) { status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み"; nextCard(); }
else { pos = 0; vel = 0; render(); status.textContent = "スプリングで弾んで戻った"; phase = "idle"; }
return;
}
if (flung) {
phase = "exit"; exitDir = dir;
status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み";
} else {
phase = "spring";
vel = velocityPxMs * 1000; // px/ms → px/s, 관성을 스프링에 넘긴다
status.textContent = "スプリングで弾んで戻った";
}
}
function startDrag(clientX) {
root.classList.remove("is-demo");
dragging = true; phase = "idle";
startX = clientX; lastX = clientX; lastT = performance.now(); vxRaw = 0;
}
function moveDrag(clientX) {
if (!dragging) return;
var now = performance.now();
var dt = Math.max(now - lastT, 1);
vxRaw = (clientX - lastX) / dt;
lastX = clientX; lastT = now;
pos = clientX - startX;
render();
}
function endDrag() {
if (!dragging) return;
dragging = false;
judge(vxRaw, pos >= 0 ? 1 : -1);
}
card.addEventListener("pointerdown", function (e) { card.setPointerCapture(e.pointerId); startDrag(e.clientX); });
card.addEventListener("pointermove", function (e) { moveDrag(e.clientX); });
card.addEventListener("pointerup", endDrag);
card.addEventListener("pointercancel", endDrag);
function keyVerdict(dir, weak) {
root.classList.remove("is-demo");
pos = dir * (weak ? 34 : 70); render();
judge(dir * (weak ? 0.1 : SPRING_FLING_VELOCITY_MIN + 0.4), dir);
}
card.addEventListener("keydown", function (e) {
if (e.key === "ArrowRight") { e.preventDefault(); keyVerdict(1, false); }
else if (e.key === "ArrowLeft") { e.preventDefault(); keyVerdict(-1, false); }
});
btnLike.addEventListener("click", function () { keyVerdict(1, false); });
btnNope.addEventListener("click", function () { keyVerdict(-1, false); });
// 스프링 방정식(강성 SPRING_STIFFNESS·감쇠 SPRING_DAMPING)을 매 프레임 적분한다 — 목표(0)를 넘겼다가 되돌아온다.
function integrate(dt) {
var accel = SPRING_STIFFNESS * (0 - pos) - SPRING_DAMPING * vel;
vel += accel * dt;
pos += vel * dt;
if (Math.abs(pos) < 0.4 && Math.abs(vel) < 4) { pos = 0; vel = 0; phase = "idle"; }
}
function step(dt) {
var dtS = Math.min(dt / 1000, 0.032);
if (phase === "exit") {
pos += exitDir * Math.max(Math.abs(vel) / 1000, SPRING_FLING_VELOCITY_MIN * 1.3) * dt;
render();
if (Math.abs(pos) > EXIT_PX) nextCard();
} else if (phase === "spring") {
integrate(dtS);
render();
}
}
// .is-demo 자동 시연: 실제 pointer 없이 pos·judge() 를 같은 경로로 호출한다.
var start = null, lastNow = 0, prevT = 0, synthPhase = "wait";
function synth(t) {
if (t > 0.4 && t < 0.65 && synthPhase === "wait") synthPhase = "pull";
if (synthPhase === "pull") {
var p = clamp((t - 0.4) / 0.25, 0, 1);
pos = p * 60; render();
if (t >= 0.65) { synthPhase = "afterPull"; judge(0.08, 1); }
}
if (t > 1.3 && t < 1.55 && synthPhase === "afterPull") synthPhase = "throw";
if (synthPhase === "throw") {
var q = clamp((t - 1.3) / 0.25, 0, 1);
pos = -q * 140; render();
if (t >= 1.55) { synthPhase = "done"; judge(-(SPRING_FLING_VELOCITY_MIN + 0.4), -1); }
}
}
function loop(now) {
if (start === null) { start = now; lastNow = now; lastT = now; }
var dt = Math.min(now - lastNow, 48);
lastNow = now;
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhase = "wait";
prevT = t;
if (!reduce && root.classList.contains("is-demo")) synth(t);
if (!reduce) step(dt);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// ElasticSnapbackSwipeCard — vanilla 판(run/422/demo/03-swipe-deck-precision-elastic-snapback/index.html) 의 React 포트.
// 변형 3: SWIPE_STACK_DEPTH_SCALE 를 뒤 카드 한 장에 실재시킨다. 값 하나: 해제 속도가
// SPRING_FLING_VELOCITY_MIN 에 못 미치면 스프링(강성·감쇠)을 적분해 오버슛하며 되돌아온다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./ElasticSnapbackSwipeCard.module.scss";
type Box<T> = { current: T };
const SWIPE_STACK_DEPTH_SCALE = 0.04;
const SPRING_FLING_VELOCITY_MIN = 0.5;
const SPRING_STIFFNESS = 120;
const SPRING_DAMPING = 14;
const EXIT_PX = 320;
const CYCLE_S = 2;
const CARDS = ["주말 카페", "홈트 기록", "독서 목록", "여행 메모", "아침 루틴", "저녁 산책"];
function seedRand(seed: number) {
let s = seed >>> 0;
return function (): number {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function ElasticSnapbackSwipeCard() {
const cardRef = useRef<HTMLDivElement | null>(null);
const peekRef = useRef<HTMLDivElement | null>(null);
const posRef = useRef<number>(0) as Box<number>;
const velRef = useRef<number>(0) as Box<number>;
const phaseRef = useRef<string>("idle") as Box<string>;
const exitDirRef = useRef<number>(1) as Box<number>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const startXRef = useRef<number>(0) as Box<number>;
const lastXRef = useRef<number>(0) as Box<number>;
const lastTRef = useRef<number>(0) as Box<number>;
const idxRef = useRef<number>(Math.floor(seedRand(37)() * CARDS.length)) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [label, setLabel] = useState(CARDS[idxRef.current % CARDS.length]);
const [status, setStatus] = useState("대기 중");
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
useEffect(() => {
const peek = peekRef.current;
if (!peek) return;
peek.style.transform = "translateY(" + (SWIPE_STACK_DEPTH_SCALE * 100).toFixed(1) + "%) scale(" + (1 - SWIPE_STACK_DEPTH_SCALE).toFixed(3) + ")";
}, []);
function render(): void {
const card = cardRef.current;
if (!card) return;
const rot = clamp(posRef.current * 0.06, -18, 18);
card.style.transform = "translateX(" + posRef.current.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
}
function nextCard(): void {
idxRef.current += 1;
setLabel(CARDS[idxRef.current % CARDS.length]);
posRef.current = 0; velRef.current = 0; phaseRef.current = "idle";
render();
setStatus("대기 중");
}
function judge(velocityPxMs: number, dir: number): void {
setDemo(false);
const flung = Math.abs(velocityPxMs) >= SPRING_FLING_VELOCITY_MIN;
if (reduce) {
if (flung) { setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨"); nextCard(); }
else { posRef.current = 0; velRef.current = 0; render(); setStatus("스프링으로 되돌아옴"); phaseRef.current = "idle"; }
return;
}
if (flung) {
phaseRef.current = "exit"; exitDirRef.current = dir;
setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨");
} else {
phaseRef.current = "spring";
velRef.current = velocityPxMs * 1000;
setStatus("스프링으로 되돌아옴");
}
}
function startDrag(clientX: number): void {
setDemo(false);
draggingRef.current = true; phaseRef.current = "idle";
startXRef.current = clientX; lastXRef.current = clientX; lastTRef.current = performance.now();
}
function moveDrag(clientX: number): void {
if (!draggingRef.current) return;
const now = performance.now();
const dt = Math.max(now - lastTRef.current, 1);
velRef.current = ((clientX - lastXRef.current) / dt) * 1000;
lastXRef.current = clientX; lastTRef.current = now;
posRef.current = clientX - startXRef.current;
render();
}
function endDrag(): void {
if (!draggingRef.current) return;
draggingRef.current = false;
judge(velRef.current / 1000, posRef.current >= 0 ? 1 : -1);
}
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>): void {
e.currentTarget.setPointerCapture(e.pointerId);
startDrag(e.clientX);
}
function onPointerMove(e: ReactPointerEvent<HTMLDivElement>): void { moveDrag(e.clientX); }
function onPointerUp(): void { endDrag(); }
function keyVerdict(dir: number, weak: boolean): void {
setDemo(false);
posRef.current = dir * (weak ? 34 : 70); render();
judge(dir * (weak ? 0.1 : SPRING_FLING_VELOCITY_MIN + 0.4), dir);
}
function onKeyDown(e: any): void {
if (e.key === "ArrowRight") { e.preventDefault(); keyVerdict(1, false); }
else if (e.key === "ArrowLeft") { e.preventDefault(); keyVerdict(-1, false); }
}
function integrate(dt: number): void {
const accel = SPRING_STIFFNESS * (0 - posRef.current) - SPRING_DAMPING * velRef.current;
velRef.current += accel * dt;
posRef.current += velRef.current * dt;
if (Math.abs(posRef.current) < 0.4 && Math.abs(velRef.current) < 4) { posRef.current = 0; velRef.current = 0; phaseRef.current = "idle"; }
}
function step(dt: number): void {
const dtS = Math.min(dt / 1000, 0.032);
if (phaseRef.current === "exit") {
posRef.current += exitDirRef.current * Math.max(Math.abs(velRef.current) / 1000, SPRING_FLING_VELOCITY_MIN * 1.3) * dt;
render();
if (Math.abs(posRef.current) > EXIT_PX) nextCard();
} else if (phaseRef.current === "spring") {
integrate(dtS);
render();
}
}
function synth(t: number, synthPhaseRef: Box<string>): void {
if (t > 0.4 && t < 0.65 && synthPhaseRef.current === "wait") synthPhaseRef.current = "pull";
if (synthPhaseRef.current === "pull") {
const p = clamp((t - 0.4) / 0.25, 0, 1);
posRef.current = p * 60; render();
if (t >= 0.65) { synthPhaseRef.current = "afterPull"; judge(0.08, 1); }
}
if (t > 1.3 && t < 1.55 && synthPhaseRef.current === "afterPull") synthPhaseRef.current = "throw";
if (synthPhaseRef.current === "throw") {
const q = clamp((t - 1.3) / 0.25, 0, 1);
posRef.current = -q * 140; render();
if (t >= 1.55) { synthPhaseRef.current = "done"; judge(-(SPRING_FLING_VELOCITY_MIN + 0.4), -1); }
}
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0, lastNow = 0, prevT = 0;
const synthPhaseRef: Box<string> = { current: "wait" };
function loop(now: number): void {
if (start === 0) { start = now; lastNow = now; }
const dt = Math.min(now - lastNow, 48);
lastNow = now;
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhaseRef.current = "wait";
prevT = t;
if (!reduce && demo) synth(t, synthPhaseRef);
if (!reduce) step(dt);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
return (
<main className={styles.stage}>
<div className={styles.es}>
<p className={styles.caption}>스프링 오버슛으로 되돌아오는 카드</p>
<div className={styles.scene}>
<div className={styles.peek} ref={peekRef} aria-hidden="true" />
<div
className={styles.card}
ref={cardRef}
tabIndex={0}
role="group"
aria-roledescription="스와이프 카드"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
>
<p className={styles.label}>{label}</p>
</div>
<button className={styles.btnNope} type="button" aria-label="싫어요" onClick={() => keyVerdict(-1, false)}>✕</button>
<button className={styles.btnLike} type="button" aria-label="좋아요" onClick={() => keyVerdict(1, false)}>♥</button>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
</div>
</main>
);
}03 · ばねオーバーシュートで戻るカード — 計測 累積面積 15.172% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function integrate(dt) {
var accel = SPRING_STIFFNESS * (0 - pos) - SPRING_DAMPING * vel;
vel += accel * dt;
pos += vel * dt;
if (Math.abs(pos) < 0.4 && Math.abs(vel) < 4) { pos = 0; vel = 0; phase = "idle"; }
}
計測ではこのオーバーシュートと収束が画面の15.2%を占め、平均強度は112.5、動きの検出は8/23フレームです。
04遠近投影で積み重なるカードスタック
後ろに積まれたカードは遠近投影で一枚ずつ小さくなり、上へずれて階段状に重なるので、デッキが平らな山ではなく奥行きとして見えます。STACK_PERSPECTIVE_SCALE は、各カードが共有する焦点距離にどれだけ食い込むかを決める値で、この縮み方の急さを左右します。

<main class="stage">
<div class="sd is-demo" id="sd">
<p class="sd__caption">遠近投影で積み重なるカードスタック</p>
<div class="sd__scene" id="sdScene">
<div class="sd__peek" id="sdPeek3" aria-hidden="true"></div>
<div class="sd__peek" id="sdPeek2" aria-hidden="true"></div>
<div class="sd__peek" id="sdPeek1" aria-hidden="true"></div>
<div class="sd__card" id="sdCard" tabindex="0" role="group" aria-roledescription="スワイプカード">
<p class="sd__label" id="sdLabel">宅トレ記録</p>
</div>
<button class="sd__btn sd__btn--nope" id="sdNope" type="button" aria-label="いいえ">✕</button>
<button class="sd__btn sd__btn--like" id="sdLike" type="button" aria-label="いいね">♥</button>
</div>
<p class="sd__status" id="sdStatus" aria-live="polite">待機中</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 뒤 카드일수록 STACK_PERSPECTIVE_SCALE·초점거리로 원근 투영 계산해 작아지고 위로 겹친다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-orange);
.sd { display: grid; gap: $sp-2; justify-items: center; width: 100%; }
.sd__caption { margin: 0; font-size: 12px; font-weight: 600; color: $subject-ink; }
.sd__scene {
position: relative;
width: 220px;
height: 190px;
display: grid;
place-items: center;
}
.sd__peek {
position: absolute;
width: 160px;
height: 180px;
border-radius: $r-card;
background: linear-gradient(160deg, $subject-lilac, $subject-sky);
will-change: transform, opacity;
}
.sd__card {
position: relative;
z-index: 4;
width: 160px;
height: 180px;
border-radius: $r-card;
background: linear-gradient(160deg, $subject-mint, $subject-blue-light);
box-shadow: $shadow-raised;
display: grid;
place-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
}
.sd__card:active { cursor: grabbing; }
.sd__card:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
.sd__label { margin: 0; font-size: 14px; font-weight: 700; color: $subject-ink; padding: 0 $sp-3; text-align: center; }
.sd__btn {
position: absolute;
bottom: $sp-3;
z-index: 5;
width: 34px;
height: 34px;
border-radius: $r-pill;
border: 0;
background: $stage-paper;
box-shadow: $shadow-press;
font-size: 15px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.sd__btn--nope { left: $sp-3; color: $error; }
.sd__btn--like { right: $sp-3; color: $subject-blue-deep; }
.sd__btn:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.sd__status { margin: 0; font-size: 11px; color: $subject-ink; font-weight: 600; }
@media (max-width: 340px) {
.sd { gap: $sp-2; }
.sd__scene { width: 150px; height: 108px; }
.sd__peek { width: 78px; height: 92px; }
.sd__card { width: 78px; height: 92px; }
.sd__label { font-size: 10px; padding: 0 $sp-1; }
.sd__btn { width: 22px; height: 22px; font-size: 11px; bottom: $sp-1; }
.sd__btn--nope { left: $sp-1; }
.sd__btn--like { right: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.sd__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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sd {
display: grid;
gap: 8px;
justify-items: center;
width: 100%;
}
.sd__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.sd__scene {
position: relative;
width: 220px;
height: 190px;
display: grid;
place-items: center;
}
.sd__peek {
position: absolute;
width: 160px;
height: 180px;
border-radius: 16px;
background: linear-gradient(160deg, #b9a0ff, #6ec8ff);
will-change: transform, opacity;
}
.sd__card {
position: relative;
z-index: 4;
width: 160px;
height: 180px;
border-radius: 16px;
background: linear-gradient(160deg, #4cd4a6, #5b8cf8);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: grid;
place-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
}
.sd__card:active {
cursor: grabbing;
}
.sd__card:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.sd__label {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #17141a;
padding: 0 12px;
text-align: center;
}
.sd__btn {
position: absolute;
bottom: 12px;
z-index: 5;
width: 34px;
height: 34px;
border-radius: 999px;
border: 0;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-size: 15px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.sd__btn--nope {
left: 12px;
color: #c2361a;
}
.sd__btn--like {
right: 12px;
color: #0a4fe8;
}
.sd__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.sd__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.sd {
gap: 8px;
}
.sd__scene {
width: 150px;
height: 108px;
}
.sd__peek {
width: 78px;
height: 92px;
}
.sd__card {
width: 78px;
height: 92px;
}
.sd__label {
font-size: 10px;
padding: 0 4px;
}
.sd__btn {
width: 22px;
height: 22px;
font-size: 11px;
bottom: 4px;
}
.sd__btn--nope {
left: 4px;
}
.sd__btn--like {
right: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.sd__card {
transition: none;
}
}(function () {
"use strict";
// 값 하나: 인덱스가 하나 늘 때마다 원근 투영(초점거리 FOCAL) 으로 scale·translateY 가 줄어든다.
var STACK_PERSPECTIVE_SCALE = 0.14;
var FOCAL = 260;
var STACK_SIZE = 3;
var RELEASE_VELOCITY_MIN = 0.4;
var EXIT_PX = 320;
var CYCLE_S = 2;
var root = document.getElementById("sd");
var card = document.getElementById("sdCard");
var label = document.getElementById("sdLabel");
var status = document.getElementById("sdStatus");
var btnLike = document.getElementById("sdLike");
var btnNope = document.getElementById("sdNope");
var peeks = [
document.getElementById("sdPeek1"),
document.getElementById("sdPeek2"),
document.getElementById("sdPeek3")
];
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 원근 투영: depth_i = i * STACK_PERSPECTIVE_SCALE * FOCAL, scale_i = FOCAL / (FOCAL + depth_i)
function layoutStack() {
for (var i = 1; i <= STACK_SIZE; i++) {
var depth = i * STACK_PERSPECTIVE_SCALE * FOCAL;
var scale = FOCAL / (FOCAL + depth);
var ty = -(1 - scale) * 60;
var el = peeks[i - 1];
el.style.transform = "translateY(" + ty.toFixed(2) + "%) scale(" + scale.toFixed(3) + ")";
el.style.opacity = (1 - i * 0.24).toFixed(2);
el.style.zIndex = String(STACK_SIZE - i);
}
}
layoutStack();
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var CARDS = ["宅トレ記録", "読書リスト", "旅行メモ", "朝のルーティン", "夕方の散歩", "週末のカフェ"];
var rand = seedRand(51);
var idx = Math.floor(rand() * CARDS.length);
var dragging = false;
var startX = 0, lastX = 0, lastT = 0, vx = 0;
var x = 0, phase = "idle", exitDir = 1;
function setCard() { label.textContent = CARDS[idx % CARDS.length]; }
setCard();
function render() {
var rot = clamp(x * 0.09, -20, 20);
card.style.transform = "translateX(" + x.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
}
function nextCard() {
idx += 1; setCard();
x = 0; vx = 0; phase = "idle";
render();
status.textContent = "待機中";
}
function judge(velocity, dir) {
root.classList.remove("is-demo");
var flung = Math.abs(velocity) >= RELEASE_VELOCITY_MIN;
if (reduce) {
if (flung) { status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み"; nextCard(); }
else { x = 0; render(); status.textContent = "元の位置に戻った"; phase = "idle"; }
return;
}
if (flung) { phase = "exit"; exitDir = dir; status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み"; }
else { phase = "settle"; status.textContent = "元の位置に戻った"; }
}
function startDrag(clientX) {
root.classList.remove("is-demo");
dragging = true; phase = "idle";
startX = clientX; lastX = clientX; lastT = performance.now(); vx = 0;
}
function moveDrag(clientX) {
if (!dragging) return;
var now = performance.now();
var dt = Math.max(now - lastT, 1);
vx = (clientX - lastX) / dt;
lastX = clientX; lastT = now;
x = clientX - startX;
render();
}
function endDrag() {
if (!dragging) return;
dragging = false;
judge(vx, x >= 0 ? 1 : -1);
}
card.addEventListener("pointerdown", function (e) { card.setPointerCapture(e.pointerId); startDrag(e.clientX); });
card.addEventListener("pointermove", function (e) { moveDrag(e.clientX); });
card.addEventListener("pointerup", endDrag);
card.addEventListener("pointercancel", endDrag);
function keyVerdict(dir) {
root.classList.remove("is-demo");
x = dir * 60; render();
judge(dir * (RELEASE_VELOCITY_MIN + 0.3), dir);
}
card.addEventListener("keydown", function (e) {
if (e.key === "ArrowRight") { e.preventDefault(); keyVerdict(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); keyVerdict(-1); }
});
btnLike.addEventListener("click", function () { keyVerdict(1); });
btnNope.addEventListener("click", function () { keyVerdict(-1); });
function step(dt) {
if (phase === "exit") {
x += exitDir * Math.max(Math.abs(vx), RELEASE_VELOCITY_MIN * 1.3) * dt;
render();
if (Math.abs(x) > EXIT_PX) nextCard();
} else if (phase === "settle") {
x += (0 - x) * 0.22;
render();
if (Math.abs(x) < 0.4) { x = 0; phase = "idle"; render(); }
}
}
// .is-demo 자동 시연: 실제 pointer 없이 x·judge() 를 같은 경로로 호출한다.
var start = null, lastNow = 0, prevT = 0, synthPhase = "wait";
function synth(t) {
if (t > 0.45 && t < 0.75 && synthPhase === "wait") synthPhase = "drag";
if (synthPhase === "drag") {
var p = clamp((t - 0.45) / 0.3, 0, 1);
x = p * 130; render();
if (t >= 0.75) { synthPhase = "afterDrag"; judge(RELEASE_VELOCITY_MIN + 0.35, 1); }
}
if (t > 1.3 && t < 1.55 && synthPhase === "afterDrag") synthPhase = "drag2";
if (synthPhase === "drag2") {
var q = clamp((t - 1.3) / 0.25, 0, 1);
x = -q * 130; render();
if (t >= 1.55) { synthPhase = "done"; judge(-(RELEASE_VELOCITY_MIN + 0.35), -1); }
}
}
function loop(now) {
if (start === null) { start = now; lastNow = now; lastT = now; }
var dt = Math.min(now - lastNow, 48);
lastNow = now;
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhase = "wait";
prevT = t;
if (!reduce && root.classList.contains("is-demo")) synth(t);
if (!reduce) step(dt);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// StackDepthPerspectiveSwipeCard — vanilla 판(run/422/demo/04-swipe-deck-precision-stack-depth-perspective/index.html) 의 React 포트.
// 값 하나: 뒤 카드 STACK_SIZE 장을 원근 투영(FOCAL)으로 축소·이동시켜 쌓고,
// 앞 카드가 빠지면 화가 알고리즘 순서(뒤→앞)로 한 칸씩 당겨 다시 그린다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./StackDepthPerspectiveSwipeCard.module.scss";
type Box<T> = { current: T };
const STACK_PERSPECTIVE_SCALE = 0.14;
const FOCAL = 260;
const STACK_SIZE = 3;
const RELEASE_VELOCITY_MIN = 0.4;
const EXIT_PX = 320;
const CYCLE_S = 2;
const CARDS = ["여행 메모", "아침 루틴", "저녁 산책", "주말 카페", "홈트 기록", "독서 목록", "메모 정리", "습관 체크"];
function seedRand(seed: number) {
let s = seed >>> 0;
return function (): number {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function StackDepthPerspectiveSwipeCard() {
const frontRef = useRef<HTMLDivElement | null>(null);
const peekRefs = useRef<Array<HTMLDivElement | null>>([null, null, null]) as Box<Array<HTMLDivElement | null>>;
const posRef = useRef<number>(0) as Box<number>;
const velRef = useRef<number>(0) as Box<number>;
const phaseRef = useRef<string>("idle") as Box<string>;
const exitDirRef = useRef<number>(1) as Box<number>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const startXRef = useRef<number>(0) as Box<number>;
const lastXRef = useRef<number>(0) as Box<number>;
const lastTRef = useRef<number>(0) as Box<number>;
const idxRef = useRef<number>(Math.floor(seedRand(41)() * CARDS.length)) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [front, setFront] = useState(CARDS[idxRef.current % CARDS.length]);
const [peeks, setPeeks] = useState<string[]>(
Array.from({ length: STACK_SIZE }, (_, i) => CARDS[(idxRef.current + i + 1) % CARDS.length])
);
const [status, setStatus] = useState("대기 중");
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function layoutStack(): void {
for (let i = 0; i < STACK_SIZE; i += 1) {
const el = peekRefs.current[i];
if (!el) continue;
const depth = i + 1;
const scale = FOCAL / (FOCAL + depth * STACK_PERSPECTIVE_SCALE * FOCAL);
const y = depth * STACK_PERSPECTIVE_SCALE * 22;
el.style.transform = "translateY(" + y.toFixed(1) + "px) scale(" + scale.toFixed(3) + ")";
el.style.zIndex = String(STACK_SIZE - i);
}
}
useEffect(() => { layoutStack(); }, []);
function render(): void {
const card = frontRef.current;
if (!card) return;
const rot = clamp(posRef.current * 0.05, -16, 16);
card.style.transform = "translateX(" + posRef.current.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
}
function nextCard(): void {
idxRef.current += 1;
setFront(CARDS[idxRef.current % CARDS.length]);
setPeeks(Array.from({ length: STACK_SIZE }, (_, i) => CARDS[(idxRef.current + i + 1) % CARDS.length]));
posRef.current = 0; velRef.current = 0; phaseRef.current = "idle";
render();
setStatus("대기 중");
}
function judge(velocity: number, dir: number): void {
setDemo(false);
const flung = Math.abs(velocity) >= RELEASE_VELOCITY_MIN;
if (reduce) {
if (flung) { setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨"); nextCard(); }
else { posRef.current = 0; render(); setStatus("제자리로 돌아옴"); phaseRef.current = "idle"; }
return;
}
if (flung) {
phaseRef.current = "exit"; exitDirRef.current = dir;
setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨");
} else {
phaseRef.current = "settle";
setStatus("제자리로 돌아옴");
}
}
function startDrag(clientX: number): void {
setDemo(false);
draggingRef.current = true; phaseRef.current = "idle";
startXRef.current = clientX; lastXRef.current = clientX; lastTRef.current = performance.now(); velRef.current = 0;
}
function moveDrag(clientX: number): void {
if (!draggingRef.current) return;
const now = performance.now();
const dt = Math.max(now - lastTRef.current, 1);
velRef.current = (clientX - lastXRef.current) / dt;
lastXRef.current = clientX; lastTRef.current = now;
posRef.current = clientX - startXRef.current;
render();
}
function endDrag(): void {
if (!draggingRef.current) return;
draggingRef.current = false;
judge(velRef.current, posRef.current >= 0 ? 1 : -1);
}
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>): void {
e.currentTarget.setPointerCapture(e.pointerId);
startDrag(e.clientX);
}
function onPointerMove(e: ReactPointerEvent<HTMLDivElement>): void { moveDrag(e.clientX); }
function onPointerUp(): void { endDrag(); }
function keyVerdict(dir: number): void {
setDemo(false);
posRef.current = dir * 40; render();
judge(dir * (RELEASE_VELOCITY_MIN + 0.2), dir);
}
function onKeyDown(e: any): void {
if (e.key === "ArrowRight") { e.preventDefault(); keyVerdict(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); keyVerdict(-1); }
}
function step(dt: number): void {
if (phaseRef.current === "exit") {
const speed = Math.max(Math.abs(velRef.current), RELEASE_VELOCITY_MIN * 1.4);
posRef.current += exitDirRef.current * speed * dt;
render();
if (Math.abs(posRef.current) > EXIT_PX) nextCard();
} else if (phaseRef.current === "settle") {
posRef.current += (0 - posRef.current) * 0.22;
render();
if (Math.abs(posRef.current) < 0.5) { posRef.current = 0; phaseRef.current = "idle"; render(); }
}
}
function synth(t: number, synthPhaseRef: Box<string>): void {
if (t > 0.4 && t < 0.7 && synthPhaseRef.current === "wait") synthPhaseRef.current = "drop";
if (synthPhaseRef.current === "drop") {
const p = clamp((t - 0.4) / 0.3, 0, 1);
posRef.current = p * 90; render();
if (t >= 0.7) { synthPhaseRef.current = "afterDrop"; judge(RELEASE_VELOCITY_MIN + 0.3, 1); }
}
if (t > 1.3 && t < 1.6 && synthPhaseRef.current === "afterDrop") synthPhaseRef.current = "drop2";
if (synthPhaseRef.current === "drop2") {
const q = clamp((t - 1.3) / 0.3, 0, 1);
posRef.current = -q * 90; render();
if (t >= 1.6) { synthPhaseRef.current = "done"; judge(-(RELEASE_VELOCITY_MIN + 0.3), -1); }
}
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0, lastNow = 0, prevT = 0;
const synthPhaseRef: Box<string> = { current: "wait" };
function loop(now: number): void {
if (start === 0) { start = now; lastNow = now; }
const dt = Math.min(now - lastNow, 48);
lastNow = now;
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhaseRef.current = "wait";
prevT = t;
if (!reduce && demo) synth(t, synthPhaseRef);
if (!reduce) step(dt);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
return (
<main className={styles.stage}>
<div className={styles.sd}>
<p className={styles.caption}>원근 투영으로 쌓이는 카드 더미</p>
<div className={styles.scene}>
{peeks.map((txt, i) => (
<div
className={styles.peek}
key={i}
ref={(el: HTMLDivElement | null) => { peekRefs.current[i] = el; }}
aria-hidden="true"
>
<p className={styles.label}>{txt}</p>
</div>
))}
<div
className={styles.card}
ref={frontRef}
tabIndex={0}
role="group"
aria-roledescription="스와이프 카드"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
>
<p className={styles.label}>{front}</p>
</div>
<button className={styles.btnNope} type="button" aria-label="싫어요" onClick={() => keyVerdict(-1)}>✕</button>
<button className={styles.btnLike} type="button" aria-label="좋아요" onClick={() => keyVerdict(1)}>♥</button>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
</div>
</main>
);
}04 · 遠近投影で積み重なるカードスタック — 計測 累積面積 48.063% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function layoutStack() {
for (var i = 1; i <= STACK_SIZE; i++) {
var depth = i * STACK_PERSPECTIVE_SCALE * FOCAL;
var scale = FOCAL / (FOCAL + depth);
var ty = -(1 - scale) * 60;
var el = peeks[i - 1];
el.style.transform = "translateY(" + ty.toFixed(2) + "%) scale(" + scale.toFixed(3) + ")";
el.style.opacity = (1 - i * 0.24).toFixed(2);
el.style.zIndex = String(STACK_SIZE - i);
}
}
計測ではこのスタックの奥行き変化が画面の48.1%を占め、平均強度は113.9、動きの検出は6/23フレームです。
05ドラッグ距離でにじむいいね・ノーの判子
いいね・ノーの判子の不透明度は、ドラッグ距離に対して直線的には上がりません — 非線形に濃くなり、最初は薄いまま、離す距離に近づくにつれ急に追いつきます。STAMP_OPACITY_CURVE_POWER は stampOpacity() がその距離比を何乗するかを決める指数で、この曲がり方を左右します。

<main class="stage">
<div class="so is-demo" id="so">
<p class="so__caption">非線形カーブで濃くなるいいね・いいえスタンプ</p>
<div class="so__scene" id="soScene">
<div class="so__card" id="soCard" tabindex="0" role="group" aria-roledescription="スワイプカード">
<span class="so__stamp so__stamp--like" id="soLike" aria-hidden="true">LIKE</span>
<span class="so__stamp so__stamp--nope" id="soNope" aria-hidden="true">NOPE</span>
<p class="so__label" id="soLabel">旅行メモ</p>
</div>
<button class="so__btn so__btn--nope" id="soBtnNope" type="button" aria-label="いいえ">✕</button>
<button class="so__btn so__btn--like" id="soBtnLike" type="button" aria-label="いいね">♥</button>
</div>
<p class="so__status" id="soStatus" aria-live="polite">待機中</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 드래그 거리를 STAMP_OPACITY_CURVE_POWER 승으로 매핑해 LIKE·NOPE 도장이
// 비선형(중심 근처 느림 → 임계 근처 급함)으로 진해진다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-mint;
@include stage($stage-paper);
.so { display: grid; gap: $sp-2; justify-items: center; width: 100%; }
.so__caption { margin: 0; font-size: 12px; font-weight: 600; color: $subject-ink; }
.so__scene {
position: relative;
width: 220px;
height: 190px;
display: grid;
place-items: center;
}
.so__card {
position: relative;
width: 160px;
height: 180px;
border-radius: $r-card;
background: linear-gradient(160deg, $subject-cream, $subject-sky);
box-shadow: $shadow-raised;
display: grid;
place-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
overflow: hidden;
}
.so__card:active { cursor: grabbing; }
.so__card:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
.so__label { margin: 0; font-size: 14px; font-weight: 700; color: $subject-ink; padding: 0 $sp-3; text-align: center; }
.so__stamp {
position: absolute;
top: $sp-3;
border: 3px solid currentColor;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
font-size: 13px;
font-weight: 800;
letter-spacing: .08em;
opacity: 0;
pointer-events: none;
}
.so__stamp--like { left: $sp-3; color: $subject-mint; transform: rotate(-14deg); }
.so__stamp--nope { right: $sp-3; color: $error; transform: rotate(14deg); }
.so__btn {
position: absolute;
bottom: $sp-3;
width: 34px;
height: 34px;
border-radius: $r-pill;
border: 0;
background: $stage-paper;
box-shadow: $shadow-press;
font-size: 15px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
z-index: 2;
}
.so__btn--nope { left: $sp-3; color: $error; }
.so__btn--like { right: $sp-3; color: $subject-mint; }
.so__btn:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.so__status { margin: 0; font-size: 11px; color: $subject-ink; font-weight: 600; }
@media (max-width: 340px) {
.so { gap: $sp-2; }
.so__scene { width: 150px; height: 108px; }
.so__card { width: 78px; height: 92px; }
.so__label { font-size: 10px; padding: 0 $sp-1; }
.so__stamp { font-size: 9px; padding: 0 $sp-1; top: $sp-1; }
.so__btn { width: 22px; height: 22px; font-size: 11px; bottom: $sp-1; }
.so__btn--nope { left: $sp-1; }
.so__btn--like { right: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.so__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);
}
.so {
display: grid;
gap: 8px;
justify-items: center;
width: 100%;
}
.so__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.so__scene {
position: relative;
width: 220px;
height: 190px;
display: grid;
place-items: center;
}
.so__card {
position: relative;
width: 160px;
height: 180px;
border-radius: 16px;
background: linear-gradient(160deg, #fff7e6, #6ec8ff);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: grid;
place-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
overflow: hidden;
}
.so__card:active {
cursor: grabbing;
}
.so__card:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.so__label {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #17141a;
padding: 0 12px;
text-align: center;
}
.so__stamp {
position: absolute;
top: 12px;
border: 3px solid currentColor;
border-radius: 4px;
padding: 4px 8px;
font-size: 13px;
font-weight: 800;
letter-spacing: 0.08em;
opacity: 0;
pointer-events: none;
}
.so__stamp--like {
left: 12px;
color: #4cd4a6;
transform: rotate(-14deg);
}
.so__stamp--nope {
right: 12px;
color: #c2361a;
transform: rotate(14deg);
}
.so__btn {
position: absolute;
bottom: 12px;
width: 34px;
height: 34px;
border-radius: 999px;
border: 0;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-size: 15px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
z-index: 2;
}
.so__btn--nope {
left: 12px;
color: #c2361a;
}
.so__btn--like {
right: 12px;
color: #4cd4a6;
}
.so__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.so__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.so {
gap: 8px;
}
.so__scene {
width: 150px;
height: 108px;
}
.so__card {
width: 78px;
height: 92px;
}
.so__label {
font-size: 10px;
padding: 0 4px;
}
.so__stamp {
font-size: 9px;
padding: 0 4px;
top: 4px;
}
.so__btn {
width: 22px;
height: 22px;
font-size: 11px;
bottom: 4px;
}
.so__btn--nope {
left: 4px;
}
.so__btn--like {
right: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.so__card {
transition: none;
}
}(function () {
"use strict";
// 값 하나: 드래그 거리를 STAMP_OPACITY_CURVE_POWER 승으로 매핑해 도장이 선형이 아니라
// 중심 근처에선 느리게, 임계 근처에선 급하게 진해진다.
var STAMP_OPACITY_CURVE_POWER = 2.4;
var STAMP_RANGE_PX = 150;
var RELEASE_VELOCITY_MIN = 0.38;
var EXIT_PX = 320;
var CYCLE_S = 2;
var root = document.getElementById("so");
var card = document.getElementById("soCard");
var label = document.getElementById("soLabel");
var like = document.getElementById("soLike");
var nope = document.getElementById("soNope");
var status = document.getElementById("soStatus");
var btnLike = document.getElementById("soBtnLike");
var btnNope = document.getElementById("soBtnNope");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var CARDS = ["旅行メモ", "朝のルーティン", "夕方の散歩", "週末のカフェ", "宅トレ記録", "読書リスト"];
var rand = seedRand(64);
var idx = Math.floor(rand() * CARDS.length);
var dragging = false;
var startX = 0, lastX = 0, lastT = 0, vx = 0;
var x = 0, phase = "idle", exitDir = 1;
function setCard() { label.textContent = CARDS[idx % CARDS.length]; }
setCard();
// 비선형 매핑: ratio^POWER — 중심 근처는 낮게 눌려 있다가 끝에서 급격히 1 로 치솟는다.
function stampOpacity(px) {
var ratio = clamp(Math.abs(px) / STAMP_RANGE_PX, 0, 1);
return Math.pow(ratio, STAMP_OPACITY_CURVE_POWER);
}
function render() {
var rot = clamp(x * 0.07, -18, 18);
card.style.transform = "translateX(" + x.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
var op = stampOpacity(x);
like.style.opacity = x > 0 ? op.toFixed(3) : "0";
nope.style.opacity = x < 0 ? op.toFixed(3) : "0";
}
function nextCard() {
idx += 1; setCard();
x = 0; vx = 0; phase = "idle";
render();
status.textContent = "待機中";
}
function judge(velocity, dir) {
root.classList.remove("is-demo");
var flung = Math.abs(velocity) >= RELEASE_VELOCITY_MIN;
if (reduce) {
if (flung) { status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み"; nextCard(); }
else { x = 0; render(); status.textContent = "元の位置に戻った"; phase = "idle"; }
return;
}
if (flung) { phase = "exit"; exitDir = dir; status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み"; }
else { phase = "settle"; status.textContent = "元の位置に戻った"; }
}
function startDrag(clientX) {
root.classList.remove("is-demo");
dragging = true; phase = "idle";
startX = clientX; lastX = clientX; lastT = performance.now(); vx = 0;
}
function moveDrag(clientX) {
if (!dragging) return;
var now = performance.now();
var dt = Math.max(now - lastT, 1);
vx = (clientX - lastX) / dt;
lastX = clientX; lastT = now;
x = clientX - startX;
render();
}
function endDrag() {
if (!dragging) return;
dragging = false;
judge(vx, x >= 0 ? 1 : -1);
}
card.addEventListener("pointerdown", function (e) { card.setPointerCapture(e.pointerId); startDrag(e.clientX); });
card.addEventListener("pointermove", function (e) { moveDrag(e.clientX); });
card.addEventListener("pointerup", endDrag);
card.addEventListener("pointercancel", endDrag);
function keyVerdict(dir) {
root.classList.remove("is-demo");
x = dir * 60; render();
judge(dir * (RELEASE_VELOCITY_MIN + 0.3), dir);
}
card.addEventListener("keydown", function (e) {
if (e.key === "ArrowRight") { e.preventDefault(); keyVerdict(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); keyVerdict(-1); }
});
btnLike.addEventListener("click", function () { keyVerdict(1); });
btnNope.addEventListener("click", function () { keyVerdict(-1); });
function step(dt) {
if (phase === "exit") {
x += exitDir * Math.max(Math.abs(vx), RELEASE_VELOCITY_MIN * 1.4) * dt;
render();
if (Math.abs(x) > EXIT_PX) nextCard();
} else if (phase === "settle") {
x += (0 - x) * 0.22;
render();
if (Math.abs(x) < 0.4) { x = 0; phase = "idle"; render(); }
}
}
// .is-demo 자동 시연: 실제 pointer 없이 x·judge() 를 같은 경로로 호출한다.
var start = null, lastNow = 0, prevT = 0, synthPhase = "wait";
function synth(t) {
if (t > 0.4 && t < 0.7 && synthPhase === "wait") synthPhase = "peekLike";
if (synthPhase === "peekLike") {
var p = clamp((t - 0.4) / 0.3, 0, 1);
x = p * 90; render();
if (t >= 0.7) { synthPhase = "afterPeek"; judge(0.1, 1); }
}
if (t > 1.25 && t < 1.55 && synthPhase === "afterPeek") synthPhase = "commit";
if (synthPhase === "commit") {
var q = clamp((t - 1.25) / 0.3, 0, 1);
x = -q * 150; render();
if (t >= 1.55) { synthPhase = "done"; judge(-(RELEASE_VELOCITY_MIN + 0.4), -1); }
}
}
function loop(now) {
if (start === null) { start = now; lastNow = now; lastT = now; }
var dt = Math.min(now - lastNow, 48);
lastNow = now;
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhase = "wait";
prevT = t;
if (!reduce && root.classList.contains("is-demo")) synth(t);
if (!reduce) step(dt);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// StampOpacityDragSwipeCard — vanilla 판(run/422/demo/05-swipe-deck-precision-stamp-opacity-drag/index.html) 의 React 포트.
// 값 하나: LIKE/NOPE 스탬프의 불투명도는 이동거리에 선형이 아니라
// (|px|/STAMP_RANGE_PX) 를 STAMP_OPACITY_CURVE_POWER 제곱한 값이다(초반은 거의 안 보이다 막판에 확 짙어짐).
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./StampOpacityDragSwipeCard.module.scss";
type Box<T> = { current: T };
const STAMP_OPACITY_CURVE_POWER = 2.4;
const STAMP_RANGE_PX = 150;
const RELEASE_VELOCITY_MIN = 0.4;
const EXIT_PX = 320;
const CYCLE_S = 2;
const CARDS = ["아침 루틴", "저녁 산책", "주말 카페", "홈트 기록", "독서 목록", "여행 메모"];
function seedRand(seed: number) {
let s = seed >>> 0;
return function (): number {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function stampOpacity(px: number): number {
return Math.pow(clamp(Math.abs(px) / STAMP_RANGE_PX, 0, 1), STAMP_OPACITY_CURVE_POWER);
}
export default function StampOpacityDragSwipeCard() {
const cardRef = useRef<HTMLDivElement | null>(null);
const likeRef = useRef<HTMLSpanElement | null>(null);
const nopeRef = useRef<HTMLSpanElement | null>(null);
const posRef = useRef<number>(0) as Box<number>;
const velRef = useRef<number>(0) as Box<number>;
const phaseRef = useRef<string>("idle") as Box<string>;
const exitDirRef = useRef<number>(1) as Box<number>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const startXRef = useRef<number>(0) as Box<number>;
const lastXRef = useRef<number>(0) as Box<number>;
const lastTRef = useRef<number>(0) as Box<number>;
const idxRef = useRef<number>(Math.floor(seedRand(53)() * CARDS.length)) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [label, setLabel] = useState(CARDS[idxRef.current % CARDS.length]);
const [status, setStatus] = useState("대기 중");
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(): void {
const card = cardRef.current;
const like = likeRef.current;
const nope = nopeRef.current;
if (!card || !like || !nope) return;
const rot = clamp(posRef.current * 0.05, -16, 16);
card.style.transform = "translateX(" + posRef.current.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
const o = stampOpacity(posRef.current);
if (posRef.current > 0) { like.style.opacity = o.toFixed(3); nope.style.opacity = "0"; }
else { nope.style.opacity = o.toFixed(3); like.style.opacity = "0"; }
}
function nextCard(): void {
idxRef.current += 1;
setLabel(CARDS[idxRef.current % CARDS.length]);
posRef.current = 0; velRef.current = 0; phaseRef.current = "idle";
render();
setStatus("대기 중");
}
function judge(velocity: number, dir: number): void {
setDemo(false);
const flung = Math.abs(velocity) >= RELEASE_VELOCITY_MIN;
if (reduce) {
if (flung) { setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨"); nextCard(); }
else { posRef.current = 0; render(); setStatus("제자리로 돌아옴"); phaseRef.current = "idle"; }
return;
}
if (flung) {
phaseRef.current = "exit"; exitDirRef.current = dir;
setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨");
} else {
phaseRef.current = "settle";
setStatus("제자리로 돌아옴");
}
}
function startDrag(clientX: number): void {
setDemo(false);
draggingRef.current = true; phaseRef.current = "idle";
startXRef.current = clientX; lastXRef.current = clientX; lastTRef.current = performance.now(); velRef.current = 0;
}
function moveDrag(clientX: number): void {
if (!draggingRef.current) return;
const now = performance.now();
const dt = Math.max(now - lastTRef.current, 1);
velRef.current = (clientX - lastXRef.current) / dt;
lastXRef.current = clientX; lastTRef.current = now;
posRef.current = clientX - startXRef.current;
render();
}
function endDrag(): void {
if (!draggingRef.current) return;
draggingRef.current = false;
judge(velRef.current, posRef.current >= 0 ? 1 : -1);
}
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>): void {
e.currentTarget.setPointerCapture(e.pointerId);
startDrag(e.clientX);
}
function onPointerMove(e: ReactPointerEvent<HTMLDivElement>): void { moveDrag(e.clientX); }
function onPointerUp(): void { endDrag(); }
function keyVerdict(dir: number): void {
setDemo(false);
posRef.current = dir * 40; render();
judge(dir * (RELEASE_VELOCITY_MIN + 0.2), dir);
}
function onKeyDown(e: any): void {
if (e.key === "ArrowRight") { e.preventDefault(); keyVerdict(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); keyVerdict(-1); }
}
function step(dt: number): void {
if (phaseRef.current === "exit") {
const speed = Math.max(Math.abs(velRef.current), RELEASE_VELOCITY_MIN * 1.4);
posRef.current += exitDirRef.current * speed * dt;
render();
if (Math.abs(posRef.current) > EXIT_PX) nextCard();
} else if (phaseRef.current === "settle") {
posRef.current += (0 - posRef.current) * 0.22;
render();
if (Math.abs(posRef.current) < 0.5) { posRef.current = 0; phaseRef.current = "idle"; render(); }
}
}
function synth(t: number, synthPhaseRef: Box<string>): void {
if (t > 0.4 && t < 0.75 && synthPhaseRef.current === "wait") synthPhaseRef.current = "toLike";
if (synthPhaseRef.current === "toLike") {
const p = clamp((t - 0.4) / 0.35, 0, 1);
posRef.current = p * 130; render();
if (t >= 0.75) { synthPhaseRef.current = "afterLike"; judge(RELEASE_VELOCITY_MIN + 0.3, 1); }
}
if (t > 1.3 && t < 1.65 && synthPhaseRef.current === "afterLike") synthPhaseRef.current = "toNope";
if (synthPhaseRef.current === "toNope") {
const q = clamp((t - 1.3) / 0.35, 0, 1);
posRef.current = -q * 130; render();
if (t >= 1.65) { synthPhaseRef.current = "done"; judge(-(RELEASE_VELOCITY_MIN + 0.3), -1); }
}
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0, lastNow = 0, prevT = 0;
const synthPhaseRef: Box<string> = { current: "wait" };
function loop(now: number): void {
if (start === 0) { start = now; lastNow = now; }
const dt = Math.min(now - lastNow, 48);
lastNow = now;
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhaseRef.current = "wait";
prevT = t;
if (!reduce && demo) synth(t, synthPhaseRef);
if (!reduce) step(dt);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
return (
<main className={styles.stage}>
<div className={styles.so}>
<p className={styles.caption}>제곱 곡선으로 짙어지는 판정 스탬프</p>
<div className={styles.scene}>
<div
className={styles.card}
ref={cardRef}
tabIndex={0}
role="group"
aria-roledescription="스와이프 카드"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
>
<span className={styles.stampLike} ref={likeRef} aria-hidden="true">LIKE</span>
<span className={styles.stampNope} ref={nopeRef} aria-hidden="true">NOPE</span>
<p className={styles.label}>{label}</p>
</div>
<button className={styles.btnNope} type="button" aria-label="싫어요" onClick={() => keyVerdict(-1)}>✕</button>
<button className={styles.btnLike} type="button" aria-label="좋아요" onClick={() => keyVerdict(1)}>♥</button>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
</div>
</main>
);
}05 · ドラッグ距離でにじむいいね・ノーの判子 — 計測 累積面積 24.239% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function stampOpacity(px) {
var ratio = clamp(Math.abs(px) / STAMP_RANGE_PX, 0, 1);
return Math.pow(ratio, STAMP_OPACITY_CURVE_POWER);
}
function render() {
var rot = clamp(x * 0.07, -18, 18);
card.style.transform = "translateX(" + x.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
var op = stampOpacity(x);
like.style.opacity = x > 0 ? op.toFixed(3) : "0";
nope.style.opacity = x < 0 ? op.toFixed(3) : "0";
}
計測ではこの判子の濃淡が画面の24.2%を占め、平均強度は53.0、動きの検出は6/23フレームです。
06逆補間で戻す最後のカード
元に戻すを押すと、直前にめくったカードは単にスナップして戻るのではなく、退出時に記録した経路を逆向きに、点ごとに補間しながらスタックの一番上へ戻ります。sampleReverse() がその保存された経路を逆にたどり、UNDO_DURATION_S が戻るまでの時間を決めます。

<main class="stage">
<div class="ur is-demo" id="ur">
<p class="ur__caption">退出経路を逆再生して戻すスワイプ</p>
<div class="ur__scene" id="urScene">
<div class="ur__card" id="urCard" tabindex="0" role="group" aria-roledescription="スワイプカード">
<p class="ur__label" id="urLabel">朝のルーティン</p>
</div>
<button class="ur__btn ur__btn--nope" id="urNope" type="button" aria-label="いいえ">✕</button>
<button class="ur__btn ur__btn--undo" id="urUndo" type="button" aria-label="元に戻す">↺</button>
<button class="ur__btn ur__btn--like" id="urLike" type="button" aria-label="いいね">♥</button>
</div>
<p class="ur__status" id="urStatus" aria-live="polite">待機中</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 나간 경로를 PATH_SAMPLE_MS 간격으로 기록해 두었다가, 되돌리기 시 시간축을
// 뒤집어(역경로 보간) 원래 지나온 자리를 그대로 거꾸로 재생한다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-yellow);
.ur { display: grid; gap: $sp-2; justify-items: center; width: 100%; }
.ur__caption { margin: 0; font-size: 12px; font-weight: 600; color: $subject-ink; }
.ur__scene {
position: relative;
width: 220px;
height: 190px;
display: grid;
place-items: center;
}
.ur__card {
position: relative;
width: 160px;
height: 180px;
border-radius: $r-card;
background: linear-gradient(160deg, $subject-cream, $subject-blue-light);
box-shadow: $shadow-raised;
display: grid;
place-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
}
.ur__card:active { cursor: grabbing; }
.ur__card:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
.ur__label { margin: 0; font-size: 14px; font-weight: 700; color: $subject-ink; padding: 0 $sp-3; text-align: center; }
.ur__btn {
position: absolute;
bottom: $sp-3;
width: 32px;
height: 32px;
border-radius: $r-pill;
border: 0;
background: $stage-paper;
box-shadow: $shadow-press;
font-size: 14px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.ur__btn:disabled { opacity: .35; cursor: default; }
.ur__btn--nope { left: $sp-2; color: $error; }
.ur__btn--like { right: $sp-2; color: $subject-blue-deep; }
.ur__btn--undo { left: 50%; transform: translateX(-50%); color: $subject-ink; }
.ur__btn:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.ur__status { margin: 0; font-size: 11px; color: $subject-ink; font-weight: 600; }
@media (max-width: 340px) {
.ur { gap: $sp-2; }
.ur__scene { width: 150px; height: 108px; }
.ur__card { width: 78px; height: 92px; }
.ur__label { font-size: 10px; padding: 0 $sp-1; }
.ur__btn { width: 20px; height: 20px; font-size: 10px; bottom: $sp-1; }
.ur__btn--nope { left: $sp-1; }
.ur__btn--like { right: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.ur__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ur {
display: grid;
gap: 8px;
justify-items: center;
width: 100%;
}
.ur__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.ur__scene {
position: relative;
width: 220px;
height: 190px;
display: grid;
place-items: center;
}
.ur__card {
position: relative;
width: 160px;
height: 180px;
border-radius: 16px;
background: linear-gradient(160deg, #fff7e6, #5b8cf8);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: grid;
place-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
}
.ur__card:active {
cursor: grabbing;
}
.ur__card:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.ur__label {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #17141a;
padding: 0 12px;
text-align: center;
}
.ur__btn {
position: absolute;
bottom: 12px;
width: 32px;
height: 32px;
border-radius: 999px;
border: 0;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-size: 14px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.ur__btn:disabled {
opacity: 0.35;
cursor: default;
}
.ur__btn--nope {
left: 8px;
color: #c2361a;
}
.ur__btn--like {
right: 8px;
color: #0a4fe8;
}
.ur__btn--undo {
left: 50%;
transform: translateX(-50%);
color: #17141a;
}
.ur__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.ur__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.ur {
gap: 8px;
}
.ur__scene {
width: 150px;
height: 108px;
}
.ur__card {
width: 78px;
height: 92px;
}
.ur__label {
font-size: 10px;
padding: 0 4px;
}
.ur__btn {
width: 20px;
height: 20px;
font-size: 10px;
bottom: 4px;
}
.ur__btn--nope {
left: 4px;
}
.ur__btn--like {
right: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.ur__card {
transition: none;
}
}(function () {
"use strict";
// 값 하나: 카드가 나간 실제 경로를 PATH_SAMPLE_MS 간격으로 기록해 두었다가,
// 되돌리기를 누르면 그 경로를 시간축을 뒤집어(역경로 보간) 그대로 재생한다.
var PATH_SAMPLE_MS = 40;
var UNDO_DURATION_S = 0.6;
var RELEASE_VELOCITY_MIN = 0.38;
var EXIT_PX = 320;
var CYCLE_S = 2;
var root = document.getElementById("ur");
var card = document.getElementById("urCard");
var label = document.getElementById("urLabel");
var status = document.getElementById("urStatus");
var btnLike = document.getElementById("urLike");
var btnNope = document.getElementById("urNope");
var btnUndo = document.getElementById("urUndo");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var CARDS = ["朝のルーティン", "夕方の散歩", "週末のカフェ", "宅トレ記録", "読書リスト", "旅行メモ"];
var rand = seedRand(78);
var idx = Math.floor(rand() * CARDS.length);
var dragging = false;
var startX = 0, lastX = 0, lastT = 0, vx = 0;
var x = 0, phase = "idle", exitDir = 1;
var lastSampleT = 0;
var path = []; // 이번 카드가 실제로 지나간 경로 {t, x}
var savedPath = null; // 직전에 나간 카드의 경로 — 되돌리기용
var savedLabel = null;
var undoStart = 0;
function setCard() { label.textContent = CARDS[idx % CARDS.length]; }
setCard();
function render() {
var rot = clamp(x * 0.08, -20, 20);
card.style.transform = "translateX(" + x.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
}
function recordSample(now) {
if (now - lastSampleT < PATH_SAMPLE_MS) return;
lastSampleT = now;
path.push({ t: now, x: x });
}
function nextCard(keepPath) {
if (keepPath && path.length >= 2) {
savedPath = path.slice();
savedLabel = label.textContent;
btnUndo.disabled = false;
}
idx += 1; setCard();
x = 0; vx = 0; phase = "idle"; path = [];
render();
status.textContent = "待機中";
}
function judge(velocity, dir) {
root.classList.remove("is-demo");
var flung = Math.abs(velocity) >= RELEASE_VELOCITY_MIN;
if (reduce) {
if (flung) {
status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み";
path = [{ t: performance.now() - 200, x: 0 }, { t: performance.now(), x: dir * EXIT_PX }];
nextCard(true);
} else { x = 0; render(); status.textContent = "元の位置に戻った"; phase = "idle"; }
return;
}
if (flung) {
phase = "exit"; exitDir = dir; path = [{ t: performance.now(), x: x }]; lastSampleT = performance.now();
status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み";
} else {
phase = "settle";
status.textContent = "元の位置に戻った";
}
}
function startDrag(clientX) {
root.classList.remove("is-demo");
dragging = true; phase = "idle"; path = []; lastSampleT = 0;
startX = clientX; lastX = clientX; lastT = performance.now(); vx = 0;
}
function moveDrag(clientX) {
if (!dragging) return;
var now = performance.now();
var dt = Math.max(now - lastT, 1);
vx = (clientX - lastX) / dt;
lastX = clientX; lastT = now;
x = clientX - startX;
render();
recordSample(now);
}
function endDrag() {
if (!dragging) return;
dragging = false;
judge(vx, x >= 0 ? 1 : -1);
}
card.addEventListener("pointerdown", function (e) { card.setPointerCapture(e.pointerId); startDrag(e.clientX); });
card.addEventListener("pointermove", function (e) { moveDrag(e.clientX); });
card.addEventListener("pointerup", endDrag);
card.addEventListener("pointercancel", endDrag);
function keyVerdict(dir) {
root.classList.remove("is-demo");
path = [{ t: performance.now() - 200, x: 0 }];
x = dir * 60; render();
judge(dir * (RELEASE_VELOCITY_MIN + 0.3), dir);
}
card.addEventListener("keydown", function (e) {
if (e.key === "ArrowRight") { e.preventDefault(); keyVerdict(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); keyVerdict(-1); }
});
btnLike.addEventListener("click", function () { keyVerdict(1); });
btnNope.addEventListener("click", function () { keyVerdict(-1); });
// 역경로 보간: 저장된 경로의 시간축을 뒤집어(T - u) 그 지점을 감싸는 두 샘플 사이를 선형보간한다.
function sampleReverse(u) {
var p = savedPath;
var t0 = p[0].t, t1 = p[p.length - 1].t;
var span = Math.max(t1 - t0, 1);
var frac = clamp(u / UNDO_DURATION_S, 0, 1);
var targetT = t1 - frac * span; // 시간을 거꾸로 훑는다
for (var i = 0; i < p.length - 1; i++) {
if (targetT >= p[i].t && targetT <= p[i + 1].t) {
var seg = Math.max(p[i + 1].t - p[i].t, 0.001);
var localFrac = (targetT - p[i].t) / seg;
return p[i].x + (p[i + 1].x - p[i].x) * localFrac;
}
}
return p[0].x;
}
function startUndo() {
if (!savedPath || savedPath.length < 2) return;
root.classList.remove("is-demo");
idx -= 1; setCard();
if (reduce) {
x = savedPath[0].x; render();
phase = "idle"; savedPath = null; btnUndo.disabled = true;
status.textContent = "元に戻した";
return;
}
phase = "undo"; btnUndo.disabled = true;
undoStart = performance.now();
status.textContent = "経路を逆再生して元に戻しています";
}
btnUndo.addEventListener("click", startUndo);
btnUndo.disabled = true;
function step(dt, now) {
if (phase === "exit") {
x += exitDir * Math.max(Math.abs(vx), RELEASE_VELOCITY_MIN * 1.4) * dt;
render();
recordSample(now);
if (Math.abs(x) > EXIT_PX) { path.push({ t: now, x: x }); nextCard(true); }
} else if (phase === "settle") {
x += (0 - x) * 0.22;
render();
if (Math.abs(x) < 0.4) { x = 0; phase = "idle"; render(); }
} else if (phase === "undo") {
var u = (now - undoStart) / 1000;
if (u >= UNDO_DURATION_S) {
x = savedPath[0].x; render();
phase = "idle"; savedPath = null; btnUndo.disabled = true;
status.textContent = "元に戻した";
} else {
x = sampleReverse(u);
render();
}
}
}
// .is-demo 자동 시연: 실제 pointer 없이 x·judge()·되돌리기를 같은 경로로 호출한다.
var start = null, lastNow = 0, prevT = 0, synthPhase = "wait";
function synth(t, now) {
if (t > 0.35 && t < 0.6 && synthPhase === "wait") synthPhase = "drag";
if (synthPhase === "drag") {
var p = clamp((t - 0.35) / 0.25, 0, 1);
x = p * 120; render(); recordSample(now);
if (t >= 0.6) { synthPhase = "exiting"; judge(RELEASE_VELOCITY_MIN + 0.4, 1); }
}
if (t > 1.15 && synthPhase === "exiting" && phase === "idle") synthPhase = "undoing";
if (synthPhase === "undoing" && phase !== "undo") { startUndo(); synthPhase = "undone"; }
}
function loop(now) {
if (start === null) { start = now; lastNow = now; lastT = now; }
var dt = Math.min(now - lastNow, 48);
lastNow = now;
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhase = "wait";
prevT = t;
if (!reduce && root.classList.contains("is-demo")) synth(t, now);
if (!reduce) step(dt, now);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// UndoReverseInterpSwipeCard — vanilla 판(run/422/demo/06-swipe-deck-precision-undo-reverse-interp/index.html) 의 React 포트.
// 값 하나: 드래그·퇴장 중 PATH_SAMPLE_MS 간격으로 {t,x} 경로를 기록해 두고,
// "되돌리기" 를 누르면 그 경로를 시간 역순으로 선형보간(sampleReverse)해 그대로 되감는다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./UndoReverseInterpSwipeCard.module.scss";
type Box<T> = { current: T };
type Sample = { t: number; x: number };
const PATH_SAMPLE_MS = 40;
const RELEASE_VELOCITY_MIN = 0.4;
const EXIT_PX = 320;
const UNDO_DURATION_MS = 420;
const CYCLE_S = 2;
const CARDS = ["독서 목록", "여행 메모", "아침 루틴", "저녁 산책", "주말 카페", "홈트 기록"];
function seedRand(seed: number) {
let s = seed >>> 0;
return function (): number {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function sampleReverse(path: Sample[], u: number): number {
if (path.length === 0) return 0;
if (path.length === 1) return path[0].x;
const t0 = path[0].t;
const t1 = path[path.length - 1].t;
const span = t1 - t0;
const targetT = t1 - clamp(u, 0, 1) * span;
for (let i = 0; i < path.length - 1; i += 1) {
const a = path[i];
const b = path[i + 1];
if (targetT >= a.t && targetT <= b.t) {
const frac = b.t === a.t ? 0 : (targetT - a.t) / (b.t - a.t);
return a.x + (b.x - a.x) * frac;
}
}
return path[0].x;
}
export default function UndoReverseInterpSwipeCard() {
const cardRef = useRef<HTMLDivElement | null>(null);
const posRef = useRef<number>(0) as Box<number>;
const velRef = useRef<number>(0) as Box<number>;
const phaseRef = useRef<string>("idle") as Box<string>;
const exitDirRef = useRef<number>(1) as Box<number>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const startXRef = useRef<number>(0) as Box<number>;
const lastXRef = useRef<number>(0) as Box<number>;
const lastTRef = useRef<number>(0) as Box<number>;
const lastSampleTRef = useRef<number>(0) as Box<number>;
const pathRef = useRef<Sample[]>([]) as Box<Sample[]>;
const savedPathRef = useRef<Sample[]>([]) as Box<Sample[]>;
const prevIdxRef = useRef<number>(0) as Box<number>;
const undoStartRef = useRef<number>(0) as Box<number>;
const idxRef = useRef<number>(Math.floor(seedRand(61)() * CARDS.length)) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [label, setLabel] = useState(CARDS[idxRef.current % CARDS.length]);
const [status, setStatus] = useState("대기 중");
const [canUndo, setCanUndo] = useState(false);
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(): void {
const card = cardRef.current;
if (!card) return;
const rot = clamp(posRef.current * 0.05, -16, 16);
card.style.transform = "translateX(" + posRef.current.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
}
function recordSample(now: number): void {
if (now - lastSampleTRef.current < PATH_SAMPLE_MS) return;
lastSampleTRef.current = now;
pathRef.current.push({ t: now, x: posRef.current });
}
function nextCard(keepPath: boolean): void {
prevIdxRef.current = idxRef.current;
if (keepPath) savedPathRef.current = pathRef.current.slice();
idxRef.current += 1;
setLabel(CARDS[idxRef.current % CARDS.length]);
posRef.current = 0; velRef.current = 0; phaseRef.current = "idle"; pathRef.current = [];
render();
setStatus("대기 중");
setCanUndo(keepPath && savedPathRef.current.length > 1);
}
function judge(velocity: number, dir: number): void {
setDemo(false);
const flung = Math.abs(velocity) >= RELEASE_VELOCITY_MIN;
if (reduce) {
if (flung) {
const now = performance.now();
pathRef.current = [{ t: now - 200, x: 0 }, { t: now, x: dir * EXIT_PX }];
setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨");
nextCard(true);
} else {
posRef.current = 0; render(); setStatus("제자리로 돌아옴"); phaseRef.current = "idle"; pathRef.current = [];
}
return;
}
if (flung) {
phaseRef.current = "exit"; exitDirRef.current = dir;
setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨");
} else {
phaseRef.current = "settle";
setStatus("제자리로 돌아옴");
}
}
function startDrag(clientX: number): void {
setDemo(false);
draggingRef.current = true; phaseRef.current = "idle";
startXRef.current = clientX; lastXRef.current = clientX; lastTRef.current = performance.now(); velRef.current = 0;
pathRef.current = [{ t: performance.now(), x: 0 }];
lastSampleTRef.current = performance.now();
}
function moveDrag(clientX: number): void {
if (!draggingRef.current) return;
const now = performance.now();
const dt = Math.max(now - lastTRef.current, 1);
velRef.current = (clientX - lastXRef.current) / dt;
lastXRef.current = clientX; lastTRef.current = now;
posRef.current = clientX - startXRef.current;
render();
recordSample(now);
}
function endDrag(): void {
if (!draggingRef.current) return;
draggingRef.current = false;
judge(velRef.current, posRef.current >= 0 ? 1 : -1);
}
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>): void {
e.currentTarget.setPointerCapture(e.pointerId);
startDrag(e.clientX);
}
function onPointerMove(e: ReactPointerEvent<HTMLDivElement>): void { moveDrag(e.clientX); }
function onPointerUp(): void { endDrag(); }
function keyVerdict(dir: number): void {
setDemo(false);
const now = performance.now();
pathRef.current = [{ t: now - 160, x: 0 }, { t: now, x: dir * 40 }];
posRef.current = dir * 40; render();
judge(dir * (RELEASE_VELOCITY_MIN + 0.2), dir);
}
function onKeyDown(e: any): void {
if (e.key === "ArrowRight") { e.preventDefault(); keyVerdict(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); keyVerdict(-1); }
}
function startUndo(): void {
if (savedPathRef.current.length < 2) return;
setDemo(false);
if (reduce) {
posRef.current = savedPathRef.current[0].x; render();
idxRef.current = prevIdxRef.current;
setLabel(CARDS[idxRef.current % CARDS.length]);
phaseRef.current = "idle";
savedPathRef.current = [];
setCanUndo(false);
setStatus("되돌림");
return;
}
phaseRef.current = "undo"; setCanUndo(false);
undoStartRef.current = performance.now();
setStatus("경로를 거꾸로 재생해 되돌리는 중");
}
function step(dt: number, now: number): void {
if (phaseRef.current === "exit") {
const speed = Math.max(Math.abs(velRef.current), RELEASE_VELOCITY_MIN * 1.4);
posRef.current += exitDirRef.current * speed * dt;
render();
recordSample(now);
if (Math.abs(posRef.current) > EXIT_PX) nextCard(true);
} else if (phaseRef.current === "settle") {
posRef.current += (0 - posRef.current) * 0.22;
render();
if (Math.abs(posRef.current) < 0.5) { posRef.current = 0; phaseRef.current = "idle"; render(); pathRef.current = []; }
} else if (phaseRef.current === "undo") {
const u = clamp((now - undoStartRef.current) / UNDO_DURATION_MS, 0, 1);
posRef.current = sampleReverse(savedPathRef.current, u);
render();
if (u >= 1) {
idxRef.current = prevIdxRef.current;
setLabel(CARDS[idxRef.current % CARDS.length]);
posRef.current = 0; render();
phaseRef.current = "idle";
savedPathRef.current = [];
setCanUndo(false);
setStatus("되돌림");
}
}
}
function synth(t: number, synthPhaseRef: Box<string>): void {
if (t > 0.35 && t < 0.65 && synthPhaseRef.current === "wait") synthPhaseRef.current = "swipe";
if (synthPhaseRef.current === "swipe") {
const p = clamp((t - 0.35) / 0.3, 0, 1);
posRef.current = p * 110; render();
recordSample(performance.now());
if (t >= 0.65) { synthPhaseRef.current = "afterSwipe"; judge(RELEASE_VELOCITY_MIN + 0.3, 1); }
}
if (t > 1.3 && t < 1.55 && synthPhaseRef.current === "afterSwipe") { synthPhaseRef.current = "undoing"; startUndo(); }
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0, lastNow = 0, prevT = 0;
const synthPhaseRef: Box<string> = { current: "wait" };
function loop(now: number): void {
if (start === 0) { start = now; lastNow = now; }
const dt = Math.min(now - lastNow, 48);
lastNow = now;
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhaseRef.current = "wait";
prevT = t;
if (!reduce && demo) synth(t, synthPhaseRef);
if (!reduce) step(dt, now);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
return (
<main className={styles.stage}>
<div className={styles.ur}>
<p className={styles.caption}>기록한 경로를 거꾸로 되감는 되돌리기</p>
<div className={styles.scene}>
<div
className={styles.card}
ref={cardRef}
tabIndex={0}
role="group"
aria-roledescription="스와이프 카드"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
>
<p className={styles.label}>{label}</p>
</div>
<button className={styles.btnNope} type="button" aria-label="싫어요" onClick={() => keyVerdict(-1)}>✕</button>
<button className={styles.btnUndo} type="button" aria-label="되돌리기" disabled={!canUndo} onClick={startUndo}>↺</button>
<button className={styles.btnLike} type="button" aria-label="좋아요" onClick={() => keyVerdict(1)}>♥</button>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
</div>
</main>
);
}06 · 逆補間で戻す最後のカード — 計測 累積面積 48.014% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function sampleReverse(u) {
var p = savedPath;
var t0 = p[0].t, t1 = p[p.length - 1].t;
var span = Math.max(t1 - t0, 1);
var frac = clamp(u / UNDO_DURATION_S, 0, 1);
var targetT = t1 - frac * span; // 時間を逆方向になぞる
for (var i = 0; i < p.length - 1; i++) {
if (targetT >= p[i].t && targetT <= p[i + 1].t) {
var seg = Math.max(p[i + 1].t - p[i].t, 0.001);
var localFrac = (targetT - p[i].t) / seg;
return p[i].x + (p[i + 1].x - p[i].x) * localFrac;
}
}
return p[0].x;
}
計測ではこの逆補間による復帰が画面の48.0%を占め、平均強度は128.9、動きの検出は6/23フレームです。
07矢印キー・ボタンでも同じ判定を出す代替経路
ドラッグせず矢印キーやボタンを押しても、ドラッグと同じいいね・ノーの判定経路をそのまま通ります — キーボード専用の別結果はありません。キーを押し続けると CHARGE_RATE で値がたまり、離した瞬間に CHARGE_DECAY で減っていき、この充電量がドラッグの離す速度の代わりを果たします。

<main class="stage">
<div class="kf is-demo" id="kf">
<p class="kf__caption">キーを押し続けた時間で力が溜まるキーボードスワイプ</p>
<div class="kf__scene" id="kfScene">
<div class="kf__card" id="kfCard" tabindex="0" role="group" aria-roledescription="スワイプカード">
<p class="kf__label" id="kfLabel">読書リスト</p>
<div class="kf__meter" aria-hidden="true"><span class="kf__meter-fill" id="kfMeterFill"></span></div>
</div>
<button class="kf__btn kf__btn--nope" id="kfNope" type="button" aria-label="いいえ">✕</button>
<button class="kf__btn kf__btn--like" id="kfLike" type="button" aria-label="いいね">♥</button>
</div>
<p class="kf__status" id="kfStatus" aria-live="polite">カードにフォーカスして矢印キーを押し続けると力が溜まります</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 방향키를 누르고 있는 시간만큼 CHARGE_RATE 로 힘이 쌓이고, 떼는 순간 그 값을
// 드래그 속도와 같은 judge() 로 판정한다 — 계기판이 충전량을 그대로 보여준다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.kf { display: grid; gap: $sp-2; justify-items: center; width: 100%; }
.kf__caption { margin: 0; font-size: 12px; font-weight: 600; color: $subject-ink; }
.kf__scene {
position: relative;
width: 220px;
height: 180px;
display: grid;
place-items: center;
}
.kf__card {
position: relative;
width: 160px;
height: 180px;
border-radius: $r-card;
background: linear-gradient(160deg, $subject-lilac, $subject-blue);
box-shadow: $shadow-raised;
display: grid;
gap: $sp-3;
align-content: center;
justify-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
}
.kf__card:active { cursor: grabbing; }
.kf__card:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
.kf__label { margin: 0; font-size: 14px; font-weight: 700; color: $subject-ink; padding: 0 $sp-3; text-align: center; }
.kf__meter {
position: relative;
width: 100px;
height: 6px;
border-radius: $r-pill;
background: rgba(255, 255, 255, .45);
overflow: hidden;
}
.kf__meter-fill {
position: absolute;
top: 0; bottom: 0;
width: 0%;
left: 50%;
background: $subject-blue-deep;
border-radius: $r-pill;
}
.kf__btn {
position: absolute;
bottom: $sp-3;
width: 34px;
height: 34px;
border-radius: $r-pill;
border: 0;
background: $stage-paper;
box-shadow: $shadow-press;
font-size: 15px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.kf__btn--nope { left: $sp-3; color: $error; }
.kf__btn--like { right: $sp-3; color: $subject-blue-deep; }
.kf__btn:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.kf__status { margin: 0; font-size: 11px; color: $subject-ink; font-weight: 600; text-align: center; max-width: 240px; }
@media (max-width: 340px) {
.kf { gap: $sp-2; }
.kf__scene { width: 150px; height: 108px; }
.kf__card { width: 78px; height: 92px; gap: $sp-1; }
.kf__label { font-size: 10px; padding: 0 $sp-1; }
.kf__meter { width: 56px; height: 4px; }
.kf__btn { width: 22px; height: 22px; font-size: 11px; bottom: $sp-1; }
.kf__btn--nope { left: $sp-1; }
.kf__btn--like { right: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.kf__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);
}
.kf {
display: grid;
gap: 8px;
justify-items: center;
width: 100%;
}
.kf__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.kf__scene {
position: relative;
width: 220px;
height: 180px;
display: grid;
place-items: center;
}
.kf__card {
position: relative;
width: 160px;
height: 180px;
border-radius: 16px;
background: linear-gradient(160deg, #b9a0ff, #2f6df6);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: grid;
gap: 12px;
align-content: center;
justify-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
}
.kf__card:active {
cursor: grabbing;
}
.kf__card:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.kf__label {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #17141a;
padding: 0 12px;
text-align: center;
}
.kf__meter {
position: relative;
width: 100px;
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.45);
overflow: hidden;
}
.kf__meter-fill {
position: absolute;
top: 0;
bottom: 0;
width: 0%;
left: 50%;
background: #0a4fe8;
border-radius: 999px;
}
.kf__btn {
position: absolute;
bottom: 12px;
width: 34px;
height: 34px;
border-radius: 999px;
border: 0;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-size: 15px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.kf__btn--nope {
left: 12px;
color: #c2361a;
}
.kf__btn--like {
right: 12px;
color: #0a4fe8;
}
.kf__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.kf__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
text-align: center;
max-width: 240px;
}
@media (max-width: 340px) {
.kf {
gap: 8px;
}
.kf__scene {
width: 150px;
height: 108px;
}
.kf__card {
width: 78px;
height: 92px;
gap: 4px;
}
.kf__label {
font-size: 10px;
padding: 0 4px;
}
.kf__meter {
width: 56px;
height: 4px;
}
.kf__btn {
width: 22px;
height: 22px;
font-size: 11px;
bottom: 4px;
}
.kf__btn--nope {
left: 4px;
}
.kf__btn--like {
right: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.kf__card {
transition: none;
}
}(function () {
"use strict";
// 값 하나: 방향키를 누르고 있는 시간만큼 CHARGE_RATE 로 힘이 쌓이고(적분), 떼는 순간
// 그 값이 CHARGE_RELEASE_MIN 을 넘었는지로 판정한다 — 드래그의 速度 판정과 같은 judge() 를 공유한다.
var CHARGE_RATE = 1.6; // 초당 쌓이는 양
var CHARGE_DECAY = 3.2; // 떼면 초당 줄어드는 양
var CHARGE_RELEASE_MIN = 0.55;
var CHARGE_MAX = 1;
var EXIT_PX = 320;
var CYCLE_S = 2;
var root = document.getElementById("kf");
var card = document.getElementById("kfCard");
var label = document.getElementById("kfLabel");
var meterFill = document.getElementById("kfMeterFill");
var status = document.getElementById("kfStatus");
var btnLike = document.getElementById("kfLike");
var btnNope = document.getElementById("kfNope");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var CARDS = ["読書リスト", "旅行メモ", "朝のルーティン", "夕方の散歩", "週末のカフェ", "宅トレ記録"];
var rand = seedRand(90);
var idx = Math.floor(rand() * CARDS.length);
var dragging = false;
var startX = 0, lastX = 0, lastT = 0, vx = 0;
var x = 0, phase = "idle", exitDir = 1;
var charge = 0, chargingDir = 0, keyDown = { left: false, right: false };
function setCard() { label.textContent = CARDS[idx % CARDS.length]; }
setCard();
function render() {
var rot = clamp(x * 0.08, -20, 20);
card.style.transform = "translateX(" + x.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
meterFill.style.width = (clamp(charge, 0, CHARGE_MAX) * 100).toFixed(0) + "%";
meterFill.style.left = chargingDir < 0 ? (50 - clamp(charge, 0, CHARGE_MAX) * 50).toFixed(1) + "%" : "50%";
}
function nextCard() {
idx += 1; setCard();
x = 0; vx = 0; phase = "idle"; charge = 0; chargingDir = 0;
render();
}
// 드래그 速度(px/ms)든 키보드 충전량(0~1)이든 같은 judge() 로 들어와 하나의 규칙으로 판정한다.
function judge(strength, dir, releaseMin) {
root.classList.remove("is-demo");
var flung = Math.abs(strength) >= releaseMin;
if (reduce) {
if (flung) { status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み"; nextCard(); }
else { x = 0; charge = 0; render(); status.textContent = "力が足りず戻った"; phase = "idle"; }
return;
}
if (flung) { phase = "exit"; exitDir = dir; status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み"; }
else { phase = "settle"; status.textContent = "力が足りず戻った"; }
}
function startDrag(clientX) {
root.classList.remove("is-demo");
dragging = true; phase = "idle";
startX = clientX; lastX = clientX; lastT = performance.now(); vx = 0;
}
function moveDrag(clientX) {
if (!dragging) return;
var now = performance.now();
var dt = Math.max(now - lastT, 1);
vx = (clientX - lastX) / dt;
lastX = clientX; lastT = now;
x = clientX - startX;
render();
}
function endDrag() {
if (!dragging) return;
dragging = false;
judge(vx, x >= 0 ? 1 : -1, 0.38);
}
card.addEventListener("pointerdown", function (e) { card.setPointerCapture(e.pointerId); startDrag(e.clientX); });
card.addEventListener("pointermove", function (e) { moveDrag(e.clientX); });
card.addEventListener("pointerup", endDrag);
card.addEventListener("pointercancel", endDrag);
function clickVerdict(dir) {
root.classList.remove("is-demo");
x = dir * 60; render();
judge(0.7, dir, CHARGE_RELEASE_MIN);
}
btnLike.addEventListener("click", function () { clickVerdict(1); });
btnNope.addEventListener("click", function () { clickVerdict(-1); });
card.addEventListener("keydown", function (e) {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
if (phase !== "idle" && phase !== "charging") return;
root.classList.remove("is-demo");
var dir = e.key === "ArrowRight" ? 1 : -1;
if (chargingDir !== 0 && chargingDir !== dir) { charge = 0; }
chargingDir = dir; phase = "charging";
(dir > 0 ? keyDown : keyDown).right = e.key === "ArrowRight";
keyDown.left = e.key === "ArrowLeft";
keyDown.right = e.key === "ArrowRight";
status.textContent = "力を溜めています";
});
card.addEventListener("keyup", function (e) {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
if (e.key === "ArrowLeft") keyDown.left = false;
if (e.key === "ArrowRight") keyDown.right = false;
if (phase === "charging") {
x = chargingDir * charge * 90; render();
judge(charge, chargingDir, CHARGE_RELEASE_MIN);
}
});
function step(dt) {
var dtS = dt / 1000;
if (phase === "charging") {
var held = chargingDir > 0 ? keyDown.right : keyDown.left;
charge = clamp(charge + (held ? CHARGE_RATE : -CHARGE_DECAY) * dtS, 0, CHARGE_MAX);
x = chargingDir * charge * 90;
render();
} else if (phase === "exit") {
x += exitDir * Math.max(Math.abs(vx) || 0.6, 0.6) * dt;
render();
if (Math.abs(x) > EXIT_PX) nextCard();
} else if (phase === "settle") {
x += (0 - x) * Math.min(dtS * 6, 1);
charge += (0 - charge) * Math.min(dtS * 5, 1);
render();
if (Math.abs(x) < 0.4) { x = 0; charge = 0; phase = "idle"; render(); }
}
}
// .is-demo 자동 시연: 실제 keydown/keyup 없이 charge·judge() 를 같은 경로로 호출한다.
var start = null, lastNow = 0, prevT = 0, synthPhase = "wait";
function synth(t) {
if (t > 0.35 && t < 0.55 && synthPhase === "wait") { synthPhase = "weak"; }
if (synthPhase === "weak") {
var p = clamp((t - 0.35) / 0.2, 0, 1);
charge = p * 0.35; x = charge * 90; render();
if (t >= 0.55) { synthPhase = "afterWeak"; judge(charge, 1, CHARGE_RELEASE_MIN); }
}
if (t > 1.1 && t < 1.5 && synthPhase === "afterWeak") synthPhase = "strong";
if (synthPhase === "strong") {
var q = clamp((t - 1.1) / 0.4, 0, 1);
charge = q; x = -charge * 90; render();
if (t >= 1.5) { synthPhase = "done"; judge(charge, -1, CHARGE_RELEASE_MIN); }
}
}
function loop(now) {
if (start === null) { start = now; lastNow = now; lastT = now; }
var dt = Math.min(now - lastNow, 48);
lastNow = now;
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhase = "wait";
prevT = t;
if (!reduce && root.classList.contains("is-demo")) synth(t);
if (!reduce) step(dt);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// KeyboardFallbackSwipeCard — vanilla 판(run/422/demo/07-swipe-deck-precision-keyboard-fallback/index.html) 의 React 포트.
// 값 하나: 드래그·버튼·키보드 세 입력이 전부 같은 judge(strength, dir, releaseMin) 를 부른다.
// 화살표 키는 누르고 있는 동안 CHARGE_RATE 로 충전되고 떼면(release) CHARGE_RELEASE_MIN 기준으로 판정한다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./KeyboardFallbackSwipeCard.module.scss";
type Box<T> = { current: T };
const CHARGE_RATE = 1.6;
const CHARGE_DECAY = 3.2;
const CHARGE_RELEASE_MIN = 0.55;
const CHARGE_MAX = 1;
const DRAG_RELEASE_MIN = 0.38;
const EXIT_PX = 320;
const CYCLE_S = 2;
const CARDS = ["저녁 산책", "주말 카페", "홈트 기록", "독서 목록", "여행 메모", "아침 루틴"];
function seedRand(seed: number) {
let s = seed >>> 0;
return function (): number {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function KeyboardFallbackSwipeCard() {
const cardRef = useRef<HTMLDivElement | null>(null);
const meterFillRef = useRef<HTMLDivElement | null>(null);
const posRef = useRef<number>(0) as Box<number>;
const velRef = useRef<number>(0) as Box<number>;
const phaseRef = useRef<string>("idle") as Box<string>;
const exitDirRef = useRef<number>(1) as Box<number>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const startXRef = useRef<number>(0) as Box<number>;
const lastXRef = useRef<number>(0) as Box<number>;
const lastTRef = useRef<number>(0) as Box<number>;
const chargeRef = useRef<number>(0) as Box<number>;
const chargingDirRef = useRef<number>(0) as Box<number>;
const idxRef = useRef<number>(Math.floor(seedRand(67)() * CARDS.length)) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [label, setLabel] = useState(CARDS[idxRef.current % CARDS.length]);
const [status, setStatus] = useState("대기 중 — 방향키를 누르고 있으면 충전됩니다");
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(): void {
const card = cardRef.current;
if (!card) return;
const rot = clamp(posRef.current * 0.05, -16, 16);
card.style.transform = "translateX(" + posRef.current.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
}
function renderMeter(): void {
const fill = meterFillRef.current;
if (!fill) return;
fill.style.width = (chargeRef.current * 100).toFixed(1) + "%";
}
function nextCard(): void {
idxRef.current += 1;
setLabel(CARDS[idxRef.current % CARDS.length]);
posRef.current = 0; velRef.current = 0; phaseRef.current = "idle";
render();
setStatus("대기 중 — 방향키를 누르고 있으면 충전됩니다");
}
// 세 입력(드래그·버튼·키보드 충전)이 전부 이 함수 하나로 판정한다.
function judge(strength: number, dir: number, releaseMin: number): void {
setDemo(false);
const flung = strength >= releaseMin;
if (reduce) {
if (flung) { setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨"); nextCard(); }
else { posRef.current = 0; render(); setStatus("충전 부족 — 되돌아옴"); phaseRef.current = "idle"; }
return;
}
if (flung) {
phaseRef.current = "exit"; exitDirRef.current = dir;
setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨");
} else {
phaseRef.current = "settle";
setStatus("충전 부족 — 되돌아옴");
}
}
function startDrag(clientX: number): void {
setDemo(false);
draggingRef.current = true; phaseRef.current = "idle";
startXRef.current = clientX; lastXRef.current = clientX; lastTRef.current = performance.now(); velRef.current = 0;
}
function moveDrag(clientX: number): void {
if (!draggingRef.current) return;
const now = performance.now();
const dt = Math.max(now - lastTRef.current, 1);
velRef.current = (clientX - lastXRef.current) / dt;
lastXRef.current = clientX; lastTRef.current = now;
posRef.current = clientX - startXRef.current;
render();
}
function endDrag(): void {
if (!draggingRef.current) return;
draggingRef.current = false;
judge(Math.abs(velRef.current), posRef.current >= 0 ? 1 : -1, DRAG_RELEASE_MIN);
}
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>): void {
e.currentTarget.setPointerCapture(e.pointerId);
startDrag(e.clientX);
}
function onPointerMove(e: ReactPointerEvent<HTMLDivElement>): void { moveDrag(e.clientX); }
function onPointerUp(): void { endDrag(); }
function buttonVerdict(dir: number): void {
setDemo(false);
posRef.current = dir * 40; render();
judge(1, dir, CHARGE_RELEASE_MIN);
}
function onKeyDown(e: any): void {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
setDemo(false);
const dir = e.key === "ArrowRight" ? 1 : -1;
if (chargingDirRef.current !== dir) { chargingDirRef.current = dir; }
}
function onKeyUp(e: any): void {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
const dir = e.key === "ArrowRight" ? 1 : -1;
if (chargingDirRef.current === dir) {
const strength = chargeRef.current;
chargingDirRef.current = 0;
posRef.current = dir * 30 * strength; render();
judge(strength, dir, CHARGE_RELEASE_MIN);
chargeRef.current = 0; renderMeter();
}
}
function step(dt: number): void {
const dtS = dt / 1000;
if (chargingDirRef.current !== 0 && phaseRef.current === "idle") {
chargeRef.current = clamp(chargeRef.current + CHARGE_RATE * dtS, 0, CHARGE_MAX);
} else if (chargeRef.current > 0) {
chargeRef.current = clamp(chargeRef.current - CHARGE_DECAY * dtS, 0, CHARGE_MAX);
}
renderMeter();
if (phaseRef.current === "exit") {
const speed = Math.max(Math.abs(velRef.current), CHARGE_RELEASE_MIN * 1.2);
posRef.current += exitDirRef.current * speed * dt;
render();
if (Math.abs(posRef.current) > EXIT_PX) nextCard();
} else if (phaseRef.current === "settle") {
posRef.current += (0 - posRef.current) * 0.22;
render();
if (Math.abs(posRef.current) < 0.5) { posRef.current = 0; phaseRef.current = "idle"; render(); }
}
}
function synth(t: number, synthPhaseRef: Box<string>): void {
if (t > 0.3 && t < 0.85 && synthPhaseRef.current === "wait") synthPhaseRef.current = "charge";
if (synthPhaseRef.current === "charge") {
const p = clamp((t - 0.3) / 0.55, 0, 1);
chargeRef.current = p; renderMeter();
if (t >= 0.85) { synthPhaseRef.current = "afterCharge"; judge(chargeRef.current, 1, CHARGE_RELEASE_MIN); chargeRef.current = 0; renderMeter(); }
}
if (t > 1.35 && t < 1.5 && synthPhaseRef.current === "afterCharge") synthPhaseRef.current = "tap";
if (synthPhaseRef.current === "tap") { synthPhaseRef.current = "done"; buttonVerdict(-1); }
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0, lastNow = 0, prevT = 0;
const synthPhaseRef: Box<string> = { current: "wait" };
function loop(now: number): void {
if (start === 0) { start = now; lastNow = now; }
const dt = Math.min(now - lastNow, 48);
lastNow = now;
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhaseRef.current = "wait";
prevT = t;
if (!reduce && demo) synth(t, synthPhaseRef);
if (!reduce) step(dt);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
return (
<main className={styles.stage}>
<div className={styles.kf}>
<p className={styles.caption}>충전해서 놓는 키보드 판정</p>
<div className={styles.scene}>
<div
className={styles.card}
ref={cardRef}
tabIndex={0}
role="group"
aria-roledescription="스와이프 카드"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
<p className={styles.label}>{label}</p>
</div>
<div className={styles.meter} aria-hidden="true">
<div className={styles.meterFill} ref={meterFillRef} />
</div>
<button className={styles.btnNope} type="button" aria-label="싫어요" onClick={() => buttonVerdict(-1)}>✕</button>
<button className={styles.btnLike} type="button" aria-label="좋아요" onClick={() => buttonVerdict(1)}>♥</button>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
</div>
</main>
);
}07 · 矢印キー・ボタンでも同じ判定を出す代替経路 — 計測 累積面積 18.896% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function step(dt) {
var dtS = dt / 1000;
if (phase === "charging") {
var held = chargingDir > 0 ? keyDown.right : keyDown.left;
charge = clamp(charge + (held ? CHARGE_RATE : -CHARGE_DECAY) * dtS, 0, CHARGE_MAX);
x = chargingDir * charge * 90;
render();
} else if (phase === "exit") {
x += exitDir * Math.max(Math.abs(vx) || 0.6, 0.6) * dt;
render();
if (Math.abs(x) > EXIT_PX) nextCard();
} else if (phase === "settle") {
x += (0 - x) * Math.min(dtS * 6, 1);
charge += (0 - charge) * Math.min(dtS * 5, 1);
render();
if (Math.abs(x) < 0.4) { x = 0; charge = 0; phase = "idle"; render(); }
}
}
計測ではこの充電から解放までの動きが画面の18.9%を占め、平均強度は71.7、動きの検出は9/23フレームです。
08ばねで跳ねて現れる空スタックカード
カードをすべてめくると空状態の案内が出て、再補充を押すと新しいカードはただフェードインするのではなく、実寸を通り越してからばねで跳ねて落ち着きます。REFILL_SPRING_STIFFNESS と REFILL_SPRING_DAMPING は03番と同じ加速・収束のループを、位置ではなく拡大率に対して回しています。

<main class="stage">
<div class="er is-demo" id="er">
<p class="er__caption">全部スワイプするとスプリングで弾んで補充されるカード</p>
<div class="er__scene" id="erScene">
<div class="er__empty" id="erEmpty" hidden>
<p class="er__empty-text">今日のカードは全部見ました</p>
<button class="er__refill" id="erRefill" type="button">補充</button>
</div>
<div class="er__card" id="erCard" tabindex="0" role="group" aria-roledescription="スワイプカード">
<p class="er__label" id="erLabel">夕方の散歩</p>
</div>
<button class="er__btn er__btn--nope" id="erNope" type="button" aria-label="いいえ">✕</button>
<button class="er__btn er__btn--like" id="erLike" type="button" aria-label="いいね">♥</button>
</div>
<p class="er__status" id="erStatus" aria-live="polite">待機中</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: REFILL_COUNT 장을 다 넘기면 빈 상태가 뜨고, 다시 채우면 스프링을 scale 에 적분해
// 오버슛하며 튀어 들어온다.
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-blue-deep;
@include stage($stage-ink);
.er { display: grid; gap: $sp-2; justify-items: center; width: 100%; }
.er__caption { margin: 0; font-size: 12px; font-weight: 600; color: $subject-cream; }
.er__scene {
position: relative;
width: 220px;
height: 190px;
display: grid;
place-items: center;
}
.er__card {
position: relative;
width: 160px;
height: 180px;
border-radius: $r-card;
background: linear-gradient(160deg, $subject-pink, $subject-lilac);
box-shadow: $shadow-raised;
display: grid;
place-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
}
.er__card:active { cursor: grabbing; }
.er__card:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
.er__card[hidden] { display: none; }
.er__label { margin: 0; font-size: 14px; font-weight: 700; color: $subject-ink; padding: 0 $sp-3; text-align: center; }
.er__empty {
width: 160px;
height: 180px;
border-radius: $r-card;
border: 2px dashed rgba(255, 247, 230, .4);
display: grid;
gap: $sp-3;
place-items: center;
padding: $sp-3;
text-align: center;
}
.er__empty[hidden] { display: none; }
.er__empty-text { margin: 0; font-size: 12px; color: $subject-cream; font-weight: 600; }
.er__refill {
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-4;
background: $subject-blue-light;
color: $subject-ink;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.er__refill:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.er__btn {
position: absolute;
bottom: $sp-3;
width: 34px;
height: 34px;
border-radius: $r-pill;
border: 0;
background: $stage-paper;
box-shadow: $shadow-press;
font-size: 15px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
z-index: 2;
}
.er__btn--nope { left: $sp-3; color: $error; }
.er__btn--like { right: $sp-3; color: $subject-blue-deep; }
.er__btn:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.er__status { margin: 0; font-size: 11px; color: $subject-cream; font-weight: 600; }
@media (max-width: 340px) {
.er { gap: $sp-2; }
.er__scene { width: 150px; height: 108px; }
.er__card { width: 78px; height: 92px; }
.er__empty { width: 78px; height: 92px; gap: $sp-1; padding: $sp-1; }
.er__empty-text { font-size: 9px; }
.er__refill { font-size: 9px; padding: $sp-1 $sp-2; }
.er__label { font-size: 10px; padding: 0 $sp-1; }
.er__btn { width: 22px; height: 22px; font-size: 11px; bottom: $sp-1; }
.er__btn--nope { left: $sp-1; }
.er__btn--like { right: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.er__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.er {
display: grid;
gap: 8px;
justify-items: center;
width: 100%;
}
.er__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #fff7e6;
}
.er__scene {
position: relative;
width: 220px;
height: 190px;
display: grid;
place-items: center;
}
.er__card {
position: relative;
width: 160px;
height: 180px;
border-radius: 16px;
background: linear-gradient(160deg, #ff9ebb, #b9a0ff);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: grid;
place-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
}
.er__card:active {
cursor: grabbing;
}
.er__card:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.er__card[hidden] {
display: none;
}
.er__label {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #17141a;
padding: 0 12px;
text-align: center;
}
.er__empty {
width: 160px;
height: 180px;
border-radius: 16px;
border: 2px dashed rgba(255, 247, 230, 0.4);
display: grid;
gap: 12px;
place-items: center;
padding: 12px;
text-align: center;
}
.er__empty[hidden] {
display: none;
}
.er__empty-text {
margin: 0;
font-size: 12px;
color: #fff7e6;
font-weight: 600;
}
.er__refill {
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: #5b8cf8;
color: #17141a;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.er__refill:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.er__btn {
position: absolute;
bottom: 12px;
width: 34px;
height: 34px;
border-radius: 999px;
border: 0;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-size: 15px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
z-index: 2;
}
.er__btn--nope {
left: 12px;
color: #c2361a;
}
.er__btn--like {
right: 12px;
color: #0a4fe8;
}
.er__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.er__status {
margin: 0;
font-size: 11px;
color: #fff7e6;
font-weight: 600;
}
@media (max-width: 340px) {
.er {
gap: 8px;
}
.er__scene {
width: 150px;
height: 108px;
}
.er__card {
width: 78px;
height: 92px;
}
.er__empty {
width: 78px;
height: 92px;
gap: 4px;
padding: 4px;
}
.er__empty-text {
font-size: 9px;
}
.er__refill {
font-size: 9px;
padding: 4px 8px;
}
.er__label {
font-size: 10px;
padding: 0 4px;
}
.er__btn {
width: 22px;
height: 22px;
font-size: 11px;
bottom: 4px;
}
.er__btn--nope {
left: 4px;
}
.er__btn--like {
right: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.er__card {
transition: none;
}
}(function () {
"use strict";
// 값 하나: 카드를 REFILL_COUNT 장 다 넘기면 빈 상태가 뜨고, 다시 채우면 스프링(강성·감쇠)을
// scale 에 적분해 오버슛하며 튀어 들어온다.
var REFILL_COUNT = 3;
var REFILL_SPRING_STIFFNESS = 160;
var REFILL_SPRING_DAMPING = 12;
var RELEASE_VELOCITY_MIN = 0.38;
var EXIT_PX = 320;
var CYCLE_S = 2;
var root = document.getElementById("er");
var card = document.getElementById("erCard");
var label = document.getElementById("erLabel");
var empty = document.getElementById("erEmpty");
var refillBtn = document.getElementById("erRefill");
var status = document.getElementById("erStatus");
var btnLike = document.getElementById("erLike");
var btnNope = document.getElementById("erNope");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var CARDS = ["夕方の散歩", "週末のカフェ", "宅トレ記録", "読書リスト", "旅行メモ", "朝のルーティン"];
var rand = seedRand(101);
var idx = Math.floor(rand() * CARDS.length);
var swiped = 0;
var dragging = false;
var startX = 0, lastX = 0, lastT = 0, vx = 0;
var x = 0, phase = "idle", exitDir = 1;
var scale = 1, scaleVel = 0;
function setCard() { label.textContent = CARDS[idx % CARDS.length]; }
setCard();
function render() {
var rot = clamp(x * 0.08, -20, 20);
card.style.transform = "translateX(" + x.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg) scale(" + scale.toFixed(3) + ")";
}
function showEmpty() {
card.hidden = true;
empty.hidden = false;
status.textContent = "カードを全部見ました — 「補充」を押してみてください";
}
function refill() {
root.classList.remove("is-demo");
empty.hidden = true;
card.hidden = false;
swiped = 0;
idx += 1; setCard();
x = 0; vx = 0;
if (reduce) { scale = 1; scaleVel = 0; render(); phase = "idle"; status.textContent = "補充された"; return; }
scale = 0.2; scaleVel = 0;
phase = "refill";
status.textContent = "スプリングで弾んで入ってきています";
}
refillBtn.addEventListener("click", refill);
function nextCard() {
swiped += 1;
idx += 1; setCard();
x = 0; vx = 0; scale = 1; phase = "idle";
render();
status.textContent = "待機中";
if (swiped >= REFILL_COUNT) showEmpty();
}
function judge(velocity, dir) {
root.classList.remove("is-demo");
var flung = Math.abs(velocity) >= RELEASE_VELOCITY_MIN;
if (reduce) {
if (flung) { status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み"; nextCard(); }
else { x = 0; render(); status.textContent = "元の位置に戻った"; phase = "idle"; }
return;
}
if (flung) { phase = "exit"; exitDir = dir; status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み"; }
else { phase = "settle"; status.textContent = "元の位置に戻った"; }
}
function startDrag(clientX) {
if (card.hidden) return;
root.classList.remove("is-demo");
dragging = true; phase = "idle";
startX = clientX; lastX = clientX; lastT = performance.now(); vx = 0;
}
function moveDrag(clientX) {
if (!dragging) return;
var now = performance.now();
var dt = Math.max(now - lastT, 1);
vx = (clientX - lastX) / dt;
lastX = clientX; lastT = now;
x = clientX - startX;
render();
}
function endDrag() {
if (!dragging) return;
dragging = false;
judge(vx, x >= 0 ? 1 : -1);
}
card.addEventListener("pointerdown", function (e) { card.setPointerCapture(e.pointerId); startDrag(e.clientX); });
card.addEventListener("pointermove", function (e) { moveDrag(e.clientX); });
card.addEventListener("pointerup", endDrag);
card.addEventListener("pointercancel", endDrag);
function keyVerdict(dir) {
if (card.hidden) return;
root.classList.remove("is-demo");
x = dir * 60; render();
judge(dir * (RELEASE_VELOCITY_MIN + 0.3), dir);
}
card.addEventListener("keydown", function (e) {
if (e.key === "ArrowRight") { e.preventDefault(); keyVerdict(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); keyVerdict(-1); }
});
btnLike.addEventListener("click", function () { keyVerdict(1); });
btnNope.addEventListener("click", function () { keyVerdict(-1); });
// 스프링 방정식을 scale 에 적분한다 — 목표(1)를 넘겼다가(오버슛) 되돌아와 정착한다.
function integrateScale(dt) {
var accel = REFILL_SPRING_STIFFNESS * (1 - scale) - REFILL_SPRING_DAMPING * scaleVel;
scaleVel += accel * dt;
scale += scaleVel * dt;
if (Math.abs(1 - scale) < 0.004 && Math.abs(scaleVel) < 0.02) { scale = 1; scaleVel = 0; phase = "idle"; }
}
function step(dt) {
var dtS = Math.min(dt / 1000, 0.032);
if (phase === "exit") {
x += exitDir * Math.max(Math.abs(vx), RELEASE_VELOCITY_MIN * 1.4) * dt;
render();
if (Math.abs(x) > EXIT_PX) nextCard();
} else if (phase === "settle") {
x += (0 - x) * 0.22;
render();
if (Math.abs(x) < 0.4) { x = 0; phase = "idle"; render(); }
} else if (phase === "refill") {
integrateScale(dtS);
render();
}
}
// .is-demo 자동 시연: 실제 클릭 없이 judge()·refill() 을 같은 경로로 호출해 상태를 두 번 바꾼다.
var start = null, lastNow = 0, prevT = 0, synthPhase = "wait";
function synth(t) {
if (t > 0.35 && t < 0.65 && synthPhase === "wait") synthPhase = "swipeOut";
if (synthPhase === "swipeOut") {
var p = clamp((t - 0.35) / 0.3, 0, 1);
x = p * 140; render();
if (t >= 0.65) {
synthPhase = "afterSwipe";
swiped = REFILL_COUNT - 1;
judge(RELEASE_VELOCITY_MIN + 0.4, 1);
}
}
if (t > 1.25 && synthPhase === "afterSwipe" && !empty.hidden) { refill(); synthPhase = "done"; }
}
function loop(now) {
if (start === null) { start = now; lastNow = now; lastT = now; }
var dt = Math.min(now - lastNow, 48);
lastNow = now;
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhase = "wait";
prevT = t;
if (!reduce && root.classList.contains("is-demo")) synth(t);
if (!reduce) step(dt);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// EmptyStateRefillSwipeCard — vanilla 판(run/422/demo/08-swipe-deck-precision-empty-state-refill/index.html) 의 React 포트.
// 값 하나: REFILL_COUNT 번 스와이프하면 빈 상태 패널이 뜨고, "다시 채우기" 를 누르면
// scale 에 스프링(강성·감쇠)을 적분해(integrateScale) 오버슛하며 카드 더미가 튀어 들어온다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./EmptyStateRefillSwipeCard.module.scss";
type Box<T> = { current: T };
const REFILL_COUNT = 3;
const REFILL_SPRING_STIFFNESS = 160;
const REFILL_SPRING_DAMPING = 12;
const RELEASE_VELOCITY_MIN = 0.4;
const EXIT_PX = 320;
const CYCLE_S = 2;
const CARDS = ["아침 루틴", "저녁 산책", "주말 카페", "홈트 기록", "독서 목록", "여행 메모"];
function seedRand(seed: number) {
let s = seed >>> 0;
return function (): number {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function EmptyStateRefillSwipeCard() {
const cardRef = useRef<HTMLDivElement | null>(null);
const posRef = useRef<number>(0) as Box<number>;
const velRef = useRef<number>(0) as Box<number>;
const phaseRef = useRef<string>("idle") as Box<string>;
const exitDirRef = useRef<number>(1) as Box<number>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const startXRef = useRef<number>(0) as Box<number>;
const lastXRef = useRef<number>(0) as Box<number>;
const lastTRef = useRef<number>(0) as Box<number>;
const swipeCountRef = useRef<number>(0) as Box<number>;
const scaleRef = useRef<number>(1) as Box<number>;
const scaleVelRef = useRef<number>(0) as Box<number>;
const idxRef = useRef<number>(Math.floor(seedRand(71)() * CARDS.length)) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [label, setLabel] = useState(CARDS[idxRef.current % CARDS.length]);
const [status, setStatus] = useState("대기 중");
const [empty, setEmpty] = useState(false);
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(): void {
const card = cardRef.current;
if (!card) return;
const rot = clamp(posRef.current * 0.05, -16, 16);
card.style.transform = "translateX(" + posRef.current.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg) scale(" + scaleRef.current.toFixed(3) + ")";
}
function nextCard(): void {
idxRef.current += 1;
swipeCountRef.current += 1;
posRef.current = 0; velRef.current = 0; phaseRef.current = "idle";
if (swipeCountRef.current >= REFILL_COUNT) {
setEmpty(true);
setStatus("카드가 소진됨 — 다시 채워 주세요");
return;
}
setLabel(CARDS[idxRef.current % CARDS.length]);
render();
setStatus("대기 중");
}
function integrateScale(dt: number): boolean {
const accel = REFILL_SPRING_STIFFNESS * (1 - scaleRef.current) - REFILL_SPRING_DAMPING * scaleVelRef.current;
scaleVelRef.current += accel * dt;
scaleRef.current += scaleVelRef.current * dt;
if (Math.abs(1 - scaleRef.current) < 0.004 && Math.abs(scaleVelRef.current) < 0.06) {
scaleRef.current = 1; scaleVelRef.current = 0;
return true;
}
return false;
}
function refill(): void {
setDemo(false);
swipeCountRef.current = 0;
idxRef.current += 1;
setLabel(CARDS[idxRef.current % CARDS.length]);
setEmpty(false);
setStatus("다시 채워짐");
if (reduce) { scaleRef.current = 1; scaleVelRef.current = 0; render(); phaseRef.current = "idle"; return; }
scaleRef.current = 0.2; scaleVelRef.current = 0;
phaseRef.current = "refilling";
render();
}
function judge(velocity: number, dir: number): void {
setDemo(false);
const flung = Math.abs(velocity) >= RELEASE_VELOCITY_MIN;
if (reduce) {
if (flung) { setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨"); nextCard(); }
else { posRef.current = 0; render(); setStatus("제자리로 돌아옴"); phaseRef.current = "idle"; }
return;
}
if (flung) {
phaseRef.current = "exit"; exitDirRef.current = dir;
setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨");
} else {
phaseRef.current = "settle";
setStatus("제자리로 돌아옴");
}
}
function startDrag(clientX: number): void {
if (empty) return;
setDemo(false);
draggingRef.current = true; phaseRef.current = "idle";
startXRef.current = clientX; lastXRef.current = clientX; lastTRef.current = performance.now(); velRef.current = 0;
}
function moveDrag(clientX: number): void {
if (!draggingRef.current) return;
const now = performance.now();
const dt = Math.max(now - lastTRef.current, 1);
velRef.current = (clientX - lastXRef.current) / dt;
lastXRef.current = clientX; lastTRef.current = now;
posRef.current = clientX - startXRef.current;
render();
}
function endDrag(): void {
if (!draggingRef.current) return;
draggingRef.current = false;
judge(velRef.current, posRef.current >= 0 ? 1 : -1);
}
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>): void {
e.currentTarget.setPointerCapture(e.pointerId);
startDrag(e.clientX);
}
function onPointerMove(e: ReactPointerEvent<HTMLDivElement>): void { moveDrag(e.clientX); }
function onPointerUp(): void { endDrag(); }
function keyVerdict(dir: number): void {
if (empty) return;
setDemo(false);
posRef.current = dir * 40; render();
judge(dir * (RELEASE_VELOCITY_MIN + 0.2), dir);
}
function onKeyDown(e: any): void {
if (e.key === "ArrowRight") { e.preventDefault(); keyVerdict(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); keyVerdict(-1); }
}
function step(dt: number): void {
const dtS = Math.min(dt / 1000, 0.032);
if (phaseRef.current === "exit") {
const speed = Math.max(Math.abs(velRef.current), RELEASE_VELOCITY_MIN * 1.4);
posRef.current += exitDirRef.current * speed * dt;
render();
if (Math.abs(posRef.current) > EXIT_PX) nextCard();
} else if (phaseRef.current === "settle") {
posRef.current += (0 - posRef.current) * 0.22;
render();
if (Math.abs(posRef.current) < 0.5) { posRef.current = 0; phaseRef.current = "idle"; render(); }
} else if (phaseRef.current === "refilling") {
const done = integrateScale(dtS);
render();
if (done) phaseRef.current = "idle";
}
}
function synth(t: number, synthPhaseRef: Box<string>): void {
if (t > 0.35 && t < 0.6 && synthPhaseRef.current === "wait") synthPhaseRef.current = "swipe1";
if (synthPhaseRef.current === "swipe1") {
const p = clamp((t - 0.35) / 0.25, 0, 1);
posRef.current = p * 100; render();
if (t >= 0.6) { synthPhaseRef.current = "after1"; judge(RELEASE_VELOCITY_MIN + 0.3, 1); }
}
if (t > 1.1 && synthPhaseRef.current === "after1" && empty) { synthPhaseRef.current = "refill"; refill(); }
if (t < 0.2 && synthPhaseRef.current !== "wait" && synthPhaseRef.current !== "refill") synthPhaseRef.current = "wait";
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0, lastNow = 0, prevT = 0;
const synthPhaseRef: Box<string> = { current: "wait" };
function loop(now: number): void {
if (start === 0) { start = now; lastNow = now; }
const dt = Math.min(now - lastNow, 48);
lastNow = now;
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhaseRef.current = "wait";
prevT = t;
if (!reduce && demo) synth(t, synthPhaseRef);
if (!reduce) step(dt);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo, empty]);
return (
<main className={styles.stage}>
<div className={styles.er}>
<p className={styles.caption}>스프링으로 튀어 들어오는 다시 채우기</p>
<div className={styles.scene}>
{!empty && (
<div
className={styles.card}
ref={cardRef}
tabIndex={0}
role="group"
aria-roledescription="스와이프 카드"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
>
<p className={styles.label}>{label}</p>
</div>
)}
{empty && (
<div className={styles.empty}>
<p className={styles.emptyText}>카드를 모두 봤어요</p>
<button className={styles.refillBtn} type="button" onClick={refill}>다시 채우기</button>
</div>
)}
{!empty && (
<>
<button className={styles.btnNope} type="button" aria-label="싫어요" onClick={() => keyVerdict(-1)}>✕</button>
<button className={styles.btnLike} type="button" aria-label="좋아요" onClick={() => keyVerdict(1)}>♥</button>
</>
)}
</div>
<p className={styles.status} aria-live="polite">{status}</p>
</div>
</main>
);
}08 · ばねで跳ねて現れる空スタックカード — 計測 累積面積 42.749% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function integrateScale(dt) {
var accel = REFILL_SPRING_STIFFNESS * (1 - scale) - REFILL_SPRING_DAMPING * scaleVel;
scaleVel += accel * dt;
scale += scaleVel * dt;
if (Math.abs(1 - scale) < 0.004 && Math.abs(scaleVel) < 0.02) { scale = 1; scaleVel = 0; phase = "idle"; }
}
計測ではこのばね式の再補充が画面の42.7%を占め、平均強度は198.2、動きの検出は6/23フレームです。
09回転・スプリング・奥行きスタックを一つのデッキに合わせたヒーロー場面
01番の回転補間・03番のスプリング復帰・04番の奥行きスタックを一つのデッキに重ねているので、カード一枚をめくると三つの値が別々にではなく同時に動きます。HERO_STACK_PERSPECTIVE_SCALE が後ろで待つカードを縮め、HERO_ROTATE_LERP_RATE が手前のカードを傾け、どちらも同じドラッグ値を読み取っています。

<main class="stage">
<div class="hs is-demo" id="hs">
<p class="hs__caption">回転補間・スプリング・遠近スタックを一つの場面にまとめた完成形カードデッキ</p>
<div class="hs__scene" id="hsScene">
<div class="hs__peek" id="hsPeek3" aria-hidden="true"></div>
<div class="hs__peek" id="hsPeek2" aria-hidden="true"></div>
<div class="hs__peek" id="hsPeek1" aria-hidden="true"></div>
<div class="hs__card" id="hsCard" tabindex="0" role="group" aria-roledescription="スワイプカード">
<p class="hs__label" id="hsLabel">週末のカフェ</p>
</div>
<button class="hs__btn hs__btn--nope" id="hsNope" type="button" aria-label="いいえ">✕</button>
<button class="hs__btn hs__btn--like" id="hsLike" type="button" aria-label="いいね">♥</button>
</div>
<p class="hs__status" id="hsStatus" aria-live="polite">待機中</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 01+03+04 결합: 회전은 목표값을 향해 매 프레임 지연 보간되고, 약하게 놓으면 스프링으로
// 되돌아오며, 뒤 카드들은 원근 투영으로 겹쳐 쌓인다.
$duration: $dur-loop;
$easing: $ease-elastic;
$color: $subject-blue-deep;
@include stage($stage-orange);
.hs { display: grid; gap: $sp-2; justify-items: center; width: 100%; }
.hs__caption { margin: 0; font-size: 12px; font-weight: 600; color: $subject-ink; max-width: 220px; text-align: center; }
.hs__scene {
position: relative;
width: 220px;
height: 180px;
display: grid;
place-items: center;
}
.hs__peek {
position: absolute;
width: 160px;
height: 180px;
border-radius: $r-card;
background: linear-gradient(160deg, $subject-cream, $subject-blue-light);
will-change: transform, opacity;
}
.hs__card {
position: relative;
z-index: 4;
width: 160px;
height: 180px;
border-radius: $r-card;
background: linear-gradient(160deg, $subject-lilac, $subject-sky);
box-shadow: $shadow-raised;
display: grid;
place-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
}
.hs__card:active { cursor: grabbing; }
.hs__card:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
.hs__label { margin: 0; font-size: 14px; font-weight: 700; color: $subject-ink; padding: 0 $sp-3; text-align: center; }
.hs__btn {
position: absolute;
bottom: $sp-3;
z-index: 5;
width: 34px;
height: 34px;
border-radius: $r-pill;
border: 0;
background: $stage-paper;
box-shadow: $shadow-press;
font-size: 15px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.hs__btn--nope { left: $sp-3; color: $error; }
.hs__btn--like { right: $sp-3; color: $subject-blue-deep; }
.hs__btn:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.hs__status { margin: 0; font-size: 11px; color: $subject-ink; font-weight: 600; }
@media (max-width: 340px) {
.hs { gap: $sp-2; }
.hs__caption { font-size: 10px; }
.hs__scene { width: 150px; height: 108px; }
.hs__peek { width: 78px; height: 92px; }
.hs__card { width: 78px; height: 92px; }
.hs__label { font-size: 10px; padding: 0 $sp-1; }
.hs__btn { width: 22px; height: 22px; font-size: 11px; bottom: $sp-1; }
.hs__btn--nope { left: $sp-1; }
.hs__btn--like { right: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.hs__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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hs {
display: grid;
gap: 8px;
justify-items: center;
width: 100%;
}
.hs__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
max-width: 220px;
text-align: center;
}
.hs__scene {
position: relative;
width: 220px;
height: 180px;
display: grid;
place-items: center;
}
.hs__peek {
position: absolute;
width: 160px;
height: 180px;
border-radius: 16px;
background: linear-gradient(160deg, #fff7e6, #5b8cf8);
will-change: transform, opacity;
}
.hs__card {
position: relative;
z-index: 4;
width: 160px;
height: 180px;
border-radius: 16px;
background: linear-gradient(160deg, #b9a0ff, #6ec8ff);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: grid;
place-items: center;
touch-action: none;
cursor: grab;
will-change: transform;
}
.hs__card:active {
cursor: grabbing;
}
.hs__card:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.hs__label {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #17141a;
padding: 0 12px;
text-align: center;
}
.hs__btn {
position: absolute;
bottom: 12px;
z-index: 5;
width: 34px;
height: 34px;
border-radius: 999px;
border: 0;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-size: 15px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.hs__btn--nope {
left: 12px;
color: #c2361a;
}
.hs__btn--like {
right: 12px;
color: #0a4fe8;
}
.hs__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.hs__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.hs {
gap: 8px;
}
.hs__caption {
font-size: 10px;
}
.hs__scene {
width: 150px;
height: 108px;
}
.hs__peek {
width: 78px;
height: 92px;
}
.hs__card {
width: 78px;
height: 92px;
}
.hs__label {
font-size: 10px;
padding: 0 4px;
}
.hs__btn {
width: 22px;
height: 22px;
font-size: 11px;
bottom: 4px;
}
.hs__btn--nope {
left: 4px;
}
.hs__btn--like {
right: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__card {
transition: none;
}
}(function () {
"use strict";
// 01+03+04 를 한 장면에 모은다: 회전은 목표값을 향해 매 프레임 HERO_ROTATE_LERP_RATE 로
// 지연 보간(01), 약하게 놓으면 스프링(강성·감쇠) 적분으로 되돌아오고(03),
// 뒤 카드들은 HERO_STACK_PERSPECTIVE_SCALE 원근 투영으로 겹쳐 쌓인다(04).
var HERO_ROTATE_LERP_RATE = 0.22;
var ROTATE_TARGET_RATIO = 0.09;
var ROTATE_MAX_DEG = 22;
var HERO_STACK_PERSPECTIVE_SCALE = 0.11;
var FOCAL = 240;
var STACK_SIZE = 3;
var SPRING_FLING_VELOCITY_MIN = 0.42;
var SPRING_STIFFNESS = 130;
var SPRING_DAMPING = 13;
var EXIT_PX = 320;
var CYCLE_S = 2;
var root = document.getElementById("hs");
var card = document.getElementById("hsCard");
var label = document.getElementById("hsLabel");
var status = document.getElementById("hsStatus");
var btnLike = document.getElementById("hsLike");
var btnNope = document.getElementById("hsNope");
var peeks = [
document.getElementById("hsPeek1"),
document.getElementById("hsPeek2"),
document.getElementById("hsPeek3")
];
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function layoutStack() {
for (var i = 1; i <= STACK_SIZE; i++) {
var depth = i * HERO_STACK_PERSPECTIVE_SCALE * FOCAL;
var s = FOCAL / (FOCAL + depth);
var ty = -(1 - s) * 58;
var el = peeks[i - 1];
el.style.transform = "translateY(" + ty.toFixed(2) + "%) scale(" + s.toFixed(3) + ")";
el.style.opacity = (1 - i * 0.24).toFixed(2);
el.style.zIndex = String(STACK_SIZE - i);
}
}
layoutStack();
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var CARDS = ["週末のカフェ", "宅トレ記録", "読書リスト", "旅行メモ", "朝のルーティン", "夕方の散歩"];
var rand = seedRand(115);
var idx = Math.floor(rand() * CARDS.length);
var dragging = false;
var startX = 0, lastX = 0, lastT = 0, vxRaw = 0;
var pos = 0, vel = 0, rot = 0, phase = "idle", exitDir = 1;
function setCard() { label.textContent = CARDS[idx % CARDS.length]; }
setCard();
function render() {
var targetRot = clamp(pos * ROTATE_TARGET_RATIO, -ROTATE_MAX_DEG, ROTATE_MAX_DEG);
rot += (targetRot - rot) * HERO_ROTATE_LERP_RATE;
card.style.transform = "translateX(" + pos.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
}
function nextCard() {
idx += 1; setCard();
pos = 0; vel = 0; rot = 0; phase = "idle";
render();
status.textContent = "待機中";
}
function judge(velocityPxMs, dir) {
root.classList.remove("is-demo");
var flung = Math.abs(velocityPxMs) >= SPRING_FLING_VELOCITY_MIN;
if (reduce) {
if (flung) { status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み"; nextCard(); }
else { pos = 0; vel = 0; rot = 0; render(); status.textContent = "スプリングで弾んで戻った"; phase = "idle"; }
return;
}
if (flung) {
phase = "exit"; exitDir = dir;
status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み";
} else {
phase = "spring";
vel = velocityPxMs * 1000;
status.textContent = "スプリングで弾んで戻った";
}
}
function startDrag(clientX) {
root.classList.remove("is-demo");
dragging = true; phase = "idle";
startX = clientX; lastX = clientX; lastT = performance.now(); vxRaw = 0;
}
function moveDrag(clientX) {
if (!dragging) return;
var now = performance.now();
var dt = Math.max(now - lastT, 1);
vxRaw = (clientX - lastX) / dt;
lastX = clientX; lastT = now;
pos = clientX - startX;
render();
}
function endDrag() {
if (!dragging) return;
dragging = false;
judge(vxRaw, pos >= 0 ? 1 : -1);
}
card.addEventListener("pointerdown", function (e) { card.setPointerCapture(e.pointerId); startDrag(e.clientX); });
card.addEventListener("pointermove", function (e) { moveDrag(e.clientX); });
card.addEventListener("pointerup", endDrag);
card.addEventListener("pointercancel", endDrag);
function keyVerdict(dir, weak) {
root.classList.remove("is-demo");
pos = dir * (weak ? 30 : 70); render();
judge(dir * (weak ? 0.08 : SPRING_FLING_VELOCITY_MIN + 0.4), dir);
}
card.addEventListener("keydown", function (e) {
if (e.key === "ArrowRight") { e.preventDefault(); keyVerdict(1, false); }
else if (e.key === "ArrowLeft") { e.preventDefault(); keyVerdict(-1, false); }
});
btnLike.addEventListener("click", function () { keyVerdict(1, false); });
btnNope.addEventListener("click", function () { keyVerdict(-1, false); });
function integrate(dt) {
var accel = SPRING_STIFFNESS * (0 - pos) - SPRING_DAMPING * vel;
vel += accel * dt;
pos += vel * dt;
if (Math.abs(pos) < 0.4 && Math.abs(vel) < 4) { pos = 0; vel = 0; phase = "idle"; }
}
function step(dt) {
var dtS = Math.min(dt / 1000, 0.032);
if (phase === "exit") {
pos += exitDir * Math.max(Math.abs(vel) / 1000, SPRING_FLING_VELOCITY_MIN * 1.3) * dt;
render();
if (Math.abs(pos) > EXIT_PX) nextCard();
} else if (phase === "spring") {
integrate(dtS);
render();
}
}
// .is-demo 자동 시연: 실제 pointer 없이 pos·judge() 를 같은 경로로 호출한다.
var start = null, lastNow = 0, prevT = 0, synthPhase = "wait";
function synth(t) {
if (t > 0.4 && t < 0.65 && synthPhase === "wait") synthPhase = "pull";
if (synthPhase === "pull") {
var p = clamp((t - 0.4) / 0.25, 0, 1);
pos = p * 65; render();
if (t >= 0.65) { synthPhase = "afterPull"; judge(0.08, 1); }
}
if (t > 1.3 && t < 1.55 && synthPhase === "afterPull") synthPhase = "throw";
if (synthPhase === "throw") {
var q = clamp((t - 1.3) / 0.25, 0, 1);
pos = -q * 140; render();
if (t >= 1.55) { synthPhase = "done"; judge(-(SPRING_FLING_VELOCITY_MIN + 0.4), -1); }
}
}
function loop(now) {
if (start === null) { start = now; lastNow = now; lastT = now; }
var dt = Math.min(now - lastNow, 48);
lastNow = now;
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhase = "wait";
prevT = t;
if (!reduce && root.classList.contains("is-demo")) synth(t);
if (!reduce) step(dt);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// HeroDeckSceneSwipeCard — vanilla 판(run/422/demo/09-swipe-deck-precision-hero-deck-scene/index.html) 의 React 포트.
// 01+03+04 를 한 장면에 합친 대표편: 회전은 목표각으로 매 프레임 보간(HERO_ROTATE_LERP_RATE),
// 놓았을 때 약하면 스프링 적분(SPRING_STIFFNESS·DAMPING)으로 되돌아오고,
// 뒤에는 원근 투영 스택(HERO_STACK_PERSPECTIVE_SCALE·FOCAL)이 쌓여 있다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./HeroDeckSceneSwipeCard.module.scss";
type Box<T> = { current: T };
const HERO_ROTATE_LERP_RATE = 0.22;
const ROTATE_MAX_DEG = 20;
const SPRING_STIFFNESS = 130;
const SPRING_DAMPING = 13;
const SPRING_FLING_VELOCITY_MIN = 0.42;
const HERO_STACK_PERSPECTIVE_SCALE = 0.11;
const FOCAL = 240;
const STACK_SIZE = 3;
const EXIT_PX = 320;
const CYCLE_S = 2;
const CARDS = ["주말 카페", "홈트 기록", "독서 목록", "여행 메모", "아침 루틴", "저녁 산책", "메모 정리", "습관 체크"];
function seedRand(seed: number) {
let s = seed >>> 0;
return function (): number {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function HeroDeckSceneSwipeCard() {
const cardRef = useRef<HTMLDivElement | null>(null);
const peekRefs = useRef<Array<HTMLDivElement | null>>([null, null, null]) as Box<Array<HTMLDivElement | null>>;
const posRef = useRef<number>(0) as Box<number>;
const velRef = useRef<number>(0) as Box<number>;
const rotRef = useRef<number>(0) as Box<number>;
const phaseRef = useRef<string>("idle") as Box<string>;
const exitDirRef = useRef<number>(1) as Box<number>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const startXRef = useRef<number>(0) as Box<number>;
const lastXRef = useRef<number>(0) as Box<number>;
const lastTRef = useRef<number>(0) as Box<number>;
const idxRef = useRef<number>(Math.floor(seedRand(83)() * CARDS.length)) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const [front, setFront] = useState(CARDS[idxRef.current % CARDS.length]);
const [peeks, setPeeks] = useState<string[]>(
Array.from({ length: STACK_SIZE }, (_, i) => CARDS[(idxRef.current + i + 1) % CARDS.length])
);
const [status, setStatus] = useState("대기 중");
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function layoutStack(): void {
for (let i = 0; i < STACK_SIZE; i += 1) {
const el = peekRefs.current[i];
if (!el) continue;
const depth = i + 1;
const scale = FOCAL / (FOCAL + depth * HERO_STACK_PERSPECTIVE_SCALE * FOCAL);
const y = depth * HERO_STACK_PERSPECTIVE_SCALE * 20;
el.style.transform = "translateY(" + y.toFixed(1) + "px) scale(" + scale.toFixed(3) + ")";
el.style.zIndex = String(STACK_SIZE - i);
}
}
useEffect(() => { layoutStack(); }, []);
function render(): void {
const card = cardRef.current;
if (!card) return;
card.style.transform = "translateX(" + posRef.current.toFixed(1) + "px) rotate(" + rotRef.current.toFixed(2) + "deg)";
}
function nextCard(): void {
idxRef.current += 1;
setFront(CARDS[idxRef.current % CARDS.length]);
setPeeks(Array.from({ length: STACK_SIZE }, (_, i) => CARDS[(idxRef.current + i + 1) % CARDS.length]));
posRef.current = 0; velRef.current = 0; rotRef.current = 0; phaseRef.current = "idle";
render();
setStatus("대기 중");
}
function judge(velocity: number, dir: number): void {
setDemo(false);
const flung = Math.abs(velocity) >= SPRING_FLING_VELOCITY_MIN;
if (reduce) {
if (flung) { setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨"); nextCard(); }
else { posRef.current = 0; rotRef.current = 0; render(); setStatus("스프링으로 되돌아옴"); phaseRef.current = "idle"; }
return;
}
if (flung) {
phaseRef.current = "exit"; exitDirRef.current = dir;
setStatus(dir > 0 ? "좋아요 처리됨" : "싫어요 처리됨");
} else {
phaseRef.current = "spring";
velRef.current = velocity * 1000;
setStatus("스프링으로 되돌아옴");
}
}
function startDrag(clientX: number): void {
setDemo(false);
draggingRef.current = true; phaseRef.current = "idle";
startXRef.current = clientX; lastXRef.current = clientX; lastTRef.current = performance.now();
}
function moveDrag(clientX: number): void {
if (!draggingRef.current) return;
const now = performance.now();
const dt = Math.max(now - lastTRef.current, 1);
velRef.current = ((clientX - lastXRef.current) / dt) * 1000;
lastXRef.current = clientX; lastTRef.current = now;
posRef.current = clientX - startXRef.current;
render();
}
function endDrag(): void {
if (!draggingRef.current) return;
draggingRef.current = false;
judge(velRef.current / 1000, posRef.current >= 0 ? 1 : -1);
}
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>): void {
e.currentTarget.setPointerCapture(e.pointerId);
startDrag(e.clientX);
}
function onPointerMove(e: ReactPointerEvent<HTMLDivElement>): void { moveDrag(e.clientX); }
function onPointerUp(): void { endDrag(); }
function keyVerdict(dir: number): void {
setDemo(false);
posRef.current = dir * 70; render();
judge(dir * (SPRING_FLING_VELOCITY_MIN + 0.4), dir);
}
function onKeyDown(e: any): void {
if (e.key === "ArrowRight") { e.preventDefault(); keyVerdict(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); keyVerdict(-1); }
}
function integrateSpring(dt: number): void {
const accel = SPRING_STIFFNESS * (0 - posRef.current) - SPRING_DAMPING * velRef.current;
velRef.current += accel * dt;
posRef.current += velRef.current * dt;
if (Math.abs(posRef.current) < 0.4 && Math.abs(velRef.current) < 4) { posRef.current = 0; velRef.current = 0; phaseRef.current = "idle"; }
}
function step(dt: number): void {
const dtS = Math.min(dt / 1000, 0.032);
const targetRot = clamp(posRef.current * 0.09, -ROTATE_MAX_DEG, ROTATE_MAX_DEG);
rotRef.current += (targetRot - rotRef.current) * HERO_ROTATE_LERP_RATE;
if (phaseRef.current === "exit") {
posRef.current += exitDirRef.current * Math.max(Math.abs(velRef.current) / 1000, SPRING_FLING_VELOCITY_MIN * 1.3) * dt;
render();
if (Math.abs(posRef.current) > EXIT_PX) nextCard();
} else if (phaseRef.current === "spring") {
integrateSpring(dtS);
render();
} else {
render();
}
}
function synth(t: number, synthPhaseRef: Box<string>): void {
if (t > 0.4 && t < 0.65 && synthPhaseRef.current === "wait") synthPhaseRef.current = "pull";
if (synthPhaseRef.current === "pull") {
const p = clamp((t - 0.4) / 0.25, 0, 1);
posRef.current = p * 65; render();
if (t >= 0.65) { synthPhaseRef.current = "afterPull"; judge(0.09, 1); }
}
if (t > 1.3 && t < 1.55 && synthPhaseRef.current === "afterPull") synthPhaseRef.current = "throw";
if (synthPhaseRef.current === "throw") {
const q = clamp((t - 1.3) / 0.25, 0, 1);
posRef.current = -q * 150; render();
if (t >= 1.55) { synthPhaseRef.current = "done"; judge(-(SPRING_FLING_VELOCITY_MIN + 0.4), -1); }
}
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0, lastNow = 0, prevT = 0;
const synthPhaseRef: Box<string> = { current: "wait" };
function loop(now: number): void {
if (start === 0) { start = now; lastNow = now; }
const dt = Math.min(now - lastNow, 48);
lastNow = now;
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) synthPhaseRef.current = "wait";
prevT = t;
if (!reduce && demo) synth(t, synthPhaseRef);
if (!reduce) step(dt);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
return (
<main className={styles.stage}>
<div className={styles.hs}>
<p className={styles.caption}>회전 보간 · 스프링 · 원근 스택을 합친 대표 장면</p>
<div className={styles.scene}>
{peeks.map((txt, i) => (
<div className={styles.peek} key={i} ref={(el: HTMLDivElement | null) => { peekRefs.current[i] = el; }} aria-hidden="true">
<p className={styles.label}>{txt}</p>
</div>
))}
<div
className={styles.card}
ref={cardRef}
tabIndex={0}
role="group"
aria-roledescription="스와이프 카드"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
>
<p className={styles.label}>{front}</p>
</div>
<button className={styles.btnNope} type="button" aria-label="싫어요" onClick={() => keyVerdict(-1)}>✕</button>
<button className={styles.btnLike} type="button" aria-label="좋아요" onClick={() => keyVerdict(1)}>♥</button>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
</div>
</main>
);
}09 · 回転・スプリング・奥行きスタックを一つのデッキに合わせたヒーロー場面 — 計測 累積面積 22.597% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function layoutStack() {
for (var i = 1; i <= STACK_SIZE; i++) {
var depth = i * HERO_STACK_PERSPECTIVE_SCALE * FOCAL;
var s = FOCAL / (FOCAL + depth);
var ty = -(1 - s) * 58;
var el = peeks[i - 1];
el.style.transform = "translateY(" + ty.toFixed(2) + "%) scale(" + s.toFixed(3) + ")";
el.style.opacity = (1 - i * 0.24).toFixed(2);
el.style.zIndex = String(STACK_SIZE - i);
}
}
計測ではこの合成ヒーロー場面が画面の22.6%を占め、平均強度は100.6、動きの検出は10/23フレームです。
9パターンを面積と強度で測定した結果
| 項目 | 面積 % | 強度 | 動いたフレーム |
| --- | --- | --- | --- |
| 01 ドラッグ距離に補間する回転 | 49.3% | 120.0 | 6/23 |
| 02 離す速度で分かれる判定 | 8.5% | 76.5 | 6/23 |
| 03 スプリングオーバーシュート | 15.2% | 112.5 | 8/23 |
| 04 遠近スタック | 48.1% | 113.9 | 6/23 |
| 05 判子のフェードイン | 24.2% | 53.0 | 6/23 |
| 06 逆補間の元に戻す | 48.0% | 128.9 | 6/23 |
| 07 キーボード・ボタン代替経路 | 18.9% | 71.7 | 9/23 |
| 08 空デッキのばね再補充 | 42.7% | 198.2 | 6/23 |
| 09 ヒーローデッキ場面 | 22.6% | 100.6 | 10/23 |
スワイプで一枚ずつ判断させたい場面で使う
このUIは、ユーザーが一つの項目だけを見て判断し、次へ進む前に行動することを求める場面に向いています。02番の離す速度による判定は、遅いドラッグをうっかり決定として数えたくないマッチング型の閲覧キューに向いています。05番のにじむ判子は、そのキューに追加の説明文なしで「なぜそう判定されたか」を読める形で示します。07番のキーボード代替経路は、タッチやマウスのドラッグを前提にできない場面 — 例えばキーボードで操作するレビューキュー — で効いてきます。08番の空デッキ再補充は有限のキューを死んだカードを残さずに締めくくり、06番の元に戻すは誤ってスワイプしたモデレーション型のキューに戻り道を用意します。「一度に一枚」の側面をカルーセルで見るならcss カルーセル スライダーの作り方9選 — コピペでOKがタイマーや矢印で送る一列のカードを扱っており、スマホ ジェスチャー操作 9選 — 指で本当に動くUIは、このデッキのポインター処理が土台にしている指の追従パターンをより広く扱っています。
値がスワイプ判定を変える場所 — 速度しきい値・回転補間比率・スタック遠近の深さ
以下の三つの定数は見た目以上の仕事をしています。02番の SWIPE_VELOCITY_THRESHOLD_PX_MS は、judge() が離す速度と比較してフリングと判定するか、遅いドラッグとして元に戻すかを分ける境界線で、これをわずかに下回ると03番のオーバーシュートするばねが代わりに動きます。01番の SWIPE_ROTATE_LERP_RATIO は横方向のドラッグ1ピクセルがどれだけ傾きを生むかを決め、同じドラッグ距離でもこの比率だけでほぼ傾かないようにも急に傾くようにも見せられます。04番の SWIPE_STACK_DEPTH_SCALE は手前以外の各カードがどれだけ縮んでずれるかを決め、平らなカードの山に見えるか本物の奥行きに見えるかの違いを生みます。料金表 デザイン 実装9選 — 切り替えも計算も動くでは、ドラッグの離す速度ではなくトグルスイッチの上で同じ種類のしきい値を調整しています。
どこで壊れるか — 動作を減らす設定・遅いドラッグ・連続する高速スワイプ
この9つのデッキはどれも var reduce = matchMedia("(prefers-reduced-motion: reduce)").matches をあらかじめ持っており、judge()(08番だけは refill())は reduce が真なら即座に最終状態へスナップして次のカードへ進み、毎フレームの step(dt) ループ自体も if (!reduce) step(dt) で丸ごと呼ばれません — 03番のばね収束も07番の充電の減衰も04番・09番の遠近再計算も、reduced motion 環境ではそもそも回りません。離す前に SWIPE_VELOCITY_THRESHOLD_PX_MS(02番)や FLING_VELOCITY_MIN を一度も超えない遅いドラッグは、どれだけ距離を動かしても必ず settle 側の分岐に戻ります — 距離だけではスワイプとして数えられません。実際に壊れやすいのは02番の startDrag() で、ほかの8つが再ドラッグ開始のたびに phase = "idle" へ戻すのに対し02番だけそれをせず、前のカードがまだ exit フェーズ(退出アニメーション中)のうちに同じカード要素を素早くつかみ直すと、step() の退出処理とドラッグの位置更新が同じ x を同時に書き換えてしまいます。これらのビルドを支える9組の index.html・style.scss、ga6p3g89は、自作の10番目のスワイプパターンに同じ reduce 分岐と phase リセットが要るかどうかを確かめる一番早い方法です。
変形3つとアクセシビリティ
| 変形 | 定数 | 範囲 | 変わること |
| --- | --- | --- | --- |
| 01 速度しきい値を調整する | SWIPE_VELOCITY_THRESHOLD_PX_MS | 0.3 → 0.8 | 値を下げると軽く押しただけでカードが飛び、値を上げると強く投げないと判定が出ず、誤操作が減ります。 |
| 02 回転補間比率を調整する | SWIPE_ROTATE_LERP_RATIO | 0.05 → 0.25 | 値を小さくするとカードはほとんど傾かずに動き、値を大きくすると移動距離に敏感に反応して大きく傾きます。 |
| 03 スタック遠近の深さを調整する | SWIPE_STACK_DEPTH_SCALE | 0.04 → 0.14 | 値を小さくすると後ろのカードはほぼ同じ大きさで平たく重なり、値を大きくすると奥ほど大きく縮んで奥行きがはっきりします。 |
ここでのアクセシビリティは飾りではありません。07番はまさに、キーボードユーザーが左右矢印キーとボタンの組でポインタードラッグの完全な代替を得て、ドラッグと同じ judge() の判定へたどり着けるようにするために存在します。08番で再補充されるカードや、04番・09番で再計算されるスタックは、どちらも状態テキストに紐づいた aria-live 領域を通じて変化を知らせるので、スクリーンリーダーはカードの動きを見なくても「いいねと判定されました」「待機中」を聞き取れます。9つのビルドはどれも reduce 分岐を自前で持ち、その中で何を即時スナップに切り替えているかは、上の「どこで壊れるか」の節を参照してください。
カード スワイプ ui とスワイプ ジェスチャー は何が違うのか?
スワイプ ジェスチャーは、指やカーソルが画面上を十分な速さで一方向に動いた、という生のポインター入力そのものを指します。このUIは、そのジェスチャーが積み重なった一枚集中型のカードに届いたときにページが行うことです — 判定を下し(02番)、届かなければ復帰のばねを再生し(03番)、下にある次のカードを見せます(04番)。ジェスチャーは入力で、判定してから遷移させるデッキの側のロジックが、その入力をいいね・ノー・まだ何も起きていない、のどれかに変えます。
この9つを試す前によくある質問に答えます
カード スワイプ ui のパターンにタッチスクリーンは必須ですか?
必須ではありません — 07番のキーボード代替経路は、矢印キーとボタンの押下をポインタードラッグとまったく同じ judge() 呼び出しに通すことで、マウスのみ・キーボードのみの訪問者にも同じ判定を届けています。
スワイプがフリングか遅いドラッグかを分けるのは何ですか?
離す瞬間の速度だけです。02番では judge() がそれを SWIPE_VELOCITY_THRESHOLD_PX_MS(姉妹ビルドでは FLING_VELOCITY_MIN)と比較しており、離すまでにどれだけ引っ張ったかはこの比較に一切入りません。この仕組みが読み取っているポインター入力の仕様はMDNのPointer Eventsにまとまっています。
デモのデッキが触る前から勝手にスワイプしているのはなぜですか?
seedRand() が決定論的な合成ポインター操作の列を作り、読み込み直後からパターンが見える自動再生を行っています。実際のポインター・矢印キー・ボタン操作が一度でも入ると、root.classList.remove("is-demo") がその自動再生を完全に止めます。








