05스와이프 카드 덱
카드를 좌우로 끌면 기울기와 함께 통과·거절 스탬프가 걸리고 임계값을 넘겨 놓으면 판정이 확정되며 카드가 화면 밖으로 날아간 뒤 다음 카드가 그 자리로 오릅니다.
쓰임추천·후보 심사·구직처럼 한 장씩 보고 빠르게 판정 내리는 화면을 만들 때
rotatevelocityaria-label

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

