01A Card That Follows the Cursor and Tilts With Its Motion
The held card closes a fraction of the gap to the pointer every frame and tilts in proportion to its movement, then returns to level when it stops.
Best forA task-board intro screen
FOLLOW_LERPTILT_MAXTILT_PER_MOVELIFT_SHADOW

<main class="stage">
<div class="kb is-demo" id="kb"><div class="kb__board" data-role="board"><div class="kb__track" data-role="track"><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">To do</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">Doing</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">Done</span></div></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="0" aria-pressed="false"><span class="kb__label" data-role="label">Login screen</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="1" aria-pressed="false"><span class="kb__label" data-role="label">Payment link</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="2" aria-pressed="false"><span class="kb__label" data-role="label">Alert settings</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="3" aria-pressed="false"><span class="kb__label" data-role="label">Search filter</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="0" aria-pressed="false"><span class="kb__label" data-role="label">Docs cleanup</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="1" aria-pressed="false"><span class="kb__label" data-role="label">Bug fix</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="2" aria-pressed="false"><span class="kb__label" data-role="label">Release check</span></div></div><div class="kb__cursor" data-role="cursor"></div></div><p class="kb__meta" data-role="meta"></p></div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 집은 카드는 목표와의 거리 일부만 따라가고, 그 이동량이 기울기가 된다.
// 좌표는 JS 가 계산해 transform 으로만 넣는다. 열·카드 크기는 화면 높이에 맞춘 4px 격자 한 단위(U)에서 나온다.
$bg: $stage-paper;
$panel: rgba($stage-ink, .06);
$text: $subject-ink;
$mute: rgba($subject-ink, .78);
$ring: $subject-ink;
@include stage($bg);
.kb {
width: min(100%, 440px);
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: $sp-2;
position: relative;
}
.kb__board {
position: relative;
}
.kb__track { position: absolute; inset: 0; will-change: transform; }
.kb__col {
position: absolute;
top: 0;
box-sizing: border-box;
border-radius: $r-sm;
background: $panel;
overflow: hidden;
}
.kb__head {
position: relative;
box-sizing: border-box;
height: var(--head, 20px);
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
padding: 0 $sp-2;
font-size: 12px;
font-weight: 700;
line-height: 16px;
color: $text;
}
.kb__name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.kb__count { font-variant-numeric: tabular-nums; color: $mute; font-weight: 600; }
.kb__card {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
margin: 0;
display: flex;
align-items: center;
gap: $sp-1;
padding: 0 $sp-2;
border: 0;
border-radius: $r-xs;
font: inherit;
font-size: 12px;
font-weight: 600;
line-height: 16px;
text-align: left;
color: $subject-ink;
cursor: grab;
user-select: none;
touch-action: none;
box-shadow: 0 calc(var(--lift-px, 0px) * .5) var(--lift-px, 0px) rgba($stage-ink, .4), 0 1px 0 rgba($stage-ink, .14);
}
.kb__card[data-tone="0"] { background: $subject-lilac; }
.kb__card[data-tone="1"] { background: $subject-sky; }
.kb__card[data-tone="2"] { background: $subject-mint; }
.kb__card[data-tone="3"] { background: $subject-pink; }
.kb__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.kb__card:focus-visible,
.kb__card.is-focus { outline: var(--focus-ring, 2px) solid $ring; outline-offset: 2px; }
.is-held { z-index: 4; cursor: grabbing; }
.kb__ghost {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
border: 2px dashed rgba($ring, .6);
border-radius: $r-xs;
opacity: 0;
pointer-events: none;
}
.kb__cursor {
position: absolute;
left: 0;
top: 0;
width: 20px;
height: 20px;
margin: -10px 0 0 -10px;
box-sizing: border-box;
border: 2px solid $ring;
border-radius: 50%;
background: rgba($ring, .16);
opacity: 0;
pointer-events: none;
z-index: 6;
}
.kb__meta {
margin: 0;
font-size: 12px;
line-height: 16px;
color: $mute;
font-variant-numeric: tabular-nums;
}
@media (max-height: 259px) {
.kb__head, .kb__card { font-size: 11px; }
}
@media (max-width: 340px) {
.kb__card { padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.kb__card, .kb__track, .kb__ghost { transition: none; animation: none; }
.kb__cursor { display: 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);
}
.kb {
width: min(100%, 440px);
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
position: relative;
}
.kb__board {
position: relative;
}
.kb__track {
position: absolute;
inset: 0;
will-change: transform;
}
.kb__col {
position: absolute;
top: 0;
box-sizing: border-box;
border-radius: 8px;
background: rgba(23, 20, 26, 0.06);
overflow: hidden;
}
.kb__head {
position: relative;
box-sizing: border-box;
height: var(--head, 20px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 8px;
font-size: 12px;
font-weight: 700;
line-height: 16px;
color: #17141a;
}
.kb__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kb__count {
font-variant-numeric: tabular-nums;
color: rgba(23, 20, 26, 0.78);
font-weight: 600;
}
.kb__card {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
margin: 0;
display: flex;
align-items: center;
gap: 4px;
padding: 0 8px;
border: 0;
border-radius: 4px;
font: inherit;
font-size: 12px;
font-weight: 600;
line-height: 16px;
text-align: left;
color: #17141a;
cursor: grab;
user-select: none;
touch-action: none;
box-shadow: 0 calc(var(--lift-px, 0px) * 0.5) var(--lift-px, 0px) rgba(23, 20, 26, 0.4), 0 1px 0 rgba(23, 20, 26, 0.14);
}
.kb__card[data-tone="0"] {
background: #b9a0ff;
}
.kb__card[data-tone="1"] {
background: #6ec8ff;
}
.kb__card[data-tone="2"] {
background: #4cd4a6;
}
.kb__card[data-tone="3"] {
background: #ff9ebb;
}
.kb__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kb__card:focus-visible,
.kb__card.is-focus {
outline: var(--focus-ring, 2px) solid #17141a;
outline-offset: 2px;
}
.is-held {
z-index: 4;
cursor: grabbing;
}
.kb__ghost {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
border: 2px dashed rgba(23, 20, 26, 0.6);
border-radius: 4px;
opacity: 0;
pointer-events: none;
}
.kb__cursor {
position: absolute;
left: 0;
top: 0;
width: 20px;
height: 20px;
margin: -10px 0 0 -10px;
box-sizing: border-box;
border: 2px solid #17141a;
border-radius: 50%;
background: rgba(23, 20, 26, 0.16);
opacity: 0;
pointer-events: none;
z-index: 6;
}
.kb__meta {
margin: 0;
font-size: 12px;
line-height: 16px;
color: rgba(23, 20, 26, 0.78);
font-variant-numeric: tabular-nums;
}
@media (max-height: 259px) {
.kb__head, .kb__card {
font-size: 11px;
}
}
@media (max-width: 340px) {
.kb__card {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.kb__card, .kb__track, .kb__ghost {
transition: none;
animation: none;
}
.kb__cursor {
display: none;
}
}(function () {
var root = document.getElementById('kb');
var CLS = { demo: 'is-demo', held: 'is-held' };
var off = [];
var raf = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function q(sel) {
return root.querySelector(sel);
}
function qa(sel) {
return Array.prototype.slice.call(root.querySelectorAll(sel));
}
function on(target, type, fn) {
target.addEventListener(type, fn);
off.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
// 칸 크기는 화면 높이에 맞춰 U(4 또는 6) 한 단위로 정한다
var U = 4;
var CARD_H = 24;
var STEP = 28;
var HEAD = 20;
var PAD = 4;
var GAP = 8;
var onUnit = function () { };
function setUnit() {
U = window.innerHeight >= 260 ? 6 : 4;
CARD_H = 6 * U;
STEP = 7 * U;
HEAD = 5 * U;
PAD = U;
GAP = 2 * PAD;
onUnit();
}
var CYCLE_MS = 2000;
var board = q('[data-role="board"]');
var cursorEl = q('[data-role="cursor"]');
var metaEl = q('[data-role="meta"]');
var colEls = qa('[data-role="col"]');
var COLS = colEls.length;
var boardW = 0;
var boardH = 0;
var colW = 0;
var cols = [];
var cards = qa('[data-role="card"]').map(function (el, i) {
return { el: el, i: i, x: 0, y: 0, vx: 0, vy: 0, rot: 0, sc: 1, lift: 0 };
});
var demo = !reduce;
var nowMs = 0;
var lastMeta = '';
var onStop = function () { };
var afterStep = function (dt) { };
var extraSettle = function () { };
function slotX(c) {
return c * (colW + GAP) + PAD;
}
function slotY(r) {
return HEAD + 2 * PAD + r * STEP;
}
function measure() {
setUnit();
boardW = board.clientWidth;
colW = FIXED_COL > 0 ? FIXED_COL : (boardW - GAP * (COLS - 1)) / COLS;
boardH = slotY(ROWS - 1) + CARD_H + PAD;
board.style.height = boardH + 'px';
board.style.setProperty('--head', HEAD + 'px');
colEls.forEach(function (el, c) {
el.style.left = c * (colW + GAP) + 'px';
el.style.width = colW + 'px';
el.style.height = boardH + 'px';
});
cards.forEach(function (c) {
c.el.style.width = (colW - 2 * PAD) + 'px';
c.el.style.height = CARD_H + 'px';
});
}
function locate(i) {
for (var c = 0; c < cols.length; c++) {
var r = cols[c].indexOf(i);
if (r >= 0) return { c: c, r: r };
}
return { c: 0, r: 0 };
}
function spring(c, tx, ty, k, d, dt) {
c.vx += (k * (tx - c.x) - d * c.vx) * dt;
c.x += c.vx * dt;
c.vy += (k * (ty - c.y) - d * c.vy) * dt;
c.y += c.vy * dt;
}
function draw() {
cards.forEach(function (c) {
c.el.style.transform = 'translate3d(' + c.x.toFixed(2) + 'px,' + c.y.toFixed(2) + 'px,0) rotate(' + c.rot.toFixed(2) + 'deg) scale(' + c.sc.toFixed(3) + ')';
});
}
function cardAt(t) {
var n = t;
while (n && n !== board) {
for (var k = 0; k < cards.length; k++) {
if (cards[k].el === n) return cards[k];
}
n = n.parentElement;
}
return null;
}
function showCursor(p) {
if (!p) { cursorEl.style.opacity = '0'; return; }
cursorEl.style.opacity = '1';
cursorEl.style.transform = 'translate3d(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px,0)';
}
// keys: [시각 ms, x, y] 목록. 키 사이는 smoothstep 으로 잇는다
function keyAt(t, keys) {
var i = 0;
while (i < keys.length - 2 && t > keys[i + 1][0]) i++;
var a = keys[i];
var b = keys[i + 1];
var u = clamp((t - a[0]) / (b[0] - a[0]), 0, 1);
u = u * u * (3 - 2 * u);
return { x: a[1] + (b[1] - a[1]) * u, y: a[2] + (b[2] - a[2]) * u };
}
// 열·행 소수 좌표를 보드 픽셀로 바꾼다 (행 r 의 카드 중심선이 소수 r)
function pathPoint(t, keys) {
var k = keyAt(t, keys);
return { x: k.x * (colW + GAP) + colW / 2, y: slotY(0) + k.y * STEP + CARD_H / 2 };
}
function settleNow() {
cards.forEach(function (c) {
var t = targetOf(c);
c.x = t.x; c.y = t.y; c.vx = 0; c.vy = 0; c.rot = 0; c.sc = 1; c.lift = 0;
});
extraSettle();
}
// reduce: 보간 없이 입력 직후 목표 위치를 바로 그린다
function sync() {
if (!reduce) return;
settleNow();
draw();
report();
}
function report() {
var m = metaText();
if (m !== lastMeta) { lastMeta = m; metaEl.textContent = m; }
}
function stopDemo() {
if (!demo) return;
demo = false;
root.classList.remove(CLS.demo);
showCursor(null);
onStop();
}
function settleClass(c, h) {
if (c.el.classList.contains(CLS.held) && Math.abs(c.x - h.x) + Math.abs(c.y - h.y) < 1.5 && Math.abs(c.vx) + Math.abs(c.vy) < 30) {
c.el.classList.remove(CLS.held);
}
}
var t0 = -1;
var lastTs = 0;
var prevT = 0;
function frame(ts) {
if (t0 < 0) { t0 = ts; lastTs = ts; }
var dt = clamp((ts - lastTs) / 1000, 0.001, 0.033);
lastTs = ts;
nowMs = ts;
var t = (ts - t0) % CYCLE_MS;
if (demo) {
if (t < prevT) demoReset();
demoAt(t);
}
prevT = t;
cards.forEach(function (c) { stepCard(c, dt); });
afterStep(dt);
draw();
report();
raf = requestAnimationFrame(frame);
}
function boot() {
measure();
cols = initialCols();
settleNow();
draw();
report();
on(window, 'resize', function () { measure(); settleNow(); draw(); });
if (reduce) {
root.classList.remove(CLS.demo);
} else {
raf = requestAnimationFrame(frame);
}
}
var held = null;
var ptr = { x: 0, y: 0 };
var grabDX = 0;
var grabDY = 0;
onStop = function () { if (held) release(); };
function local(e) {
var r = board.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function kbd(e) {
var c = cardAt(e.target);
if (!c) return;
var k = e.key;
if (k === ' ' || k === 'Enter') {
e.preventDefault();
stopDemo();
if (held === c) {
release();
} else if (!held) {
grab(c, c.x + (colW - 2 * PAD) / 2, c.y + CARD_H / 2);
}
sync();
return;
}
if (held !== c) return;
if (k === 'Escape') { e.preventDefault(); cancel(); sync(); return; }
var dx = k === 'ArrowRight' ? colW + GAP : k === 'ArrowLeft' ? -(colW + GAP) : 0;
var dy = k === 'ArrowDown' ? STEP : k === 'ArrowUp' ? -STEP : 0;
if (dx !== 0 || dy !== 0) { e.preventDefault(); moveTo(ptr.x + dx, ptr.y + dy); sync(); }
}
on(board, 'pointerdown', function (e) {
var pe = e;
var c = cardAt(pe.target);
if (!c) return;
stopDemo();
if (held) release();
var p = local(pe);
board.setPointerCapture(pe.pointerId);
grab(c, p.x, p.y);
sync();
});
on(board, 'pointermove', function (e) {
if (!held || demo) return;
var p = local(e);
moveTo(p.x, p.y);
sync();
});
function pointerEnd() {
if (held && !demo) { release(); sync(); }
}
on(board, 'pointerup', pointerEnd);
on(board, 'pointercancel', pointerEnd);
on(root, 'keydown', function (e) { kbd(e); });
var FOLLOW_LERP = 0.25;
var TILT_MAX = 12;
var TILT_PER_MOVE = 1.2;
var LIFT_SHADOW = 18;
var ROWS = 4;
var FIXED_COL = 0;
var tgt = { x: 0, y: 0 };
var demoGrabbed = false;
var grabAt = { x: 0, y: 0 };
function initialCols() {
return [[0, 1, 2], [3, 4], [5, 6]];
}
function targetOf(c) {
if (c === held) return tgt;
var s = locate(c.i);
return { x: slotX(s.c), y: slotY(s.r) };
}
function grab(c, px, py) {
held = c;
grabDX = px - c.x;
grabDY = py - c.y;
ptr = { x: px, y: py };
tgt = { x: c.x, y: c.y };
c.el.classList.add(CLS.held);
c.el.setAttribute('aria-pressed', 'true');
}
function moveTo(px, py) {
ptr = { x: clamp(px, 0, boardW), y: clamp(py, 0, boardH) };
tgt = { x: ptr.x - grabDX, y: ptr.y - grabDY };
}
function release() {
if (held) {
held.el.setAttribute('aria-pressed', 'false');
held = null;
}
}
function cancel() {
release();
}
// 핵심: 집은 카드는 목표와의 거리 FOLLOW_LERP 만큼만 따라가고, 그 이동량으로 기울기가 정해진다
function stepCard(c, dt) {
var f = dt * 60;
if (c === held) {
var k = reduce ? 1 : 1 - Math.pow(1 - FOLLOW_LERP, f);
var nx = c.x + (tgt.x - c.x) * k;
var ny = c.y + (tgt.y - c.y) * k;
var move = (nx - c.x) / f;
c.vx = (nx - c.x) / dt;
c.vy = (ny - c.y) / dt;
c.x = nx;
c.y = ny;
var want = clamp(move * TILT_PER_MOVE, -TILT_MAX, TILT_MAX);
c.rot += (want - c.rot) * (1 - Math.pow(0.7, f));
c.lift += (1 - c.lift) * (1 - Math.pow(0.75, f));
} else {
var h = targetOf(c);
spring(c, h.x, h.y, 180, 22, dt);
c.rot += (0 - c.rot) * (1 - Math.pow(0.8, f));
c.lift += (0 - c.lift) * (1 - Math.pow(0.75, f));
settleClass(c, h);
}
c.sc = 1 + 0.05 * c.lift;
c.el.style.setProperty('--lift-px', (c.lift * LIFT_SHADOW).toFixed(1) + 'px');
}
function demoAt(t) {
var c = cards[0];
if (t >= 200 && t < 1700) {
if (!demoGrabbed) {
demoGrabbed = true;
grab(c, c.x + (colW - 2 * PAD) / 2, c.y + CARD_H / 2);
grabAt = { x: ptr.x, y: ptr.y };
}
var u = (t - 200) / 1500;
var sweep = 0.5 - 0.5 * Math.cos(u * Math.PI * 2);
moveTo(grabAt.x + boardW * 0.6 * sweep, grabAt.y + boardH * 0.22 * Math.sin(u * Math.PI * 4));
showCursor(ptr);
} else {
if (held) release();
showCursor(null);
}
}
function demoReset() {
demoGrabbed = false;
}
function metaText() {
var tilt = 0;
cards.forEach(function (c) { if (Math.abs(c.rot) > Math.abs(tilt)) tilt = c.rot; });
return 'FOLLOW_LERP ' + FOLLOW_LERP + ' · tilt ' + tilt.toFixed(1) + '°';
}
boot();
})();// FollowCard — vanilla 판(kbn-drag-pointer-lerp-tilt) 의 React 포트. 집은 카드는 목표와의 거리 일부만 따라가고, 그 이동량이 기울기가 된다.
// 소스는 vanilla 와 같은 구조(공용 엔진 + 장면 코드)이고 정식 타입을 단다.
import { useEffect, useRef } from "react";
import styles from "./FollowCard.module.scss";
export default function FollowCard() {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
type Off = () => void;
type Step = (dt: number) => void;
type Card = { el: HTMLElement; i: number; x: number; y: number; vx: number; vy: number; rot: number; sc: number; lift: number };
type Pt = { x: number; y: number };
type Slot = { c: number; r: number };
var root: HTMLElement = rootEl;
var CLS = { demo: styles["is-demo"], held: styles["is-held"] };
var off: Off[] = [];
var raf = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function q(sel: string): HTMLElement {
return root.querySelector(sel) as HTMLElement;
}
function qa(sel: string): HTMLElement[] {
return Array.prototype.slice.call(root.querySelectorAll(sel)) as HTMLElement[];
}
function on(target: EventTarget, type: string, fn: EventListener): void {
target.addEventListener(type, fn);
off.push(function () { target.removeEventListener(type, fn); });
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
// 칸 크기는 화면 높이에 맞춰 U(4 또는 6) 한 단위로 정한다
var U = 4;
var CARD_H = 24;
var STEP = 28;
var HEAD = 20;
var PAD = 4;
var GAP = 8;
var onUnit: Off = function () { };
function setUnit(): void {
U = window.innerHeight >= 260 ? 6 : 4;
CARD_H = 6 * U;
STEP = 7 * U;
HEAD = 5 * U;
PAD = U;
GAP = 2 * PAD;
onUnit();
}
var CYCLE_MS = 2000;
var board = q('[data-role="board"]');
var cursorEl = q('[data-role="cursor"]');
var metaEl = q('[data-role="meta"]');
var colEls = qa('[data-role="col"]');
var COLS = colEls.length;
var boardW = 0;
var boardH = 0;
var colW = 0;
var cols: number[][] = [];
var cards: Card[] = qa('[data-role="card"]').map(function (el: HTMLElement, i: number): Card {
return { el: el, i: i, x: 0, y: 0, vx: 0, vy: 0, rot: 0, sc: 1, lift: 0 };
});
var demo = !reduce;
var nowMs = 0;
var lastMeta = '';
var onStop: Off = function () { };
var afterStep: Step = function (dt: number) { };
var extraSettle: Off = function () { };
function slotX(c: number): number {
return c * (colW + GAP) + PAD;
}
function slotY(r: number): number {
return HEAD + 2 * PAD + r * STEP;
}
function measure(): void {
setUnit();
boardW = board.clientWidth;
colW = FIXED_COL > 0 ? FIXED_COL : (boardW - GAP * (COLS - 1)) / COLS;
boardH = slotY(ROWS - 1) + CARD_H + PAD;
board.style.height = boardH + 'px';
board.style.setProperty('--head', HEAD + 'px');
colEls.forEach(function (el: HTMLElement, c: number) {
el.style.left = c * (colW + GAP) + 'px';
el.style.width = colW + 'px';
el.style.height = boardH + 'px';
});
cards.forEach(function (c: Card) {
c.el.style.width = (colW - 2 * PAD) + 'px';
c.el.style.height = CARD_H + 'px';
});
}
function locate(i: number): Slot {
for (var c = 0; c < cols.length; c++) {
var r = cols[c].indexOf(i);
if (r >= 0) return { c: c, r: r };
}
return { c: 0, r: 0 };
}
function spring(c: Card, tx: number, ty: number, k: number, d: number, dt: number): void {
c.vx += (k * (tx - c.x) - d * c.vx) * dt;
c.x += c.vx * dt;
c.vy += (k * (ty - c.y) - d * c.vy) * dt;
c.y += c.vy * dt;
}
function draw(): void {
cards.forEach(function (c: Card) {
c.el.style.transform = 'translate3d(' + c.x.toFixed(2) + 'px,' + c.y.toFixed(2) + 'px,0) rotate(' + c.rot.toFixed(2) + 'deg) scale(' + c.sc.toFixed(3) + ')';
});
}
function cardAt(t: EventTarget | null): Card | null {
var n = t as HTMLElement | null;
while (n && n !== board) {
for (var k = 0; k < cards.length; k++) {
if (cards[k].el === n) return cards[k];
}
n = n.parentElement;
}
return null;
}
function showCursor(p: Pt | null): void {
if (!p) { cursorEl.style.opacity = '0'; return; }
cursorEl.style.opacity = '1';
cursorEl.style.transform = 'translate3d(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px,0)';
}
// keys: [시각 ms, x, y] 목록. 키 사이는 smoothstep 으로 잇는다
function keyAt(t: number, keys: number[][]): Pt {
var i = 0;
while (i < keys.length - 2 && t > keys[i + 1][0]) i++;
var a = keys[i];
var b = keys[i + 1];
var u = clamp((t - a[0]) / (b[0] - a[0]), 0, 1);
u = u * u * (3 - 2 * u);
return { x: a[1] + (b[1] - a[1]) * u, y: a[2] + (b[2] - a[2]) * u };
}
// 열·행 소수 좌표를 보드 픽셀로 바꾼다 (행 r 의 카드 중심선이 소수 r)
function pathPoint(t: number, keys: number[][]): Pt {
var k = keyAt(t, keys);
return { x: k.x * (colW + GAP) + colW / 2, y: slotY(0) + k.y * STEP + CARD_H / 2 };
}
function settleNow(): void {
cards.forEach(function (c: Card) {
var t = targetOf(c);
c.x = t.x; c.y = t.y; c.vx = 0; c.vy = 0; c.rot = 0; c.sc = 1; c.lift = 0;
});
extraSettle();
}
// reduce: 보간 없이 입력 직후 목표 위치를 바로 그린다
function sync(): void {
if (!reduce) return;
settleNow();
draw();
report();
}
function report(): void {
var m = metaText();
if (m !== lastMeta) { lastMeta = m; metaEl.textContent = m; }
}
function stopDemo(): void {
if (!demo) return;
demo = false;
root.classList.remove(CLS.demo);
showCursor(null);
onStop();
}
function settleClass(c: Card, h: Pt): void {
if (c.el.classList.contains(CLS.held) && Math.abs(c.x - h.x) + Math.abs(c.y - h.y) < 1.5 && Math.abs(c.vx) + Math.abs(c.vy) < 30) {
c.el.classList.remove(CLS.held);
}
}
var t0 = -1;
var lastTs = 0;
var prevT = 0;
function frame(ts: number): void {
if (t0 < 0) { t0 = ts; lastTs = ts; }
var dt = clamp((ts - lastTs) / 1000, 0.001, 0.033);
lastTs = ts;
nowMs = ts;
var t = (ts - t0) % CYCLE_MS;
if (demo) {
if (t < prevT) demoReset();
demoAt(t);
}
prevT = t;
cards.forEach(function (c: Card) { stepCard(c, dt); });
afterStep(dt);
draw();
report();
raf = requestAnimationFrame(frame);
}
function boot(): void {
measure();
cols = initialCols();
settleNow();
draw();
report();
on(window, 'resize', function () { measure(); settleNow(); draw(); });
if (reduce) {
root.classList.remove(CLS.demo);
} else {
raf = requestAnimationFrame(frame);
}
}
var held: Card | null = null;
var ptr: Pt = { x: 0, y: 0 };
var grabDX = 0;
var grabDY = 0;
onStop = function () { if (held) release(); };
function local(e: PointerEvent): Pt {
var r = board.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function kbd(e: KeyboardEvent): void {
var c = cardAt(e.target);
if (!c) return;
var k = e.key;
if (k === ' ' || k === 'Enter') {
e.preventDefault();
stopDemo();
if (held === c) {
release();
} else if (!held) {
grab(c, c.x + (colW - 2 * PAD) / 2, c.y + CARD_H / 2);
}
sync();
return;
}
if (held !== c) return;
if (k === 'Escape') { e.preventDefault(); cancel(); sync(); return; }
var dx = k === 'ArrowRight' ? colW + GAP : k === 'ArrowLeft' ? -(colW + GAP) : 0;
var dy = k === 'ArrowDown' ? STEP : k === 'ArrowUp' ? -STEP : 0;
if (dx !== 0 || dy !== 0) { e.preventDefault(); moveTo(ptr.x + dx, ptr.y + dy); sync(); }
}
on(board, 'pointerdown', function (e: Event) {
var pe = e as PointerEvent;
var c = cardAt(pe.target);
if (!c) return;
stopDemo();
if (held) release();
var p = local(pe);
board.setPointerCapture(pe.pointerId);
grab(c, p.x, p.y);
sync();
});
on(board, 'pointermove', function (e: Event) {
if (!held || demo) return;
var p = local(e as PointerEvent);
moveTo(p.x, p.y);
sync();
});
function pointerEnd(): void {
if (held && !demo) { release(); sync(); }
}
on(board, 'pointerup', pointerEnd);
on(board, 'pointercancel', pointerEnd);
on(root, 'keydown', function (e: Event) { kbd(e as KeyboardEvent); });
var FOLLOW_LERP = 0.25;
var TILT_MAX = 12;
var TILT_PER_MOVE = 1.2;
var LIFT_SHADOW = 18;
var ROWS = 4;
var FIXED_COL = 0;
var tgt: Pt = { x: 0, y: 0 };
var demoGrabbed = false;
var grabAt: Pt = { x: 0, y: 0 };
function initialCols(): number[][] {
return [[0, 1, 2], [3, 4], [5, 6]];
}
function targetOf(c: Card): Pt {
if (c === held) return tgt;
var s = locate(c.i);
return { x: slotX(s.c), y: slotY(s.r) };
}
function grab(c: Card, px: number, py: number): void {
held = c;
grabDX = px - c.x;
grabDY = py - c.y;
ptr = { x: px, y: py };
tgt = { x: c.x, y: c.y };
c.el.classList.add(CLS.held);
c.el.setAttribute('aria-pressed', 'true');
}
function moveTo(px: number, py: number): void {
ptr = { x: clamp(px, 0, boardW), y: clamp(py, 0, boardH) };
tgt = { x: ptr.x - grabDX, y: ptr.y - grabDY };
}
function release(): void {
if (held) {
held.el.setAttribute('aria-pressed', 'false');
held = null;
}
}
function cancel(): void {
release();
}
// 핵심: 집은 카드는 목표와의 거리 FOLLOW_LERP 만큼만 따라가고, 그 이동량으로 기울기가 정해진다
function stepCard(c: Card, dt: number): void {
var f = dt * 60;
if (c === held) {
var k = reduce ? 1 : 1 - Math.pow(1 - FOLLOW_LERP, f);
var nx = c.x + (tgt.x - c.x) * k;
var ny = c.y + (tgt.y - c.y) * k;
var move = (nx - c.x) / f;
c.vx = (nx - c.x) / dt;
c.vy = (ny - c.y) / dt;
c.x = nx;
c.y = ny;
var want = clamp(move * TILT_PER_MOVE, -TILT_MAX, TILT_MAX);
c.rot += (want - c.rot) * (1 - Math.pow(0.7, f));
c.lift += (1 - c.lift) * (1 - Math.pow(0.75, f));
} else {
var h = targetOf(c);
spring(c, h.x, h.y, 180, 22, dt);
c.rot += (0 - c.rot) * (1 - Math.pow(0.8, f));
c.lift += (0 - c.lift) * (1 - Math.pow(0.75, f));
settleClass(c, h);
}
c.sc = 1 + 0.05 * c.lift;
c.el.style.setProperty('--lift-px', (c.lift * LIFT_SHADOW).toFixed(1) + 'px');
}
function demoAt(t: number): void {
var c = cards[0];
if (t >= 200 && t < 1700) {
if (!demoGrabbed) {
demoGrabbed = true;
grab(c, c.x + (colW - 2 * PAD) / 2, c.y + CARD_H / 2);
grabAt = { x: ptr.x, y: ptr.y };
}
var u = (t - 200) / 1500;
var sweep = 0.5 - 0.5 * Math.cos(u * Math.PI * 2);
moveTo(grabAt.x + boardW * 0.6 * sweep, grabAt.y + boardH * 0.22 * Math.sin(u * Math.PI * 4));
showCursor(ptr);
} else {
if (held) release();
showCursor(null);
}
}
function demoReset(): void {
demoGrabbed = false;
}
function metaText(): string {
var tilt = 0;
cards.forEach(function (c: Card) { if (Math.abs(c.rot) > Math.abs(tilt)) tilt = c.rot; });
return 'FOLLOW_LERP ' + FOLLOW_LERP + ' · tilt ' + tilt.toFixed(1) + '°';
}
boot();
return () => {
cancelAnimationFrame(raf);
off.forEach(function (fn: Off) { fn(); });
};
}, []);
return (
<main className={styles["stage"]}><div className={styles["kb"] + " " + styles["is-demo"]} ref={rootRef}><div className={styles["kb__board"]} data-role="board"><div className={styles["kb__track"]} data-role="track"><div className={styles["kb__col"]} data-role="col"><div className={styles["kb__head"]}><span className={styles["kb__name"]}>할 일</span></div></div><div className={styles["kb__col"]} data-role="col"><div className={styles["kb__head"]}><span className={styles["kb__name"]}>진행 중</span></div></div><div className={styles["kb__col"]} data-role="col"><div className={styles["kb__head"]}><span className={styles["kb__name"]}>완료</span></div></div><div className={styles["kb__card"]} role="button" tabIndex="0" data-role="card" data-tone="0" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">로그인 화면</span></div><div className={styles["kb__card"]} role="button" tabIndex="0" data-role="card" data-tone="1" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">결제 연동</span></div><div className={styles["kb__card"]} role="button" tabIndex="0" data-role="card" data-tone="2" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">알림 설정</span></div><div className={styles["kb__card"]} role="button" tabIndex="0" data-role="card" data-tone="3" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">검색 필터</span></div><div className={styles["kb__card"]} role="button" tabIndex="0" data-role="card" data-tone="0" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">문서 정리</span></div><div className={styles["kb__card"]} role="button" tabIndex="0" data-role="card" data-tone="1" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">오류 수정</span></div><div className={styles["kb__card"]} role="button" tabIndex="0" data-role="card" data-tone="2" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">배포 점검</span></div></div><div className={styles["kb__cursor"]} data-role="cursor"></div></div><p className={styles["kb__meta"]} data-role="meta"></p></div></main>
);
}01 A Card That Follows the Cursor and Tilts With Its Motion — Measured area 35.966% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Kanban Board: 9 Demos With Springs and Lerp
The post this demo lives in
CSS Kanban Board: 9 Demos With Springs and LerpA css kanban board that only swaps card order leaves the held card off the cursor, hides the drop slot and accepts cards See all demos in this post
If you like this effect - similar demos
Dashboard UI03 A 3D Pie Chart That Tilts the Disc and Draws Its Side WallPie Chart CSS: 9 Slices That Pop, Tilt and Morph
Dashboard UI03 Scroll that follows the cursorCommand Palette UI Design — 9 That Really Open
Dashboard UI03 An Hourglass That Counts Time With Grains Squeezing Through the Neck and Piling UpCSS Countdown Timer: 9 Clocks That Drain and Flip