05Swipe card deck
Dragging the card sideways tilts it and stamps PASS or SKIP, passing the threshold on release locks the verdict, the card flies off screen, and the next card rises into place.
Best forRecommendations, candidate triage, job swipes — anything judged one card at a time
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="Applicant 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>Applicant 3</strong><span>Data analysis</span></div>
</article>
<article class="cd__card is-back1" data-name="Applicant 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>Applicant 2</strong><span>Frontend</span></div>
</article>
<article class="cd__card is-top" id="cd-top" data-name="Applicant 1">
<span class="cd__stamp cd__stamp--pass">PASS</span>
<span class="cd__stamp cd__stamp--skip">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>Applicant 1</strong><span>Frontend · 3 yrs</span></div>
</article>
</div>
<div class="cd__acts">
<button class="cd__btn cd__btn--skip" type="button" id="cd-skip" aria-label="SKIP">
<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 cards left</span>
<button class="cd__btn cd__btn--pass" type="button" id="cd-pass" aria-label="PASS">
<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="Applicant 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>Applicant 3</strong><span>Data analysis</span></div>
</article>
<article class="cd__card is-back1" data-name="Applicant 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>Applicant 2</strong><span>Frontend</span></div>
</article>
<article class="cd__card is-top">
<span class="cd__stamp cd__stamp--pass">PASS</span>
<span class="cd__stamp cd__stamp--skip">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>Applicant 1</strong><span>Frontend · 3 yrs</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 Swipe card deck — Measured area 8.776% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · 9 Mobile Touch Gestures That Follow Your Finger
The post this demo lives in
9 Mobile Touch Gestures That Follow Your FingerMobile touch gestures turn the distance and speed of a finger on glass into a change on screen. Nine of them are below, See all demos in this post

