09회전·스프링·원근 스택을 한 덱에 합친 히어로 장면
01 회전 보간, 03 스프링 복귀, 04 원근 스택을 한 덱에 겹쳐, 카드 한 장을 넘길 때 세 값이 함께 움직이는 완성 장면을 만든다.
쓰임접근성 동작 줄임 대응
HERO_ROTATE_LERP_RATEHERO_STACK_PERSPECTIVE_SCALESPRING_STIFFNESS

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


