07Keyboard/Button Fallback for the Same Verdict
Pressing an arrow key or button without dragging routes through the exact same like/nope verdict path as a drag would.
Best forKeyboard-only card swiping
CHARGE_RATECHARGE_DECAYCHARGE_RELEASE_MIN

<main class="stage">
<div class="kf is-demo" id="kf">
<p class="kf__caption">Keyboard swipe charged by how long you hold the key</p>
<div class="kf__scene" id="kfScene">
<div class="kf__card" id="kfCard" tabindex="0" role="group" aria-roledescription="Swipe card">
<p class="kf__label" id="kfLabel">Reading list</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="Dislike">✕</button>
<button class="kf__btn kf__btn--like" id="kfLike" type="button" aria-label="Like">♥</button>
</div>
<p class="kf__status" id="kfStatus" aria-live="polite">Focus the card and hold an arrow key to charge</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 을 넘었는지로 판정한다 — 드래그의 Speed 판정과 같은 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 = ["Reading list", "Travel notes", "Morning routine", "Evening walk", "Weekend café", "Home workout log"];
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();
}
// 드래그 Speed(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 ? "Marked as liked" : "Marked as disliked"; nextCard(); }
else { x = 0; charge = 0; render(); status.textContent = "Not enough charge — snapped back"; phase = "idle"; }
return;
}
if (flung) { phase = "exit"; exitDir = dir; status.textContent = dir > 0 ? "Marked as liked" : "Marked as disliked"; }
else { phase = "settle"; status.textContent = "Not enough charge — snapped back"; }
}
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 = "Charging";
});
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 Keyboard/Button Fallback for the Same Verdict — Measured area 18.896% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · Card Swipe UI: 9 Physics-Based Deck Patterns
The post this demo lives in
Card Swipe UI: 9 Physics-Based Deck PatternsNine card swipe ui builds: The instant a release speed misses item 02's threshold, the card springs back like item 03 in See all demos in this post


