06역방향 보간으로 되돌리는 마지막 카드
되돌리기를 누르면 방금 넘긴 카드가 나간 경로를 거꾸로 보간하며 스택 맨 위로 돌아온다.
쓰임실수로 넘긴 카드 되돌리기 버튼
PATH_SAMPLE_MSUNDO_DURATION_SRELEASE_VELOCITY_MIN

<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 원작 · zip 에 SCSS·React 포함 · 카드 스와이프 ui 9가지 — 드래그를 물리 계산으로 다듬기
이 데모가 속한 글
카드 스와이프 ui 9가지 — 드래그를 물리 계산으로 다듬기카드 스와이프 ui 아홉 개를 모아 놓고 봤다. 엄지 끝 속도가 02번 기준을 넘지 못한 순간, 카드는 손을 놓아도 날아가지 않고 03번처럼 스프링으로 튕겨 제자리에 앉는다. 마지막 장까지 넘긴 자리엔 08번 빈 카 이 글의 데모 전부 보기


