kanban board html css js 9パターン — 掴んだカードが追従し置く場所が開く
kanban board html css js をカードの入れ替えだけで作ると、掴んだカードはカーソルとずれ、置く場所は見えず、満杯の列にも入る — 01番・02番・04番が計算で直す。
カンバンの九つの場面をこのページで見る
格子の各マスは、短い自動の動きをひとりで再生します。マウスやキーボード、ボタンを最初に操作した時点で、動かす主役はあなたの入力に替わります。通信はなく、距離はすべて定数でシードも固定なので、見えるのは「ボードがカードの行き先をどう決め、どう動かすか」だけです。
01 kbn-drag-pointer-lerp-tilt
02 kbn-drop-slot-gap-spring
03 kbn-drop-settle-overshoot
04 kbn-wip-limit-pressure
05 kbn-edge-autoscroll-ramp
06 kbn-count-badge-digit-strip
07 kbn-priority-sort-flip-stagger
08 kbn-progress-ring-from-columns
09 kbn-keyboard-move-announce
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 カーソルに追従しカードが動きで傾く
- 02 置く場所が開く列
- 03 離すと所定の位置に収まるカード
- 04 上限に近づく列の見出し
- 05 端でボードが動く自動スクロール
- 06 カード数が変わる列のバッジ
- 07 優先度の並べ替えでカードが移動する場面
- 08 カードの移動で長さが決まる進捗リング
- 09 キーボードでカードを動かし文で知らせる
kanban board html css js 9パターン — ポインタ・間隔・バネを計算するとボードが手に付く
九つは、一枚のボードを使う順に並べました。01 はカードがカーソルを追う動き、02 は置く場所を開く間隔、03 は離したカードが収まるバネです。04 は上限に近づく列の圧力、05 は端でボードが滑る速さ、06 は列見出しの数字バッジ、07 は並べ替えボタンがカードを動かす順番を扱います。08 は完了列を進捗リングに変え、09 はキーボードでカードを動かして結果を文で知らせます。
こんな画面で使います
タスクボードを紹介する画面なら、カードがカーソルに付いてくる 01 と、離すと収まる 03 が合います。列の間の移動を説明するページには 02、進行中の作業数を絞る解説には 04 が合います。列の多い広いボードは 05、列見出しのカード数表示は 06 の担当です。優先度の並べ替えボタンには 07、プロジェクトの要約カードには 08、ポインタなしで使えるボードには 09 を選んでください。tailwind css kanban board と手書きを比べている場合も、ここの計算は素の JS なので、クラスの付け方が変わっても持ち込めます。
九つはどれも同じ盤面の割り付けを使います。単位 U は、ウィンドウの高さが260px以上なら6px、それ未満なら4pxです。カードの高さは 6U、カード一段は 7U、列見出しは 5U なので、同じコードのまま、小さな埋め込みでは4px、大きな画面では6pxの単位で動きます。
隣の記事とどこが違うか
ドラッグで並べ替える UI 9選は、ドラッグで順番が変わるリストを中心にした記事です。ダッシュボード部品 9選には、動かない三列のカード盤面があります。この記事はその間を埋め、掴んだカードの追従係数、開いた場所の間隔、満杯の列の圧力を、それぞれ一つの数値に固定しました。グラフ側の操作はチャート UI デザイン 9選、順位の入れ替わる行はリーダーボード UI デザイン 9選が扱っています。
技法別のコードで見るカンバン
ライブラリは読み込まず、手書きの JS と CSS だけです。01 から 05 は pointerdown と setPointerCapture でカードを掴みます。06 から 09 はポインタのドラッグを持たず、クリックと並べ替えボタン、キーボードで動かす構成です。どのファイルも動きの設定を matchMedia で読み、フレームは requestAnimationFrame で回します。以下の断片は、動きを決める区間だけをソースのまま抜き出したものです。
01カーソルに追従しカードが動きで傾く
掴んだカードは、毎フレームのポインタとの距離のうち FOLLOW_LERP の割合だけを詰め、そのフレームの移動量に比例して傾きます。止まれば水平に戻ります。この場面は列を変えないので、離したカードは元の場所に帰ります。レンダリングでは累積面積36.0%、強度平均66.0で、動いたフレームは23個中23個でした。

<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">未着手</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">進行中</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">完了</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">ログイン画面</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">決済連携</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">通知設定</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">検索フィルター</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">文書の整理</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">不具合修正</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">リリース確認</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 · カーソルに追従しカードが動きで傾く — 計測 累積面積 35.966% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 核心: 掴んだカードは目標との距離の 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');
}
02置く場所が開く列
ポインタの高さを各カードの中線と比べて入る位置を求め、その位置より下のカードを SLOT_GAP ぶんバネで下げて空きを開けます。単位がカード一段なので、値が1ならカード一枚分の空きがちょうどできます。レンダリングでは累積面積22.6%、強度平均138.0で、動いたフレームは23個中23個でした。

<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">未着手</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">進行中</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">完了</span></div></div><div class="kb__ghost" data-role="ghost"></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">ログイン画面</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">決済連携</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">通知設定</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">検索フィルター</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">文書の整理</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">不具合修正</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">リリース確認</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-ink;
$panel: $tick-panel;
$text: $stage-paper;
$mute: rgba($stage-paper, .78);
$ring: $stage-paper;
@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: #17141a;
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: #231f28;
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: #ffffff;
}
.kb__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kb__count {
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 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 #ffffff;
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(255, 255, 255, 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 #ffffff;
border-radius: 50%;
background: rgba(255, 255, 255, 0.16);
opacity: 0;
pointer-events: none;
z-index: 6;
}
.kb__meta {
margin: 0;
font-size: 12px;
line-height: 16px;
color: rgba(255, 255, 255, 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 SLOT_GAP = 1;
var SPRING_K = 170;
var SPRING_DAMP = 21;
var MIDLINE_BIAS = 0;
var ROWS = 4;
var FIXED_COL = 0;
var tgt = { x: 0, y: 0 };
var ins = null;
var origin = { c: 0, r: 0 };
var demoGrabbed = false;
var KEYS = [[200, 0, 1], [650, 1, -0.3], [1000, 1, 0.5], [1350, 1, 1.6], [1700, 1, 1.6]];
function insertionAt(px, py) {
var c = clamp(Math.floor(px / (colW + GAP)), 0, COLS - 1);
var r = 0;
for (var k = 0; k < cols[c].length; k++) {
if (slotY(k) + CARD_H / 2 + MIDLINE_BIAS < py) r = k + 1;
}
return { c: c, r: r };
}
var ghost = q('[data-role="ghost"]');
var gx = 0;
var gy = 0;
var ghostO = 0;
function paintGhost() {
ghost.style.opacity = ghostO.toFixed(3);
ghost.style.transform = 'translate3d(' + gx.toFixed(1) + 'px,' + gy.toFixed(1) + 'px,0)';
}
function ghostStep(dt) {
var f = dt * 60;
var show = held && ins ? 1 : 0;
if (ins) {
gx += (slotX(ins.c) - gx) * (1 - Math.pow(0.7, f));
gy += (slotY(ins.r) - gy) * (1 - Math.pow(0.7, f));
}
ghostO += (show - ghostO) * (1 - Math.pow(0.7, f));
paintGhost();
}
function initialCols() {
return [[0, 1, 2], [3, 4], [5, 6]];
}
// 놓을 자리 이후의 카드는 SLOT_GAP 칸만큼 아래로 목표가 밀린다
function targetOf(c) {
if (c === held) return tgt;
var s = locate(c.i);
var y = slotY(s.r);
if (ins && ins.c === s.c && s.r >= ins.r) y += SLOT_GAP * STEP;
return { x: slotX(s.c), y: y };
}
function grab(c, px, py) {
origin = locate(c.i);
cols[origin.c].splice(origin.r, 1);
ins = { c: origin.c, r: origin.r };
held = c;
grabDX = px - c.x;
grabDY = py - c.y;
ptr = { x: px, y: py };
tgt = { x: c.x, y: c.y };
gx = slotX(origin.c);
gy = slotY(origin.r);
ghost.style.width = (colW - 2 * PAD) + 'px';
ghost.style.height = CARD_H + 'px';
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 };
ins = insertionAt(ptr.x, ptr.y);
}
function release() {
if (!held) return;
var s = ins ? ins : origin;
cols[s.c].splice(s.r, 0, held.i);
held.el.setAttribute('aria-pressed', 'false');
held = null;
ins = null;
}
function cancel() {
ins = { c: origin.c, r: origin.r };
release();
}
function stepCard(c, dt) {
var f = dt * 60;
if (c === held) {
var k = reduce ? 1 : 1 - Math.pow(0.5, f);
c.vx = ((tgt.x - c.x) * k) / dt;
c.vy = ((tgt.y - c.y) * k) / dt;
c.x += (tgt.x - c.x) * k;
c.y += (tgt.y - c.y) * k;
c.lift += (1 - c.lift) * (1 - Math.pow(0.75, f));
} else {
var h = targetOf(c);
spring(c, h.x, h.y, SPRING_K, SPRING_DAMP, dt);
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 * 14).toFixed(1) + 'px');
}
afterStep = ghostStep;
extraSettle = function () {
ghostO = held && ins ? 1 : 0;
if (ins) { gx = slotX(ins.c); gy = slotY(ins.r); }
paintGhost();
};
function demoAt(t) {
var c = cards[1];
if (t >= 200 && t < 1700) {
if (!demoGrabbed) {
demoGrabbed = true;
grab(c, c.x + (colW - 2 * PAD) / 2, c.y + CARD_H / 2);
}
var p = pathPoint(t, KEYS);
moveTo(p.x, p.y);
showCursor(ptr);
} else {
if (held) release();
showCursor(null);
}
}
function demoReset() {
demoGrabbed = false;
cols = initialCols();
ins = null;
}
function metaText() {
return 'SLOT_GAP ' + SLOT_GAP + (ins ? ' · slot ' + (ins.c + 1) + '/' + (ins.r + 1) : '');
}
boot();
})();// DropSlot — vanilla 판(kbn-drop-slot-gap-spring) 의 React 포트. 포인터 높이와 카드 중간선을 비교해 삽입 위치를 구하고, 아래 카드를 스프링으로 밀어 자리를 연다.
// 소스는 vanilla 와 같은 구조(공용 엔진 + 장면 코드)이고 정식 타입을 단다.
import { useEffect, useRef } from "react";
import styles from "./DropSlot.module.scss";
export default function DropSlot() {
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 SLOT_GAP = 1;
var SPRING_K = 170;
var SPRING_DAMP = 21;
var MIDLINE_BIAS = 0;
var ROWS = 4;
var FIXED_COL = 0;
var tgt: Pt = { x: 0, y: 0 };
var ins: Slot | null = null;
var origin: Slot = { c: 0, r: 0 };
var demoGrabbed = false;
var KEYS: number[][] = [[200, 0, 1], [650, 1, -0.3], [1000, 1, 0.5], [1350, 1, 1.6], [1700, 1, 1.6]];
function insertionAt(px: number, py: number): Slot {
var c = clamp(Math.floor(px / (colW + GAP)), 0, COLS - 1);
var r = 0;
for (var k = 0; k < cols[c].length; k++) {
if (slotY(k) + CARD_H / 2 + MIDLINE_BIAS < py) r = k + 1;
}
return { c: c, r: r };
}
var ghost = q('[data-role="ghost"]');
var gx = 0;
var gy = 0;
var ghostO = 0;
function paintGhost(): void {
ghost.style.opacity = ghostO.toFixed(3);
ghost.style.transform = 'translate3d(' + gx.toFixed(1) + 'px,' + gy.toFixed(1) + 'px,0)';
}
function ghostStep(dt: number): void {
var f = dt * 60;
var show = held && ins ? 1 : 0;
if (ins) {
gx += (slotX(ins.c) - gx) * (1 - Math.pow(0.7, f));
gy += (slotY(ins.r) - gy) * (1 - Math.pow(0.7, f));
}
ghostO += (show - ghostO) * (1 - Math.pow(0.7, f));
paintGhost();
}
function initialCols(): number[][] {
return [[0, 1, 2], [3, 4], [5, 6]];
}
// 놓을 자리 이후의 카드는 SLOT_GAP 칸만큼 아래로 목표가 밀린다
function targetOf(c: Card): Pt {
if (c === held) return tgt;
var s = locate(c.i);
var y = slotY(s.r);
if (ins && ins.c === s.c && s.r >= ins.r) y += SLOT_GAP * STEP;
return { x: slotX(s.c), y: y };
}
function grab(c: Card, px: number, py: number): void {
origin = locate(c.i);
cols[origin.c].splice(origin.r, 1);
ins = { c: origin.c, r: origin.r };
held = c;
grabDX = px - c.x;
grabDY = py - c.y;
ptr = { x: px, y: py };
tgt = { x: c.x, y: c.y };
gx = slotX(origin.c);
gy = slotY(origin.r);
ghost.style.width = (colW - 2 * PAD) + 'px';
ghost.style.height = CARD_H + 'px';
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 };
ins = insertionAt(ptr.x, ptr.y);
}
function release(): void {
if (!held) return;
var s = ins ? ins : origin;
cols[s.c].splice(s.r, 0, held.i);
held.el.setAttribute('aria-pressed', 'false');
held = null;
ins = null;
}
function cancel(): void {
ins = { c: origin.c, r: origin.r };
release();
}
function stepCard(c: Card, dt: number): void {
var f = dt * 60;
if (c === held) {
var k = reduce ? 1 : 1 - Math.pow(0.5, f);
c.vx = ((tgt.x - c.x) * k) / dt;
c.vy = ((tgt.y - c.y) * k) / dt;
c.x += (tgt.x - c.x) * k;
c.y += (tgt.y - c.y) * k;
c.lift += (1 - c.lift) * (1 - Math.pow(0.75, f));
} else {
var h = targetOf(c);
spring(c, h.x, h.y, SPRING_K, SPRING_DAMP, dt);
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 * 14).toFixed(1) + 'px');
}
afterStep = ghostStep;
extraSettle = function () {
ghostO = held && ins ? 1 : 0;
if (ins) { gx = slotX(ins.c); gy = slotY(ins.r); }
paintGhost();
};
function demoAt(t: number): void {
var c = cards[1];
if (t >= 200 && t < 1700) {
if (!demoGrabbed) {
demoGrabbed = true;
grab(c, c.x + (colW - 2 * PAD) / 2, c.y + CARD_H / 2);
}
var p = pathPoint(t, KEYS);
moveTo(p.x, p.y);
showCursor(ptr);
} else {
if (held) release();
showCursor(null);
}
}
function demoReset(): void {
demoGrabbed = false;
cols = initialCols();
ins = null;
}
function metaText(): string {
return 'SLOT_GAP ' + SLOT_GAP + (ins ? ' · slot ' + (ins.c + 1) + '/' + (ins.r + 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__ghost"]} data-role="ghost"></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>
);
}02 · 置く場所が開く列 — 計測 累積面積 22.584% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function insertionAt(px, py) {
var c = clamp(Math.floor(px / (colW + GAP)), 0, COLS - 1);
var r = 0;
for (var k = 0; k < cols[c].length; k++) {
if (slotY(k) + CARD_H / 2 + MIDLINE_BIAS < py) r = k + 1;
}
return { c: c, r: r };
}
// 置く場所より後ろのカードは、目標が SLOT_GAP 段ぶん下へ押される
function targetOf(c) {
if (c === held) return tgt;
var s = locate(c.i);
var y = slotY(s.r);
if (ins && ins.c === s.c && s.r >= ins.r) y += SLOT_GAP * STEP;
return { x: slotX(s.c), y: y };
}
03離すと所定の位置に収まるカード
掴んだカードはポインタより HOVER_PX だけ上に浮き、離すと収まりのバネが空きまで下ろします。目標を一度越えて戻り、影は残りの高さに比例して縮みます。レンダリングでは累積面積27.4%、強度平均76.0で、動いたフレームは23個中20個でした。

<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">未着手</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">進行中</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">完了</span></div></div><div class="kb__ghost" data-role="ghost"></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">ログイン画面</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">決済連携</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">通知設定</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">検索フィルター</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">文書の整理</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">不具合修正</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">リリース確認</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 SETTLE_K = 300;
var SETTLE_DAMP = 14;
var DROP_SHADOW = 22;
var OVERSHOOT_CAP = 6;
var HOVER_PX = 18;
var SLOT_GAP = 1;
var MIDLINE_BIAS = 0;
var ROWS = 4;
var FIXED_COL = 0;
var tgt = { x: 0, y: 0 };
var ins = null;
var origin = { c: 0, r: 0 };
var dropped = null;
var demoGrabbed = false;
var KEYS = [[200, 0, 0], [500, 0.5, -0.2], [900, 1, 0.6]];
function insertionAt(px, py) {
var c = clamp(Math.floor(px / (colW + GAP)), 0, COLS - 1);
var r = 0;
for (var k = 0; k < cols[c].length; k++) {
if (slotY(k) + CARD_H / 2 + MIDLINE_BIAS < py) r = k + 1;
}
return { c: c, r: r };
}
var ghost = q('[data-role="ghost"]');
var gx = 0;
var gy = 0;
var ghostO = 0;
function paintGhost() {
ghost.style.opacity = ghostO.toFixed(3);
ghost.style.transform = 'translate3d(' + gx.toFixed(1) + 'px,' + gy.toFixed(1) + 'px,0)';
}
function ghostStep(dt) {
var f = dt * 60;
var show = held && ins ? 1 : 0;
if (ins) {
gx += (slotX(ins.c) - gx) * (1 - Math.pow(0.7, f));
gy += (slotY(ins.r) - gy) * (1 - Math.pow(0.7, f));
}
ghostO += (show - ghostO) * (1 - Math.pow(0.7, f));
paintGhost();
}
function initialCols() {
return [[0, 1, 2], [3, 4], [5, 6]];
}
function targetOf(c) {
if (c === held) return tgt;
var s = locate(c.i);
var y = slotY(s.r);
if (ins && ins.c === s.c && s.r >= ins.r) y += SLOT_GAP * STEP;
return { x: slotX(s.c), y: y };
}
function grab(c, px, py) {
origin = locate(c.i);
cols[origin.c].splice(origin.r, 1);
ins = { c: origin.c, r: origin.r };
held = c;
dropped = null;
grabDX = px - c.x;
grabDY = py - c.y;
ptr = { x: px, y: py };
tgt = { x: c.x, y: c.y - HOVER_PX };
gx = slotX(origin.c);
gy = slotY(origin.r);
ghost.style.width = (colW - 2 * PAD) + 'px';
ghost.style.height = CARD_H + 'px';
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 - HOVER_PX };
ins = insertionAt(ptr.x, ptr.y);
}
function release() {
if (!held) return;
var s = ins ? ins : origin;
cols[s.c].splice(s.r, 0, held.i);
held.el.setAttribute('aria-pressed', 'false');
dropped = held;
held = null;
ins = null;
}
function cancel() {
ins = { c: origin.c, r: origin.r };
release();
}
// 핵심: 놓은 카드는 SETTLE_K·SETTLE_DAMP 스프링으로 슬롯에 내려앉고, 그림자는 슬롯과의 높이 차에 비례해 줄어든다
function stepCard(c, dt) {
var f = dt * 60;
if (c === held) {
var k = reduce ? 1 : 1 - Math.pow(0.5, f);
c.vx = ((tgt.x - c.x) * k) / dt;
c.vy = ((tgt.y - c.y) * k) / dt;
c.x += (tgt.x - c.x) * k;
c.y += (tgt.y - c.y) * k;
c.lift += (1 - c.lift) * (1 - Math.pow(0.75, f));
} else if (c === dropped) {
var h = targetOf(c);
spring(c, h.x, h.y, SETTLE_K, SETTLE_DAMP, dt);
var over = c.y - h.y;
if (over > OVERSHOOT_CAP) {
c.y = h.y + OVERSHOOT_CAP;
if (c.vy > 0) c.vy = 0;
}
var want = clamp((h.y - c.y) / HOVER_PX, 0, 1);
c.lift += (want - c.lift) * (1 - Math.pow(0.5, f));
if (Math.abs(over) < 0.4 && Math.abs(c.vy) < 12 && Math.abs(c.x - h.x) < 0.5) {
dropped = null;
c.el.classList.remove(CLS.held);
}
} else {
var g = targetOf(c);
spring(c, g.x, g.y, 170, 21, dt);
c.lift += (0 - c.lift) * (1 - Math.pow(0.75, f));
settleClass(c, g);
}
c.sc = 1 + 0.05 * c.lift;
c.el.style.setProperty('--lift-px', (c.lift * DROP_SHADOW).toFixed(1) + 'px');
}
afterStep = ghostStep;
extraSettle = function () {
dropped = null;
ghostO = held && ins ? 1 : 0;
if (ins) { gx = slotX(ins.c); gy = slotY(ins.r); }
paintGhost();
};
function demoAt(t) {
var c = cards[0];
if (t >= 200 && t < 1000) {
if (!demoGrabbed) {
demoGrabbed = true;
grab(c, c.x + (colW - 2 * PAD) / 2, c.y + CARD_H / 2);
}
var p = pathPoint(t, KEYS);
moveTo(p.x, p.y);
showCursor(ptr);
} else {
if (held) release();
showCursor(null);
}
}
function demoReset() {
demoGrabbed = false;
cols = initialCols();
ins = null;
dropped = null;
}
function metaText() {
var s = 0;
cards.forEach(function (c) { s = Math.max(s, c.lift); });
return 'SETTLE_K ' + SETTLE_K + ' · shadow ' + (s * DROP_SHADOW).toFixed(0) + 'px';
}
boot();
})();// SettleCard — vanilla 판(kbn-drop-settle-overshoot) 의 React 포트. 놓은 카드는 언더댐핑 스프링으로 슬롯에 내려앉고 그림자는 높이 차에 비례해 줄어든다.
// 소스는 vanilla 와 같은 구조(공용 엔진 + 장면 코드)이고 정식 타입을 단다.
import { useEffect, useRef } from "react";
import styles from "./SettleCard.module.scss";
export default function SettleCard() {
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 SETTLE_K = 300;
var SETTLE_DAMP = 14;
var DROP_SHADOW = 22;
var OVERSHOOT_CAP = 6;
var HOVER_PX = 18;
var SLOT_GAP = 1;
var MIDLINE_BIAS = 0;
var ROWS = 4;
var FIXED_COL = 0;
var tgt: Pt = { x: 0, y: 0 };
var ins: Slot | null = null;
var origin: Slot = { c: 0, r: 0 };
var dropped: Card | null = null;
var demoGrabbed = false;
var KEYS: number[][] = [[200, 0, 0], [500, 0.5, -0.2], [900, 1, 0.6]];
function insertionAt(px: number, py: number): Slot {
var c = clamp(Math.floor(px / (colW + GAP)), 0, COLS - 1);
var r = 0;
for (var k = 0; k < cols[c].length; k++) {
if (slotY(k) + CARD_H / 2 + MIDLINE_BIAS < py) r = k + 1;
}
return { c: c, r: r };
}
var ghost = q('[data-role="ghost"]');
var gx = 0;
var gy = 0;
var ghostO = 0;
function paintGhost(): void {
ghost.style.opacity = ghostO.toFixed(3);
ghost.style.transform = 'translate3d(' + gx.toFixed(1) + 'px,' + gy.toFixed(1) + 'px,0)';
}
function ghostStep(dt: number): void {
var f = dt * 60;
var show = held && ins ? 1 : 0;
if (ins) {
gx += (slotX(ins.c) - gx) * (1 - Math.pow(0.7, f));
gy += (slotY(ins.r) - gy) * (1 - Math.pow(0.7, f));
}
ghostO += (show - ghostO) * (1 - Math.pow(0.7, f));
paintGhost();
}
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);
var y = slotY(s.r);
if (ins && ins.c === s.c && s.r >= ins.r) y += SLOT_GAP * STEP;
return { x: slotX(s.c), y: y };
}
function grab(c: Card, px: number, py: number): void {
origin = locate(c.i);
cols[origin.c].splice(origin.r, 1);
ins = { c: origin.c, r: origin.r };
held = c;
dropped = null;
grabDX = px - c.x;
grabDY = py - c.y;
ptr = { x: px, y: py };
tgt = { x: c.x, y: c.y - HOVER_PX };
gx = slotX(origin.c);
gy = slotY(origin.r);
ghost.style.width = (colW - 2 * PAD) + 'px';
ghost.style.height = CARD_H + 'px';
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 - HOVER_PX };
ins = insertionAt(ptr.x, ptr.y);
}
function release(): void {
if (!held) return;
var s = ins ? ins : origin;
cols[s.c].splice(s.r, 0, held.i);
held.el.setAttribute('aria-pressed', 'false');
dropped = held;
held = null;
ins = null;
}
function cancel(): void {
ins = { c: origin.c, r: origin.r };
release();
}
// 핵심: 놓은 카드는 SETTLE_K·SETTLE_DAMP 스프링으로 슬롯에 내려앉고, 그림자는 슬롯과의 높이 차에 비례해 줄어든다
function stepCard(c: Card, dt: number): void {
var f = dt * 60;
if (c === held) {
var k = reduce ? 1 : 1 - Math.pow(0.5, f);
c.vx = ((tgt.x - c.x) * k) / dt;
c.vy = ((tgt.y - c.y) * k) / dt;
c.x += (tgt.x - c.x) * k;
c.y += (tgt.y - c.y) * k;
c.lift += (1 - c.lift) * (1 - Math.pow(0.75, f));
} else if (c === dropped) {
var h = targetOf(c);
spring(c, h.x, h.y, SETTLE_K, SETTLE_DAMP, dt);
var over = c.y - h.y;
if (over > OVERSHOOT_CAP) {
c.y = h.y + OVERSHOOT_CAP;
if (c.vy > 0) c.vy = 0;
}
var want = clamp((h.y - c.y) / HOVER_PX, 0, 1);
c.lift += (want - c.lift) * (1 - Math.pow(0.5, f));
if (Math.abs(over) < 0.4 && Math.abs(c.vy) < 12 && Math.abs(c.x - h.x) < 0.5) {
dropped = null;
c.el.classList.remove(CLS.held);
}
} else {
var g = targetOf(c);
spring(c, g.x, g.y, 170, 21, dt);
c.lift += (0 - c.lift) * (1 - Math.pow(0.75, f));
settleClass(c, g);
}
c.sc = 1 + 0.05 * c.lift;
c.el.style.setProperty('--lift-px', (c.lift * DROP_SHADOW).toFixed(1) + 'px');
}
afterStep = ghostStep;
extraSettle = function () {
dropped = null;
ghostO = held && ins ? 1 : 0;
if (ins) { gx = slotX(ins.c); gy = slotY(ins.r); }
paintGhost();
};
function demoAt(t: number): void {
var c = cards[0];
if (t >= 200 && t < 1000) {
if (!demoGrabbed) {
demoGrabbed = true;
grab(c, c.x + (colW - 2 * PAD) / 2, c.y + CARD_H / 2);
}
var p = pathPoint(t, KEYS);
moveTo(p.x, p.y);
showCursor(ptr);
} else {
if (held) release();
showCursor(null);
}
}
function demoReset(): void {
demoGrabbed = false;
cols = initialCols();
ins = null;
dropped = null;
}
function metaText(): string {
var s = 0;
cards.forEach(function (c: Card) { s = Math.max(s, c.lift); });
return 'SETTLE_K ' + SETTLE_K + ' · shadow ' + (s * DROP_SHADOW).toFixed(0) + 'px';
}
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__ghost"]} data-role="ghost"></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>
);
}03 · 離すと所定の位置に収まるカード — 計測 累積面積 27.447% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 核心: 離したカードは SETTLE_K・SETTLE_DAMP のバネで枠に収まり、影は枠との高さの差に比例して縮む
function stepCard(c, dt) {
var f = dt * 60;
if (c === held) {
var k = reduce ? 1 : 1 - Math.pow(0.5, f);
c.vx = ((tgt.x - c.x) * k) / dt;
c.vy = ((tgt.y - c.y) * k) / dt;
c.x += (tgt.x - c.x) * k;
c.y += (tgt.y - c.y) * k;
c.lift += (1 - c.lift) * (1 - Math.pow(0.75, f));
} else if (c === dropped) {
var h = targetOf(c);
spring(c, h.x, h.y, SETTLE_K, SETTLE_DAMP, dt);
var over = c.y - h.y;
if (over > OVERSHOOT_CAP) {
c.y = h.y + OVERSHOOT_CAP;
if (c.vy > 0) c.vy = 0;
}
var want = clamp((h.y - c.y) / HOVER_PX, 0, 1);
c.lift += (want - c.lift) * (1 - Math.pow(0.5, f));
if (Math.abs(over) < 0.4 && Math.abs(c.vy) < 12 && Math.abs(c.x - h.x) < 0.5) {
dropped = null;
c.el.classList.remove(CLS.held);
}
04上限に近づく列の見出し
中央の列のカード数を WIP_LIMIT で割った値が圧力になり、見出しのゲージと背景の濃さを上げます。満杯の列には空きが開かないので、置いたカードはバネで元の列へ戻ります。レンダリングでは累積面積29.1%、強度平均116.9で、動いたフレームは23個中23個でした。

<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">未着手</span><span class="kb__count" data-role="count"></span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">進行中</span><span class="kb__count" data-role="count"></span><span class="kb__gauge" data-role="gauge"></span></div><div class="kb__tint" data-role="tint"></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">完了</span><span class="kb__count" data-role="count"></span></div></div><div class="kb__ghost" data-role="ghost"></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">ログイン画面</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">決済連携</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">通知設定</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">検索フィルター</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">文書の整理</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">不具合修正</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">リリース確認</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-ink;
$panel: $tick-panel;
$text: $stage-paper;
$mute: rgba($stage-paper, .78);
$ring: $stage-paper;
@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;
}
.kb__tint { position: absolute; inset: 0; background: $subject-pink; opacity: 0; pointer-events: none; }
// 게이지는 시작 변(왼쪽)에서 자란다 — 중심이 아니라 붙는 변이 기준이라 transform-origin 이 0 50% 다
.kb__gauge {
position: absolute;
left: $sp-2;
right: $sp-2;
bottom: 0;
height: 4px;
border-radius: $r-pill;
background: $subject-mint;
transform-origin: 0 50%;
transform: scaleX(0);
}
.is-blocked .kb__gauge { background: $subject-pink; }
@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: #17141a;
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: #231f28;
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: #ffffff;
}
.kb__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kb__count {
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 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 #ffffff;
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(255, 255, 255, 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 #ffffff;
border-radius: 50%;
background: rgba(255, 255, 255, 0.16);
opacity: 0;
pointer-events: none;
z-index: 6;
}
.kb__meta {
margin: 0;
font-size: 12px;
line-height: 16px;
color: rgba(255, 255, 255, 0.78);
font-variant-numeric: tabular-nums;
}
.kb__tint {
position: absolute;
inset: 0;
background: #ff9ebb;
opacity: 0;
pointer-events: none;
}
.kb__gauge {
position: absolute;
left: 8px;
right: 8px;
bottom: 0;
height: 4px;
border-radius: 999px;
background: #4cd4a6;
transform-origin: 0 50%;
transform: scaleX(0);
}
.is-blocked .kb__gauge {
background: #ff9ebb;
}
@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', blocked: 'is-blocked' };
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 WIP_LIMIT = 4;
var PRESSURE_LERP = 0.15;
var RETURN_K = 150;
var TINT_MAX = 0.5;
var MIDLINE_BIAS = 0;
var DOING = 1;
var ROWS = 4;
var FIXED_COL = 0;
var tgt = { x: 0, y: 0 };
var ins = null;
var origin = { c: 0, r: 0 };
var blocked = false;
var returning = null;
var pressure = 0;
var demoGrabbed = false;
var KEYS_A = [[200, 0, 0], [600, 1, 2.6]];
var KEYS_B = [[850, 0, 0], [1250, 1, 2.6]];
var gauge = q('[data-role="gauge"]');
var tint = q('[data-role="tint"]');
var counts = qa('[data-role="count"]');
function insertionAt(px, py) {
var c = clamp(Math.floor(px / (colW + GAP)), 0, COLS - 1);
var r = 0;
for (var k = 0; k < cols[c].length; k++) {
if (slotY(k) + CARD_H / 2 + MIDLINE_BIAS < py) r = k + 1;
}
return { c: c, r: r };
}
var ghost = q('[data-role="ghost"]');
var gx = 0;
var gy = 0;
var ghostO = 0;
function paintGhost() {
ghost.style.opacity = ghostO.toFixed(3);
ghost.style.transform = 'translate3d(' + gx.toFixed(1) + 'px,' + gy.toFixed(1) + 'px,0)';
}
function ghostStep(dt) {
var f = dt * 60;
var show = held && ins ? 1 : 0;
if (ins) {
gx += (slotX(ins.c) - gx) * (1 - Math.pow(0.7, f));
gy += (slotY(ins.r) - gy) * (1 - Math.pow(0.7, f));
}
ghostO += (show - ghostO) * (1 - Math.pow(0.7, f));
paintGhost();
}
function initialCols() {
return [[0, 1, 2], [3, 4, 5], [6]];
}
function targetOf(c) {
if (c === held) return tgt;
var s = locate(c.i);
var y = slotY(s.r);
if (ins && ins.c === s.c && s.r >= ins.r) y += STEP;
return { x: slotX(s.c), y: y };
}
// 열의 카드 수 ÷ 한도. 집은 카드가 그 열 위에 있으면 하나 더 들어온 것으로 미리 센다
function pressureGoal() {
var n = cols[DOING].length;
if (held) n = blocked ? WIP_LIMIT : n + (ins && ins.c === DOING ? 1 : 0);
return Math.min(1, n / WIP_LIMIT);
}
function grab(c, px, py) {
origin = locate(c.i);
cols[origin.c].splice(origin.r, 1);
ins = { c: origin.c, r: origin.r };
blocked = false;
held = c;
returning = null;
grabDX = px - c.x;
grabDY = py - c.y;
ptr = { x: px, y: py };
tgt = { x: c.x, y: c.y };
gx = slotX(origin.c);
gy = slotY(origin.r);
ghost.style.width = (colW - 2 * PAD) + 'px';
ghost.style.height = CARD_H + 'px';
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 };
var s = insertionAt(ptr.x, ptr.y);
blocked = s.c === DOING && cols[DOING].length >= WIP_LIMIT;
ins = blocked ? null : s;
}
// 한도에 찬 열이면 카드는 원래 자리로 돌아간다 (RETURN_K 스프링이 한 번 넘었다 돌아온다)
function release() {
if (!held) return;
var s = ins ? ins : origin;
if (!ins) returning = held;
cols[s.c].splice(s.r, 0, held.i);
held.el.setAttribute('aria-pressed', 'false');
held = null;
ins = null;
blocked = false;
}
function cancel() {
ins = null;
release();
}
function stepCard(c, dt) {
var f = dt * 60;
if (c === held) {
var k = reduce ? 1 : 1 - Math.pow(0.5, f);
c.vx = ((tgt.x - c.x) * k) / dt;
c.vy = ((tgt.y - c.y) * k) / dt;
c.x += (tgt.x - c.x) * k;
c.y += (tgt.y - c.y) * k;
c.lift += (1 - c.lift) * (1 - Math.pow(0.75, f));
} else {
var h = targetOf(c);
if (c === returning) {
spring(c, h.x, h.y, RETURN_K, 12, dt);
if (Math.abs(c.x - h.x) + Math.abs(c.y - h.y) < 1 && Math.abs(c.vx) + Math.abs(c.vy) < 20) returning = null;
} else {
spring(c, h.x, h.y, 170, 21, dt);
}
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 * 14).toFixed(1) + 'px');
}
function paintPressure() {
gauge.style.transform = 'scaleX(' + pressure.toFixed(3) + ')';
tint.style.opacity = (Math.pow(pressure, 3) * TINT_MAX).toFixed(3);
colEls[DOING].classList.toggle(CLS.blocked, blocked);
counts.forEach(function (el, c) {
el.textContent = c === DOING ? cols[c].length + '/' + WIP_LIMIT : String(cols[c].length);
});
}
afterStep = function (dt) {
pressure += (pressureGoal() - pressure) * (1 - Math.pow(1 - PRESSURE_LERP, dt * 60));
paintPressure();
ghostStep(dt);
};
extraSettle = function () {
pressure = pressureGoal();
paintPressure();
ghostO = held && ins ? 1 : 0;
if (ins) { gx = slotX(ins.c); gy = slotY(ins.r); }
paintGhost();
};
function drive(c, t, keys) {
if (held !== c) grab(c, c.x + (colW - 2 * PAD) / 2, c.y + CARD_H / 2);
var p = pathPoint(t, keys);
moveTo(p.x, p.y);
showCursor(ptr);
}
function demoAt(t) {
if (t >= 200 && t < 700) {
drive(cards[0], t, KEYS_A);
} else if (t >= 850 && t < 1400) {
drive(cards[1], t, KEYS_B);
} else {
if (held) release();
showCursor(null);
}
}
function demoReset() {
cols = initialCols();
ins = null;
blocked = false;
returning = null;
}
function metaText() {
return 'WIP_LIMIT ' + WIP_LIMIT + ' · pressure ' + pressure.toFixed(2);
}
boot();
})();// WipLimit — vanilla 판(kbn-wip-limit-pressure) 의 React 포트. 열의 카드 수 ÷ 한도 비율을 보간해 게이지와 배경 농도를 올리고, 찬 열은 카드를 되돌려 보낸다.
// 소스는 vanilla 와 같은 구조(공용 엔진 + 장면 코드)이고 정식 타입을 단다.
import { useEffect, useRef } from "react";
import styles from "./WipLimit.module.scss";
export default function WipLimit() {
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"], blocked: styles["is-blocked"] };
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 WIP_LIMIT = 4;
var PRESSURE_LERP = 0.15;
var RETURN_K = 150;
var TINT_MAX = 0.5;
var MIDLINE_BIAS = 0;
var DOING = 1;
var ROWS = 4;
var FIXED_COL = 0;
var tgt: Pt = { x: 0, y: 0 };
var ins: Slot | null = null;
var origin: Slot = { c: 0, r: 0 };
var blocked = false;
var returning: Card | null = null;
var pressure = 0;
var demoGrabbed = false;
var KEYS_A: number[][] = [[200, 0, 0], [600, 1, 2.6]];
var KEYS_B: number[][] = [[850, 0, 0], [1250, 1, 2.6]];
var gauge = q('[data-role="gauge"]');
var tint = q('[data-role="tint"]');
var counts = qa('[data-role="count"]');
function insertionAt(px: number, py: number): Slot {
var c = clamp(Math.floor(px / (colW + GAP)), 0, COLS - 1);
var r = 0;
for (var k = 0; k < cols[c].length; k++) {
if (slotY(k) + CARD_H / 2 + MIDLINE_BIAS < py) r = k + 1;
}
return { c: c, r: r };
}
var ghost = q('[data-role="ghost"]');
var gx = 0;
var gy = 0;
var ghostO = 0;
function paintGhost(): void {
ghost.style.opacity = ghostO.toFixed(3);
ghost.style.transform = 'translate3d(' + gx.toFixed(1) + 'px,' + gy.toFixed(1) + 'px,0)';
}
function ghostStep(dt: number): void {
var f = dt * 60;
var show = held && ins ? 1 : 0;
if (ins) {
gx += (slotX(ins.c) - gx) * (1 - Math.pow(0.7, f));
gy += (slotY(ins.r) - gy) * (1 - Math.pow(0.7, f));
}
ghostO += (show - ghostO) * (1 - Math.pow(0.7, f));
paintGhost();
}
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);
var y = slotY(s.r);
if (ins && ins.c === s.c && s.r >= ins.r) y += STEP;
return { x: slotX(s.c), y: y };
}
// 열의 카드 수 ÷ 한도. 집은 카드가 그 열 위에 있으면 하나 더 들어온 것으로 미리 센다
function pressureGoal(): number {
var n = cols[DOING].length;
if (held) n = blocked ? WIP_LIMIT : n + (ins && ins.c === DOING ? 1 : 0);
return Math.min(1, n / WIP_LIMIT);
}
function grab(c: Card, px: number, py: number): void {
origin = locate(c.i);
cols[origin.c].splice(origin.r, 1);
ins = { c: origin.c, r: origin.r };
blocked = false;
held = c;
returning = null;
grabDX = px - c.x;
grabDY = py - c.y;
ptr = { x: px, y: py };
tgt = { x: c.x, y: c.y };
gx = slotX(origin.c);
gy = slotY(origin.r);
ghost.style.width = (colW - 2 * PAD) + 'px';
ghost.style.height = CARD_H + 'px';
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 };
var s = insertionAt(ptr.x, ptr.y);
blocked = s.c === DOING && cols[DOING].length >= WIP_LIMIT;
ins = blocked ? null : s;
}
// 한도에 찬 열이면 카드는 원래 자리로 돌아간다 (RETURN_K 스프링이 한 번 넘었다 돌아온다)
function release(): void {
if (!held) return;
var s = ins ? ins : origin;
if (!ins) returning = held;
cols[s.c].splice(s.r, 0, held.i);
held.el.setAttribute('aria-pressed', 'false');
held = null;
ins = null;
blocked = false;
}
function cancel(): void {
ins = null;
release();
}
function stepCard(c: Card, dt: number): void {
var f = dt * 60;
if (c === held) {
var k = reduce ? 1 : 1 - Math.pow(0.5, f);
c.vx = ((tgt.x - c.x) * k) / dt;
c.vy = ((tgt.y - c.y) * k) / dt;
c.x += (tgt.x - c.x) * k;
c.y += (tgt.y - c.y) * k;
c.lift += (1 - c.lift) * (1 - Math.pow(0.75, f));
} else {
var h = targetOf(c);
if (c === returning) {
spring(c, h.x, h.y, RETURN_K, 12, dt);
if (Math.abs(c.x - h.x) + Math.abs(c.y - h.y) < 1 && Math.abs(c.vx) + Math.abs(c.vy) < 20) returning = null;
} else {
spring(c, h.x, h.y, 170, 21, dt);
}
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 * 14).toFixed(1) + 'px');
}
function paintPressure(): void {
gauge.style.transform = 'scaleX(' + pressure.toFixed(3) + ')';
tint.style.opacity = (Math.pow(pressure, 3) * TINT_MAX).toFixed(3);
colEls[DOING].classList.toggle(CLS.blocked, blocked);
counts.forEach(function (el: HTMLElement, c: number) {
el.textContent = c === DOING ? cols[c].length + '/' + WIP_LIMIT : String(cols[c].length);
});
}
afterStep = function (dt: number) {
pressure += (pressureGoal() - pressure) * (1 - Math.pow(1 - PRESSURE_LERP, dt * 60));
paintPressure();
ghostStep(dt);
};
extraSettle = function () {
pressure = pressureGoal();
paintPressure();
ghostO = held && ins ? 1 : 0;
if (ins) { gx = slotX(ins.c); gy = slotY(ins.r); }
paintGhost();
};
function drive(c: Card, t: number, keys: number[][]): void {
if (held !== c) grab(c, c.x + (colW - 2 * PAD) / 2, c.y + CARD_H / 2);
var p = pathPoint(t, keys);
moveTo(p.x, p.y);
showCursor(ptr);
}
function demoAt(t: number): void {
if (t >= 200 && t < 700) {
drive(cards[0], t, KEYS_A);
} else if (t >= 850 && t < 1400) {
drive(cards[1], t, KEYS_B);
} else {
if (held) release();
showCursor(null);
}
}
function demoReset(): void {
cols = initialCols();
ins = null;
blocked = false;
returning = null;
}
function metaText(): string {
return 'WIP_LIMIT ' + WIP_LIMIT + ' · pressure ' + pressure.toFixed(2);
}
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><span className={styles["kb__count"]} data-role="count"></span></div></div><div className={styles["kb__col"]} data-role="col"><div className={styles["kb__head"]}><span className={styles["kb__name"]}>진행 중</span><span className={styles["kb__count"]} data-role="count"></span><span className={styles["kb__gauge"]} data-role="gauge"></span></div><div className={styles["kb__tint"]} data-role="tint"></div></div><div className={styles["kb__col"]} data-role="col"><div className={styles["kb__head"]}><span className={styles["kb__name"]}>완료</span><span className={styles["kb__count"]} data-role="count"></span></div></div><div className={styles["kb__ghost"]} data-role="ghost"></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>
);
}04 · 上限に近づく列の見出し — 計測 累積面積 29.077% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function pressureGoal() {
var n = cols[DOING].length;
if (held) n = blocked ? WIP_LIMIT : n + (ins && ins.c === DOING ? 1 : 0);
return Math.min(1, n / WIP_LIMIT);
}
function grab(c, px, py) {
origin = locate(c.i);
cols[origin.c].splice(origin.r, 1);
blocked = s.c === DOING && cols[DOING].length >= WIP_LIMIT;
ins = blocked ? null : s;
}
05端でボードが動く自動スクロール
ポインタが端の領域に入った深さ(0から1)を RAMP_POWER 乗するので、端に近づくほど一フレームの移動量が急に増えます。深さ0.5なら3.5px、深さ1なら14pxです。これは六列のボードで、レンダリングでは累積面積51.0%、強度平均71.2、動いたフレームは23個中23個でした。

<main class="stage">
<div class="kb is-demo" id="kb"><div class="kb__board" data-role="board" tabindex="0" role="group" aria-label="カンバンボード — 左右キーで横にスクロール"><div class="kb__track" data-role="track"><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">バックログ</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">分析</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">設計</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">実装</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">レビュー</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">リリース</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">ログイン画面</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">決済連携</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">通知設定</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">検索フィルター</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">文書の整理</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">不具合修正</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">リリース確認</span></div></div><div class="kb__edge kb__edge--l" data-role="edge-l"></div><div class="kb__edge kb__edge--r" data-role="edge-r"></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;
overflow: hidden;
}
.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;
}
.kb__edge { position: absolute; top: 0; bottom: 0; width: 25%; opacity: 0; pointer-events: none; z-index: 5; }
.kb__edge--l { left: 0; background: linear-gradient(90deg, rgba($subject-lilac, .55), rgba($subject-lilac, 0)); }
.kb__edge--r { right: 0; background: linear-gradient(270deg, rgba($subject-lilac, .55), rgba($subject-lilac, 0)); }
.kb__board:focus-visible { outline: 2px solid $ring; outline-offset: 2px; }
@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;
overflow: hidden;
}
.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;
}
.kb__edge {
position: absolute;
top: 0;
bottom: 0;
width: 25%;
opacity: 0;
pointer-events: none;
z-index: 5;
}
.kb__edge--l {
left: 0;
background: linear-gradient(90deg, rgba(185, 160, 255, 0.55), rgba(185, 160, 255, 0));
}
.kb__edge--r {
right: 0;
background: linear-gradient(270deg, rgba(185, 160, 255, 0.55), rgba(185, 160, 255, 0));
}
.kb__board:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@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 EDGE_ZONE = 16 * U;
var MAX_STEP = 14;
var RAMP_POWER = 2;
var COLUMN_COUNT = COLS;
var ROWS = 4;
var FIXED_COL = 26 * U;
onUnit = function () {
EDGE_ZONE = 16 * U;
FIXED_COL = 26 * U;
};
var track = q('[data-role="track"]');
var edgeL = q('[data-role="edge-l"]');
var edgeR = q('[data-role="edge-r"]');
var scroll = 0;
var scrollGoal = 0;
var stepNow = 0;
var origin = { c: 0, r: 0 };
var demoGrabbed = false;
var KEYS = [[200, 0.18, 0.29], [650, 0.72, 0.3], [1400, 0.99, 0.3], [1800, 0.5, 0.4]];
function initialCols() {
return [[0], [1, 2], [3], [4, 5], [6], []];
}
function maxScroll() {
return Math.max(0, COLS * (colW + GAP) - GAP - boardW);
}
// 핵심: 가장자리 구역에 들어간 깊이(0 에서 1)를 RAMP_POWER 제곱해 한 프레임 이동량으로 쓴다
function rampStep(x) {
var left = clamp((EDGE_ZONE - x) / EDGE_ZONE, 0, 1);
var right = clamp((x - (boardW - EDGE_ZONE)) / EDGE_ZONE, 0, 1);
return MAX_STEP * (Math.pow(right, RAMP_POWER) - Math.pow(left, RAMP_POWER));
}
function targetOf(c) {
if (c === held) return { x: ptr.x + scroll - grabDX, y: ptr.y - grabDY };
var s = locate(c.i);
return { x: slotX(s.c), y: slotY(s.r) };
}
function grab(c, px, py) {
origin = locate(c.i);
cols[origin.c].splice(origin.r, 1);
held = c;
grabDX = px + scroll - c.x;
grabDY = py - c.y;
ptr = { x: px, y: py };
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) };
if (reduce) scroll = clamp(scroll + rampStep(ptr.x), 0, maxScroll());
}
function release() {
if (!held) return;
var wx = ptr.x + scroll;
var c = clamp(Math.floor(wx / (colW + GAP)), 0, COLS - 1);
var r = 0;
for (var k = 0; k < cols[c].length; k++) {
if (slotY(k) + CARD_H / 2 < ptr.y) r = k + 1;
}
cols[c].splice(r, 0, held.i);
held.el.setAttribute('aria-pressed', 'false');
held = null;
}
function cancel() {
if (!held) return;
cols[origin.c].splice(origin.r, 0, held.i);
held.el.setAttribute('aria-pressed', 'false');
held = null;
}
function stepCard(c, dt) {
var f = dt * 60;
if (c === held) {
var t = targetOf(c);
var k = reduce ? 1 : 1 - Math.pow(0.5, f);
c.x += (t.x - c.x) * k;
c.y += (t.y - c.y) * k;
c.lift += (1 - c.lift) * (1 - Math.pow(0.75, f));
} else {
var h = targetOf(c);
spring(c, h.x, h.y, 170, 21, dt);
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 * 14).toFixed(1) + 'px');
}
function paintTrack() {
track.style.transform = 'translate3d(' + (-scroll).toFixed(1) + 'px,0,0)';
var l = held ? clamp((EDGE_ZONE - ptr.x) / EDGE_ZONE, 0, 1) : 0;
var r = held ? clamp((ptr.x - (boardW - EDGE_ZONE)) / EDGE_ZONE, 0, 1) : 0;
edgeL.style.opacity = l.toFixed(3);
edgeR.style.opacity = r.toFixed(3);
}
afterStep = function (dt) {
var f = dt * 60;
if (held) {
stepNow = rampStep(ptr.x);
scroll = clamp(scroll + stepNow * f, 0, maxScroll());
scrollGoal = scroll;
} else {
stepNow = 0;
scroll += (scrollGoal - scroll) * (1 - Math.pow(0.88, f));
}
paintTrack();
};
extraSettle = function () {
scroll = scrollGoal = clamp(held ? scroll : scrollGoal, 0, maxScroll());
paintTrack();
};
on(board, 'keydown', function (e) {
var ke = e;
if (ke.target !== board) return;
var dir = ke.key === 'ArrowRight' ? 1 : ke.key === 'ArrowLeft' ? -1 : 0;
if (dir === 0) return;
ke.preventDefault();
stopDemo();
scrollGoal = clamp(scrollGoal + dir * (colW + GAP), 0, maxScroll());
sync();
});
function demoAt(t) {
var c = cards[0];
if (t >= 200 && t < 1850) {
if (!demoGrabbed) {
demoGrabbed = true;
grab(c, c.x + (colW - 2 * PAD) / 2, c.y + CARD_H / 2);
}
var p = keyAt(t, KEYS);
moveTo(p.x * boardW, p.y * boardH);
showCursor(ptr);
} else {
if (held) release();
showCursor(null);
}
}
function demoReset() {
demoGrabbed = false;
cols = initialCols();
scrollGoal = 0;
}
function metaText() {
return 'RAMP_POWER ' + RAMP_POWER + ' · step ' + stepNow.toFixed(1) + ' px/frame';
}
boot();
})();// EdgeScroll — vanilla 판(kbn-edge-autoscroll-ramp) 의 React 포트. 가장자리 구역 안쪽 깊이를 거듭제곱해 한 프레임 스크롤 이동량을 정한다.
// 소스는 vanilla 와 같은 구조(공용 엔진 + 장면 코드)이고 정식 타입을 단다.
import { useEffect, useRef } from "react";
import styles from "./EdgeScroll.module.scss";
export default function EdgeScroll() {
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 EDGE_ZONE = 16 * U;
var MAX_STEP = 14;
var RAMP_POWER = 2;
var COLUMN_COUNT = COLS;
var ROWS = 4;
var FIXED_COL = 26 * U;
onUnit = function () {
EDGE_ZONE = 16 * U;
FIXED_COL = 26 * U;
};
var track = q('[data-role="track"]');
var edgeL = q('[data-role="edge-l"]');
var edgeR = q('[data-role="edge-r"]');
var scroll = 0;
var scrollGoal = 0;
var stepNow = 0;
var origin: Slot = { c: 0, r: 0 };
var demoGrabbed = false;
var KEYS: number[][] = [[200, 0.18, 0.29], [650, 0.72, 0.3], [1400, 0.99, 0.3], [1800, 0.5, 0.4]];
function initialCols(): number[][] {
return [[0], [1, 2], [3], [4, 5], [6], []];
}
function maxScroll(): number {
return Math.max(0, COLS * (colW + GAP) - GAP - boardW);
}
// 핵심: 가장자리 구역에 들어간 깊이(0 에서 1)를 RAMP_POWER 제곱해 한 프레임 이동량으로 쓴다
function rampStep(x: number): number {
var left = clamp((EDGE_ZONE - x) / EDGE_ZONE, 0, 1);
var right = clamp((x - (boardW - EDGE_ZONE)) / EDGE_ZONE, 0, 1);
return MAX_STEP * (Math.pow(right, RAMP_POWER) - Math.pow(left, RAMP_POWER));
}
function targetOf(c: Card): Pt {
if (c === held) return { x: ptr.x + scroll - grabDX, y: ptr.y - grabDY };
var s = locate(c.i);
return { x: slotX(s.c), y: slotY(s.r) };
}
function grab(c: Card, px: number, py: number): void {
origin = locate(c.i);
cols[origin.c].splice(origin.r, 1);
held = c;
grabDX = px + scroll - c.x;
grabDY = py - c.y;
ptr = { x: px, y: py };
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) };
if (reduce) scroll = clamp(scroll + rampStep(ptr.x), 0, maxScroll());
}
function release(): void {
if (!held) return;
var wx = ptr.x + scroll;
var c = clamp(Math.floor(wx / (colW + GAP)), 0, COLS - 1);
var r = 0;
for (var k = 0; k < cols[c].length; k++) {
if (slotY(k) + CARD_H / 2 < ptr.y) r = k + 1;
}
cols[c].splice(r, 0, held.i);
held.el.setAttribute('aria-pressed', 'false');
held = null;
}
function cancel(): void {
if (!held) return;
cols[origin.c].splice(origin.r, 0, held.i);
held.el.setAttribute('aria-pressed', 'false');
held = null;
}
function stepCard(c: Card, dt: number): void {
var f = dt * 60;
if (c === held) {
var t = targetOf(c);
var k = reduce ? 1 : 1 - Math.pow(0.5, f);
c.x += (t.x - c.x) * k;
c.y += (t.y - c.y) * k;
c.lift += (1 - c.lift) * (1 - Math.pow(0.75, f));
} else {
var h = targetOf(c);
spring(c, h.x, h.y, 170, 21, dt);
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 * 14).toFixed(1) + 'px');
}
function paintTrack(): void {
track.style.transform = 'translate3d(' + (-scroll).toFixed(1) + 'px,0,0)';
var l = held ? clamp((EDGE_ZONE - ptr.x) / EDGE_ZONE, 0, 1) : 0;
var r = held ? clamp((ptr.x - (boardW - EDGE_ZONE)) / EDGE_ZONE, 0, 1) : 0;
edgeL.style.opacity = l.toFixed(3);
edgeR.style.opacity = r.toFixed(3);
}
afterStep = function (dt: number) {
var f = dt * 60;
if (held) {
stepNow = rampStep(ptr.x);
scroll = clamp(scroll + stepNow * f, 0, maxScroll());
scrollGoal = scroll;
} else {
stepNow = 0;
scroll += (scrollGoal - scroll) * (1 - Math.pow(0.88, f));
}
paintTrack();
};
extraSettle = function () {
scroll = scrollGoal = clamp(held ? scroll : scrollGoal, 0, maxScroll());
paintTrack();
};
on(board, 'keydown', function (e: Event) {
var ke = e as KeyboardEvent;
if (ke.target !== board) return;
var dir = ke.key === 'ArrowRight' ? 1 : ke.key === 'ArrowLeft' ? -1 : 0;
if (dir === 0) return;
ke.preventDefault();
stopDemo();
scrollGoal = clamp(scrollGoal + dir * (colW + GAP), 0, maxScroll());
sync();
});
function demoAt(t: number): void {
var c = cards[0];
if (t >= 200 && t < 1850) {
if (!demoGrabbed) {
demoGrabbed = true;
grab(c, c.x + (colW - 2 * PAD) / 2, c.y + CARD_H / 2);
}
var p = keyAt(t, KEYS);
moveTo(p.x * boardW, p.y * boardH);
showCursor(ptr);
} else {
if (held) release();
showCursor(null);
}
}
function demoReset(): void {
demoGrabbed = false;
cols = initialCols();
scrollGoal = 0;
}
function metaText(): string {
return 'RAMP_POWER ' + RAMP_POWER + ' · step ' + stepNow.toFixed(1) + ' px/frame';
}
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" tabIndex="0" role="group" aria-label="칸반 보드 — 좌우 방향키로 가로 이동"><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__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__edge"] + " " + styles["kb__edge--l"]} data-role="edge-l"></div><div className={styles["kb__edge"] + " " + styles["kb__edge--r"]} data-role="edge-r"></div><div className={styles["kb__cursor"]} data-role="cursor"></div></div><p className={styles["kb__meta"]} data-role="meta"></p></div></main>
);
}05 · 端でボードが動く自動スクロール — 計測 累積面積 50.992% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 核心: 端の領域に入った深さ(0から1)を RAMP_POWER 乗して、一フレームの移動量に使う
function rampStep(x) {
var left = clamp((EDGE_ZONE - x) / EDGE_ZONE, 0, 1);
var right = clamp((x - (boardW - EDGE_ZONE)) / EDGE_ZONE, 0, 1);
return MAX_STEP * (Math.pow(right, RAMP_POWER) - Math.pow(left, RAMP_POWER));
}
06カード数が変わる列のバッジ
カードを押すと隣の列の末尾へ移り、その列見出しの数字の帯が新しい件数の位置までバネで滑ります。増えれば上へ、減れば下へ動き、切り替わる瞬間に小さく一度だけ脈打ちます。レンダリングでは累積面積37.3%、強度平均142.4で、動いたフレームは23個中23個でした。

<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">未着手</span><span class="kb__badge" data-role="badge"><i class="kb__flash" data-role="flash"></i><span class="kb__strip" data-role="strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i></span></span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">進行中</span><span class="kb__badge" data-role="badge"><i class="kb__flash" data-role="flash"></i><span class="kb__strip" data-role="strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i></span></span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">完了</span><span class="kb__badge" data-role="badge"><i class="kb__flash" data-role="flash"></i><span class="kb__strip" data-role="strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i></span></span></div></div><button class="kb__card" type="button" data-role="card" data-tone="0"><span class="kb__label" data-role="label">ログイン画面</span></button><button class="kb__card" type="button" data-role="card" data-tone="1"><span class="kb__label" data-role="label">決済連携</span></button><button class="kb__card" type="button" data-role="card" data-tone="2"><span class="kb__label" data-role="label">通知設定</span></button><button class="kb__card" type="button" data-role="card" data-tone="3"><span class="kb__label" data-role="label">検索フィルター</span></button><button class="kb__card" type="button" data-role="card" data-tone="0"><span class="kb__label" data-role="label">文書の整理</span></button><button class="kb__card" type="button" data-role="card" data-tone="1"><span class="kb__label" data-role="label">不具合修正</span></button><button class="kb__card" type="button" data-role="card" data-tone="2"><span class="kb__label" data-role="label">リリース確認</span></button></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-ink;
$panel: $tick-panel;
$text: $stage-paper;
$mute: rgba($stage-paper, .78);
$ring: $stage-paper;
@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: pointer;
user-select: none;
touch-action: manipulation;
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;
}
.kb__badge {
position: relative;
box-sizing: border-box;
width: 24px;
height: var(--digit, 16px);
overflow: hidden;
border-radius: $r-xs;
background: $stage-paper;
color: $subject-ink;
}
.kb__flash { position: absolute; inset: 0; opacity: 0; background: $subject-mint; }
.kb__flash.is-down { background: $subject-pink; }
.kb__flash.is-up { background: $subject-mint; }
.kb__strip { position: relative; display: flex; flex-direction: column; will-change: transform; }
.kb__strip i {
display: block;
height: var(--digit, 16px);
line-height: var(--digit, 16px);
text-align: center;
font-style: normal;
font-size: 12px;
font-weight: 700;
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: #17141a;
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: #231f28;
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: #ffffff;
}
.kb__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kb__count {
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 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: pointer;
user-select: none;
touch-action: manipulation;
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 #ffffff;
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(255, 255, 255, 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 #ffffff;
border-radius: 50%;
background: rgba(255, 255, 255, 0.16);
opacity: 0;
pointer-events: none;
z-index: 6;
}
.kb__meta {
margin: 0;
font-size: 12px;
line-height: 16px;
color: rgba(255, 255, 255, 0.78);
font-variant-numeric: tabular-nums;
}
.kb__badge {
position: relative;
box-sizing: border-box;
width: 24px;
height: var(--digit, 16px);
overflow: hidden;
border-radius: 4px;
background: #ffffff;
color: #17141a;
}
.kb__flash {
position: absolute;
inset: 0;
opacity: 0;
background: #4cd4a6;
}
.kb__flash.is-down {
background: #ff9ebb;
}
.kb__flash.is-up {
background: #4cd4a6;
}
.kb__strip {
position: relative;
display: flex;
flex-direction: column;
will-change: transform;
}
.kb__strip i {
display: block;
height: var(--digit, 16px);
line-height: var(--digit, 16px);
text-align: center;
font-style: normal;
font-size: 12px;
font-weight: 700;
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', up: 'is-up', down: 'is-down' };
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 STRIP_K = 240;
var STRIP_DAMP = 17;
var DIGIT_HEIGHT = 4 * U;
var PULSE_SCALE = 1.18;
var ROWS = 4;
var FIXED_COL = 0;
var strips = qa('[data-role="strip"]').map(function (el, c) {
return {
el: el,
badge: colEls[c].querySelector('[data-role="badge"]'),
flash: colEls[c].querySelector('[data-role="flash"]'),
p: 0, v: 0, n: 0, pulse: 0
};
});
var MOVES = [[250, 0], [750, 3], [1250, 1], [1750, 5]];
var demoN = 0;
onUnit = function () {
DIGIT_HEIGHT = 4 * U;
board.style.setProperty('--digit', DIGIT_HEIGHT + 'px');
};
function initialCols() {
return [[0, 1, 2], [3, 4], [5, 6]];
}
function targetOf(c) {
var s = locate(c.i);
return { x: slotX(s.c), y: slotY(s.r) };
}
function advance(c) {
var s = locate(c.i);
cols[s.c].splice(s.r, 1);
cols[(s.c + 1) % COLS].push(c.i);
}
function stepCard(c, dt) {
var h = targetOf(c);
spring(c, h.x, h.y, 170, 21, dt);
}
function paintStrips() {
strips.forEach(function (s) {
s.el.style.transform = 'translate3d(0,' + (-s.p * DIGIT_HEIGHT).toFixed(2) + 'px,0)';
s.badge.style.transform = 'scale(' + (1 + (PULSE_SCALE - 1) * s.pulse).toFixed(3) + ')';
s.flash.style.opacity = (s.pulse * 0.9).toFixed(3);
});
}
// 핵심: 열의 카드 수가 목표 숫자 위치이고, 숫자 띠가 그 위치로 스프링 이동한다 (늘면 위로, 줄면 아래로)
afterStep = function (dt) {
strips.forEach(function (s, c) {
var n = cols[c].length;
if (n !== s.n) {
s.flash.classList.toggle(CLS.up, n > s.n);
s.flash.classList.toggle(CLS.down, n < s.n);
s.n = n;
s.pulse = 1;
}
s.v += (STRIP_K * (n - s.p) - STRIP_DAMP * s.v) * dt;
s.p += s.v * dt;
s.pulse *= Math.pow(0.9, dt * 60);
});
paintStrips();
};
extraSettle = function () {
strips.forEach(function (s, c) {
s.n = cols[c].length;
s.p = s.n;
s.v = 0;
s.pulse = 0;
});
paintStrips();
};
on(board, 'click', function (e) {
var c = cardAt(e.target);
if (!c) return;
stopDemo();
advance(c);
sync();
});
function demoAt(t) {
while (demoN < MOVES.length && t >= MOVES[demoN][0]) {
advance(cards[MOVES[demoN][1]]);
demoN++;
}
}
function demoReset() {
demoN = 0;
cols = initialCols();
}
function metaText() {
return 'STRIP_K ' + STRIP_K + ' · counts ' + cols.map(function (l) { return String(l.length); }).join(' ');
}
boot();
})();// CountBadge — vanilla 판(kbn-count-badge-digit-strip) 의 React 포트. 열의 카드 수가 숫자 띠의 목표 위치이고, 띠는 스프링으로 그 위치까지 이동한다.
// 소스는 vanilla 와 같은 구조(공용 엔진 + 장면 코드)이고 정식 타입을 단다.
import { useEffect, useRef } from "react";
import styles from "./CountBadge.module.scss";
export default function CountBadge() {
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"], up: styles["is-up"], down: styles["is-down"] };
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 STRIP_K = 240;
var STRIP_DAMP = 17;
var DIGIT_HEIGHT = 4 * U;
var PULSE_SCALE = 1.18;
var ROWS = 4;
var FIXED_COL = 0;
type Strip = { el: HTMLElement; badge: HTMLElement; flash: HTMLElement; p: number; v: number; n: number; pulse: number };
var strips: Strip[] = qa('[data-role="strip"]').map(function (el: HTMLElement, c: number): Strip {
return {
el: el,
badge: colEls[c].querySelector('[data-role="badge"]') as HTMLElement,
flash: colEls[c].querySelector('[data-role="flash"]') as HTMLElement,
p: 0, v: 0, n: 0, pulse: 0
};
});
var MOVES: number[][] = [[250, 0], [750, 3], [1250, 1], [1750, 5]];
var demoN = 0;
onUnit = function () {
DIGIT_HEIGHT = 4 * U;
board.style.setProperty('--digit', DIGIT_HEIGHT + 'px');
};
function initialCols(): number[][] {
return [[0, 1, 2], [3, 4], [5, 6]];
}
function targetOf(c: Card): Pt {
var s = locate(c.i);
return { x: slotX(s.c), y: slotY(s.r) };
}
function advance(c: Card): void {
var s = locate(c.i);
cols[s.c].splice(s.r, 1);
cols[(s.c + 1) % COLS].push(c.i);
}
function stepCard(c: Card, dt: number): void {
var h = targetOf(c);
spring(c, h.x, h.y, 170, 21, dt);
}
function paintStrips(): void {
strips.forEach(function (s: Strip) {
s.el.style.transform = 'translate3d(0,' + (-s.p * DIGIT_HEIGHT).toFixed(2) + 'px,0)';
s.badge.style.transform = 'scale(' + (1 + (PULSE_SCALE - 1) * s.pulse).toFixed(3) + ')';
s.flash.style.opacity = (s.pulse * 0.9).toFixed(3);
});
}
// 핵심: 열의 카드 수가 목표 숫자 위치이고, 숫자 띠가 그 위치로 스프링 이동한다 (늘면 위로, 줄면 아래로)
afterStep = function (dt: number) {
strips.forEach(function (s: Strip, c: number) {
var n = cols[c].length;
if (n !== s.n) {
s.flash.classList.toggle(CLS.up, n > s.n);
s.flash.classList.toggle(CLS.down, n < s.n);
s.n = n;
s.pulse = 1;
}
s.v += (STRIP_K * (n - s.p) - STRIP_DAMP * s.v) * dt;
s.p += s.v * dt;
s.pulse *= Math.pow(0.9, dt * 60);
});
paintStrips();
};
extraSettle = function () {
strips.forEach(function (s: Strip, c: number) {
s.n = cols[c].length;
s.p = s.n;
s.v = 0;
s.pulse = 0;
});
paintStrips();
};
on(board, 'click', function (e: Event) {
var c = cardAt(e.target);
if (!c) return;
stopDemo();
advance(c);
sync();
});
function demoAt(t: number): void {
while (demoN < MOVES.length && t >= MOVES[demoN][0]) {
advance(cards[MOVES[demoN][1]]);
demoN++;
}
}
function demoReset(): void {
demoN = 0;
cols = initialCols();
}
function metaText(): string {
return 'STRIP_K ' + STRIP_K + ' · counts ' + cols.map(function (l: number[]) { return String(l.length); }).join(' ');
}
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><span className={styles["kb__badge"]} data-role="badge"><i className={styles["kb__flash"]} data-role="flash"></i><span className={styles["kb__strip"]} data-role="strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i></span></span></div></div><div className={styles["kb__col"]} data-role="col"><div className={styles["kb__head"]}><span className={styles["kb__name"]}>진행 중</span><span className={styles["kb__badge"]} data-role="badge"><i className={styles["kb__flash"]} data-role="flash"></i><span className={styles["kb__strip"]} data-role="strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i></span></span></div></div><div className={styles["kb__col"]} data-role="col"><div className={styles["kb__head"]}><span className={styles["kb__name"]}>완료</span><span className={styles["kb__badge"]} data-role="badge"><i className={styles["kb__flash"]} data-role="flash"></i><span className={styles["kb__strip"]} data-role="strip"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i></span></span></div></div><button className={styles["kb__card"]} type="button" data-role="card" data-tone="0"><span className={styles["kb__label"]} data-role="label">로그인 화면</span></button><button className={styles["kb__card"]} type="button" data-role="card" data-tone="1"><span className={styles["kb__label"]} data-role="label">결제 연동</span></button><button className={styles["kb__card"]} type="button" data-role="card" data-tone="2"><span className={styles["kb__label"]} data-role="label">알림 설정</span></button><button className={styles["kb__card"]} type="button" data-role="card" data-tone="3"><span className={styles["kb__label"]} data-role="label">검색 필터</span></button><button className={styles["kb__card"]} type="button" data-role="card" data-tone="0"><span className={styles["kb__label"]} data-role="label">문서 정리</span></button><button className={styles["kb__card"]} type="button" data-role="card" data-tone="1"><span className={styles["kb__label"]} data-role="label">오류 수정</span></button><button className={styles["kb__card"]} type="button" data-role="card" data-tone="2"><span className={styles["kb__label"]} data-role="label">배포 점검</span></button></div><div className={styles["kb__cursor"]} data-role="cursor"></div></div><p className={styles["kb__meta"]} data-role="meta"></p></div></main>
);
}06 · カード数が変わる列のバッジ — 計測 累積面積 37.273% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 核心: 列のカード数が目標の数字の位置で、数字の帯がその位置へバネで動く(増えれば上へ、減れば下へ)
afterStep = function (dt) {
strips.forEach(function (s, c) {
var n = cols[c].length;
if (n !== s.n) {
s.flash.classList.toggle(CLS.up, n > s.n);
s.flash.classList.toggle(CLS.down, n < s.n);
s.n = n;
s.pulse = 1;
}
s.v += (STRIP_K * (n - s.p) - STRIP_DAMP * s.v) * dt;
s.p += s.v * dt;
s.pulse *= Math.pow(0.9, dt * 60);
});
paintStrips();
};
07優先度の並べ替えでカードが移動する場面
並べ替えボタンは、各カードの並べ替え前後の座標差を逆向きに当ててから0へ解き、順位ごとに開始を遅らせます。七枚なら最後の一枚の到着は 420 + 6 × 45 = 690ms です。レンダリングでは累積面積27.6%、強度平均78.9で、動いたフレームは23個中20個でした。

<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">未着手</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">進行中</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">完了</span></div></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">ログイン画面</span><i class="kb__prio">P3</i></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">決済連携</span><i class="kb__prio">P1</i></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">通知設定</span><i class="kb__prio">P2</i></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">検索フィルター</span><i class="kb__prio">P2</i></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">文書の整理</span><i class="kb__prio">P3</i></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">不具合修正</span><i class="kb__prio">P1</i></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">リリース確認</span><i class="kb__prio">P3</i></div></div><div class="kb__cursor" data-role="cursor"></div></div><div class="kb__bar"><button class="kb__btn" type="button" data-role="sort" aria-pressed="false"><span class="kb__lab kb__lab--a">優先度で並べる</span><span class="kb__lab kb__lab--b">元の順序</span></button><p class="kb__meta" data-role="meta"></p></div></div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 정렬 전후 좌표 차(FLIP)를 거꾸로 적용하고 순번마다 시작 시각을 어긋나게 둔다.
// 좌표는 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: pointer;
user-select: none;
touch-action: manipulation;
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;
}
.kb__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.kb__btn {
flex: none;
box-sizing: border-box;
height: 24px;
padding: 0 $sp-3;
border: 0;
border-radius: $r-control;
background: $subject-lilac;
color: $subject-ink;
font: inherit;
font-size: 12px;
font-weight: 700;
line-height: 16px;
cursor: pointer;
}
.kb__btn:focus-visible { outline: 2px solid $ring; outline-offset: 2px; }
.kb__lab--b { display: none; }
.kb__btn[aria-pressed="true"] .kb__lab--a { display: none; }
.kb__btn[aria-pressed="true"] .kb__lab--b { display: inline; }
.kb__prio {
flex: none;
margin-left: auto;
padding: 0 $sp-1;
border-radius: $r-xs;
background: rgba($stage-ink, .14);
font-style: normal;
font-size: 10px;
font-weight: 700;
line-height: 16px;
}
@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: pointer;
user-select: none;
touch-action: manipulation;
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;
}
.kb__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.kb__btn {
flex: none;
box-sizing: border-box;
height: 24px;
padding: 0 12px;
border: 0;
border-radius: 12px;
background: #b9a0ff;
color: #17141a;
font: inherit;
font-size: 12px;
font-weight: 700;
line-height: 16px;
cursor: pointer;
}
.kb__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.kb__lab--b {
display: none;
}
.kb__btn[aria-pressed=true] .kb__lab--a {
display: none;
}
.kb__btn[aria-pressed=true] .kb__lab--b {
display: inline;
}
.kb__prio {
flex: none;
margin-left: auto;
padding: 0 4px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.14);
font-style: normal;
font-size: 10px;
font-weight: 700;
line-height: 16px;
}
@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 FLIP_DURATION = 420;
var STAGGER_STEP = 45;
var PRIORITY_ORDER = [0, 1, 2];
var EASE_POWER = 3;
var PRIO = [2, 0, 1, 1, 2, 0, 2];
var ROWS = 3;
var FIXED_COL = 0;
var flips = [];
var sorted = false;
var btn = q('[data-role="sort"]');
var demoStep = 0;
var TOGGLES = [250, 1250];
function initialCols() {
return [[0, 1, 2], [3, 4], [5, 6]];
}
function sortedCols() {
var order = [];
PRIORITY_ORDER.forEach(function (p) {
PRIO.forEach(function (v, i) { if (v === p) order.push(i); });
});
return [order.slice(0, 3), order.slice(3, 5), order.slice(5, 7)];
}
function targetOf(c) {
var s = locate(c.i);
return { x: slotX(s.c), y: slotY(s.r) };
}
// 핵심: 옮기기 전 좌표(First)와 옮긴 뒤 좌표(Last)의 차를 거꾸로 적용하고, 순번마다 늦게 시작해 0 으로 보낸다
function toggleSort() {
sorted = !sorted;
cols = sorted ? sortedCols() : initialCols();
var rank = 0;
cols.forEach(function (list) {
list.forEach(function (i) {
var c = cards[i];
var t = targetOf(c);
flips[i] = { fx: c.x, fy: c.y, tx: t.x, ty: t.y, start: nowMs, delay: rank * STAGGER_STEP };
rank++;
});
});
btn.setAttribute('aria-pressed', sorted ? 'true' : 'false');
}
function stepCard(c, dt) {
var f = flips[c.i];
var p = clamp((nowMs - f.start - f.delay) / FLIP_DURATION, 0, 1);
var e = 1 - Math.pow(1 - p, EASE_POWER);
var invX = f.fx - f.tx;
var invY = f.fy - f.ty;
c.x = f.tx + invX * (1 - e);
c.y = f.ty + invY * (1 - e);
c.lift = p > 0 && p < 1 ? Math.sin(p * Math.PI) : 0;
c.sc = 1 + 0.06 * c.lift;
c.rot = clamp((invX * (1 - e)) / 24, -5, 5);
c.el.style.setProperty('--lift-px', (c.lift * 12).toFixed(1) + 'px');
}
extraSettle = function () {
cards.forEach(function (c) {
flips[c.i] = { fx: c.x, fy: c.y, tx: c.x, ty: c.y, start: -1000000, delay: 0 };
});
};
on(btn, 'click', function () {
stopDemo();
toggleSort();
sync();
});
function demoAt(t) {
if (demoStep < TOGGLES.length && t >= TOGGLES[demoStep]) {
toggleSort();
demoStep++;
}
}
function demoReset() {
demoStep = 0;
if (sorted) toggleSort();
}
function metaText() {
var n = 0;
flips.forEach(function (f) {
if (nowMs - f.start < FLIP_DURATION + f.delay) n++;
});
return 'FLIP_DURATION ' + FLIP_DURATION + ' · moving ' + n;
}
boot();
})();// PrioritySort — vanilla 판(kbn-priority-sort-flip-stagger) 의 React 포트. 정렬 전후 좌표 차(FLIP)를 거꾸로 적용하고 순번마다 시작 시각을 어긋나게 둔다.
// 소스는 vanilla 와 같은 구조(공용 엔진 + 장면 코드)이고 정식 타입을 단다.
import { useEffect, useRef } from "react";
import styles from "./PrioritySort.module.scss";
export default function PrioritySort() {
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 FLIP_DURATION = 420;
var STAGGER_STEP = 45;
var PRIORITY_ORDER: number[] = [0, 1, 2];
var EASE_POWER = 3;
var PRIO: number[] = [2, 0, 1, 1, 2, 0, 2];
var ROWS = 3;
var FIXED_COL = 0;
type Flip = { fx: number; fy: number; tx: number; ty: number; start: number; delay: number };
var flips: Flip[] = [];
var sorted = false;
var btn = q('[data-role="sort"]');
var demoStep = 0;
var TOGGLES: number[] = [250, 1250];
function initialCols(): number[][] {
return [[0, 1, 2], [3, 4], [5, 6]];
}
function sortedCols(): number[][] {
var order: number[] = [];
PRIORITY_ORDER.forEach(function (p: number) {
PRIO.forEach(function (v: number, i: number) { if (v === p) order.push(i); });
});
return [order.slice(0, 3), order.slice(3, 5), order.slice(5, 7)];
}
function targetOf(c: Card): Pt {
var s = locate(c.i);
return { x: slotX(s.c), y: slotY(s.r) };
}
// 핵심: 옮기기 전 좌표(First)와 옮긴 뒤 좌표(Last)의 차를 거꾸로 적용하고, 순번마다 늦게 시작해 0 으로 보낸다
function toggleSort(): void {
sorted = !sorted;
cols = sorted ? sortedCols() : initialCols();
var rank = 0;
cols.forEach(function (list: number[]) {
list.forEach(function (i: number) {
var c = cards[i];
var t = targetOf(c);
flips[i] = { fx: c.x, fy: c.y, tx: t.x, ty: t.y, start: nowMs, delay: rank * STAGGER_STEP };
rank++;
});
});
btn.setAttribute('aria-pressed', sorted ? 'true' : 'false');
}
function stepCard(c: Card, dt: number): void {
var f = flips[c.i];
var p = clamp((nowMs - f.start - f.delay) / FLIP_DURATION, 0, 1);
var e = 1 - Math.pow(1 - p, EASE_POWER);
var invX = f.fx - f.tx;
var invY = f.fy - f.ty;
c.x = f.tx + invX * (1 - e);
c.y = f.ty + invY * (1 - e);
c.lift = p > 0 && p < 1 ? Math.sin(p * Math.PI) : 0;
c.sc = 1 + 0.06 * c.lift;
c.rot = clamp((invX * (1 - e)) / 24, -5, 5);
c.el.style.setProperty('--lift-px', (c.lift * 12).toFixed(1) + 'px');
}
extraSettle = function () {
cards.forEach(function (c: Card) {
flips[c.i] = { fx: c.x, fy: c.y, tx: c.x, ty: c.y, start: -1000000, delay: 0 };
});
};
on(btn, 'click', function () {
stopDemo();
toggleSort();
sync();
});
function demoAt(t: number): void {
if (demoStep < TOGGLES.length && t >= TOGGLES[demoStep]) {
toggleSort();
demoStep++;
}
}
function demoReset(): void {
demoStep = 0;
if (sorted) toggleSort();
}
function metaText(): string {
var n = 0;
flips.forEach(function (f: Flip) {
if (nowMs - f.start < FLIP_DURATION + f.delay) n++;
});
return 'FLIP_DURATION ' + FLIP_DURATION + ' · moving ' + n;
}
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="1" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">로그인 화면</span><i className={styles["kb__prio"]}>P3</i></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><i className={styles["kb__prio"]}>P1</i></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><i className={styles["kb__prio"]}>P2</i></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><i className={styles["kb__prio"]}>P2</i></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><i className={styles["kb__prio"]}>P3</i></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><i className={styles["kb__prio"]}>P1</i></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><i className={styles["kb__prio"]}>P3</i></div></div><div className={styles["kb__cursor"]} data-role="cursor"></div></div><div className={styles["kb__bar"]}><button className={styles["kb__btn"]} type="button" data-role="sort" aria-pressed="false"><span className={styles["kb__lab"] + " " + styles["kb__lab--a"]}>우선순위 정렬</span><span className={styles["kb__lab"] + " " + styles["kb__lab--b"]}>원래 순서</span></button><p className={styles["kb__meta"]} data-role="meta"></p></div></div></main>
);
}07 · 優先度の並べ替えでカードが移動する場面 — 計測 累積面積 27.56% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 核心: 動かす前(First)と後(Last)の座標の差を逆向きに当て、順番ごとに遅らせて開始し0へ送る
function toggleSort() {
sorted = !sorted;
cols = sorted ? sortedCols() : initialCols();
var rank = 0;
cols.forEach(function (list) {
list.forEach(function (i) {
var c = cards[i];
var t = targetOf(c);
flips[i] = { fx: c.x, fy: c.y, tx: t.x, ty: t.y, start: nowMs, delay: rank * STAGGER_STEP };
rank++;
});
});
btn.setAttribute('aria-pressed', sorted ? 'true' : 'false');
}
function stepCard(c, dt) {
var f = flips[c.i];
var p = clamp((nowMs - f.start - f.delay) / FLIP_DURATION, 0, 1);
var e = 1 - Math.pow(1 - p, EASE_POWER);
var invX = f.fx - f.tx;
var invY = f.fy - f.ty;
c.x = f.tx + invX * (1 - e);
c.y = f.ty + invY * (1 - e);
c.lift = p > 0 && p < 1 ? Math.sin(p * Math.PI) : 0;
c.sc = 1 + 0.06 * c.lift;
c.rot = clamp((invX * (1 - e)) / 24, -5, 5);
c.el.style.setProperty('--lift-px', (c.lift * 12).toFixed(1) + 'px');
}
08カードの移動で長さが決まる進捗リング
完了列のカード数を全カード数で割って弧の長さを求め、カードが列を越えるたびに RING_LERP で新しい目標へ近づきます。四枚なので弧の止まる位置は 0・25・50・75・100% の五つだけです。レンダリングでは累積面積35.8%、強度平均151.0で、動いたフレームは23個中23個でした。

<main class="stage">
<div class="kb is-demo" id="kb"><div class="kb__row"><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">残り</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">完了</span></div></div><button class="kb__card" type="button" data-role="card" data-tone="0"><span class="kb__label" data-role="label">ログイン画面</span></button><button class="kb__card" type="button" data-role="card" data-tone="1"><span class="kb__label" data-role="label">決済連携</span></button><button class="kb__card" type="button" data-role="card" data-tone="2"><span class="kb__label" data-role="label">通知設定</span></button><button class="kb__card" type="button" data-role="card" data-tone="3"><span class="kb__label" data-role="label">検索フィルター</span></button></div><div class="kb__cursor" data-role="cursor"></div></div><div class="kb__ringbox"><div class="kb__dial"><svg viewBox="0 0 64 64" width="64" height="64" aria-hidden="true"><circle class="kb__arc kb__arc--bg" cx="32" cy="32" r="24"></circle><circle class="kb__arc kb__arc--a" data-role="arc-a" cx="32" cy="32" r="24"></circle><circle class="kb__arc kb__arc--b" data-role="arc-b" cx="32" cy="32" r="24"></circle></svg><span class="kb__pct" data-role="pct"></span></div><span class="kb__ringname">進捗</span><span class="kb__ringdone" data-role="done"></span></div></div><p class="kb__meta" data-role="meta"></p></div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 완료 열 카드 수 ÷ 전체 카드 수로 호의 길이를 구하고 목표가 바뀌면 보간한다.
// 좌표는 JS 가 계산해 transform 으로만 넣는다. 열·카드 크기는 화면 높이에 맞춘 4px 격자 한 단위(U)에서 나온다.
$bg: $stage-ink;
$panel: $tick-panel;
$text: $stage-paper;
$mute: rgba($stage-paper, .78);
$ring: $stage-paper;
@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: pointer;
user-select: none;
touch-action: manipulation;
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;
}
.kb__row { display: grid; grid-template-columns: minmax(0, 1fr) 72px; gap: $sp-2; }
.kb__ringbox {
box-sizing: border-box;
border-radius: $r-sm;
background: $panel;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-1;
color: $text;
font-size: 12px;
line-height: 16px;
}
.kb__dial { position: relative; width: 64px; height: 64px; display: grid; place-items: center; }
.kb__dial svg { position: absolute; inset: 0; }
.kb__arc { fill: none; stroke-width: 8; stroke-linecap: round; transform-box: fill-box; transform-origin: center; }
.kb__arc--bg { stroke: rgba($stage-paper, .14); }
.kb__arc--a { stroke: $subject-sky; }
.kb__arc--b { stroke: $subject-mint; opacity: 0; }
.kb__pct { position: relative; font-weight: 700; font-variant-numeric: tabular-nums; }
.kb__ringname { color: $mute; }
.kb__ringdone { font-weight: 700; 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: #17141a;
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: #231f28;
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: #ffffff;
}
.kb__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kb__count {
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 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: pointer;
user-select: none;
touch-action: manipulation;
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 #ffffff;
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(255, 255, 255, 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 #ffffff;
border-radius: 50%;
background: rgba(255, 255, 255, 0.16);
opacity: 0;
pointer-events: none;
z-index: 6;
}
.kb__meta {
margin: 0;
font-size: 12px;
line-height: 16px;
color: rgba(255, 255, 255, 0.78);
font-variant-numeric: tabular-nums;
}
.kb__row {
display: grid;
grid-template-columns: minmax(0, 1fr) 72px;
gap: 8px;
}
.kb__ringbox {
box-sizing: border-box;
border-radius: 8px;
background: #231f28;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
color: #ffffff;
font-size: 12px;
line-height: 16px;
}
.kb__dial {
position: relative;
width: 64px;
height: 64px;
display: grid;
place-items: center;
}
.kb__dial svg {
position: absolute;
inset: 0;
}
.kb__arc {
fill: none;
stroke-width: 8;
stroke-linecap: round;
transform-box: fill-box;
transform-origin: center;
}
.kb__arc--bg {
stroke: rgba(255, 255, 255, 0.14);
}
.kb__arc--a {
stroke: #6ec8ff;
}
.kb__arc--b {
stroke: #4cd4a6;
opacity: 0;
}
.kb__pct {
position: relative;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.kb__ringname {
color: rgba(255, 255, 255, 0.78);
}
.kb__ringdone {
font-weight: 700;
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 RING_RADIUS = 24;
var RING_LERP = 0.12;
var DONE_COLUMN = 1;
var ARC_START = -90;
var ROWS = 4;
var FIXED_COL = 0;
var CIRC = 2 * Math.PI * RING_RADIUS;
var arcA = q('[data-role="arc-a"]');
var arcB = q('[data-role="arc-b"]');
var pctEl = q('[data-role="pct"]');
var doneEl = q('[data-role="done"]');
var ring = 0;
var MOVES = [[250, 0], [650, 1], [1050, 2], [1450, 3]];
var demoN = 0;
[arcA, arcB].forEach(function (a) {
a.setAttribute('r', String(RING_RADIUS));
a.style.strokeDasharray = CIRC.toFixed(2);
a.style.transform = 'rotate(' + ARC_START + 'deg)';
});
function initialCols() {
return [[0, 1, 2], [3]];
}
function targetOf(c) {
var s = locate(c.i);
return { x: slotX(s.c), y: slotY(s.r) };
}
function toggleCard(c) {
var s = locate(c.i);
cols[s.c].splice(s.r, 1);
cols[1 - s.c].push(c.i);
}
function stepCard(c, dt) {
var h = targetOf(c);
spring(c, h.x, h.y, 170, 21, dt);
}
// 핵심: 호의 길이 = 完了 열 카드 수 ÷ 전체 카드 수. 목표가 바뀌면 RING_LERP 만큼씩 따라간다
function goal() {
return cols[DONE_COLUMN].length / cards.length;
}
function paintRing() {
var rest = CIRC * (1 - ring);
arcA.style.strokeDashoffset = rest.toFixed(2);
arcB.style.strokeDashoffset = rest.toFixed(2);
arcB.style.opacity = (ring * ring).toFixed(3);
pctEl.textContent = Math.round(ring * 100) + '%';
doneEl.textContent = cols[DONE_COLUMN].length + '/' + cards.length;
}
afterStep = function (dt) {
ring += (goal() - ring) * (1 - Math.pow(1 - RING_LERP, dt * 60));
paintRing();
};
extraSettle = function () {
ring = goal();
paintRing();
};
on(board, 'click', function (e) {
var c = cardAt(e.target);
if (!c) return;
stopDemo();
toggleCard(c);
sync();
});
function demoAt(t) {
while (demoN < MOVES.length && t >= MOVES[demoN][0]) {
toggleCard(cards[MOVES[demoN][1]]);
demoN++;
}
}
function demoReset() {
demoN = 0;
cols = initialCols();
}
function metaText() {
return 'RING_LERP ' + RING_LERP + ' · arc ' + (ring * 360).toFixed(0) + ' deg';
}
boot();
})();// ProgressRing — vanilla 판(kbn-progress-ring-from-columns) 의 React 포트. 완료 열 카드 수 ÷ 전체 카드 수로 호의 길이를 구하고 목표가 바뀌면 보간한다.
// 소스는 vanilla 와 같은 구조(공용 엔진 + 장면 코드)이고 정식 타입을 단다.
import { useEffect, useRef } from "react";
import styles from "./ProgressRing.module.scss";
export default function ProgressRing() {
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 RING_RADIUS = 24;
var RING_LERP = 0.12;
var DONE_COLUMN = 1;
var ARC_START = -90;
var ROWS = 4;
var FIXED_COL = 0;
var CIRC = 2 * Math.PI * RING_RADIUS;
var arcA = q('[data-role="arc-a"]');
var arcB = q('[data-role="arc-b"]');
var pctEl = q('[data-role="pct"]');
var doneEl = q('[data-role="done"]');
var ring = 0;
var MOVES: number[][] = [[250, 0], [650, 1], [1050, 2], [1450, 3]];
var demoN = 0;
[arcA, arcB].forEach(function (a: HTMLElement) {
a.setAttribute('r', String(RING_RADIUS));
a.style.strokeDasharray = CIRC.toFixed(2);
a.style.transform = 'rotate(' + ARC_START + 'deg)';
});
function initialCols(): number[][] {
return [[0, 1, 2], [3]];
}
function targetOf(c: Card): Pt {
var s = locate(c.i);
return { x: slotX(s.c), y: slotY(s.r) };
}
function toggleCard(c: Card): void {
var s = locate(c.i);
cols[s.c].splice(s.r, 1);
cols[1 - s.c].push(c.i);
}
function stepCard(c: Card, dt: number): void {
var h = targetOf(c);
spring(c, h.x, h.y, 170, 21, dt);
}
// 핵심: 호의 길이 = 완료 열 카드 수 ÷ 전체 카드 수. 목표가 바뀌면 RING_LERP 만큼씩 따라간다
function goal(): number {
return cols[DONE_COLUMN].length / cards.length;
}
function paintRing(): void {
var rest = CIRC * (1 - ring);
arcA.style.strokeDashoffset = rest.toFixed(2);
arcB.style.strokeDashoffset = rest.toFixed(2);
arcB.style.opacity = (ring * ring).toFixed(3);
pctEl.textContent = Math.round(ring * 100) + '%';
doneEl.textContent = cols[DONE_COLUMN].length + '/' + cards.length;
}
afterStep = function (dt: number) {
ring += (goal() - ring) * (1 - Math.pow(1 - RING_LERP, dt * 60));
paintRing();
};
extraSettle = function () {
ring = goal();
paintRing();
};
on(board, 'click', function (e: Event) {
var c = cardAt(e.target);
if (!c) return;
stopDemo();
toggleCard(c);
sync();
});
function demoAt(t: number): void {
while (demoN < MOVES.length && t >= MOVES[demoN][0]) {
toggleCard(cards[MOVES[demoN][1]]);
demoN++;
}
}
function demoReset(): void {
demoN = 0;
cols = initialCols();
}
function metaText(): string {
return 'RING_LERP ' + RING_LERP + ' · arc ' + (ring * 360).toFixed(0) + ' deg';
}
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__row"]}><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><button className={styles["kb__card"]} type="button" data-role="card" data-tone="0"><span className={styles["kb__label"]} data-role="label">로그인 화면</span></button><button className={styles["kb__card"]} type="button" data-role="card" data-tone="1"><span className={styles["kb__label"]} data-role="label">결제 연동</span></button><button className={styles["kb__card"]} type="button" data-role="card" data-tone="2"><span className={styles["kb__label"]} data-role="label">알림 설정</span></button><button className={styles["kb__card"]} type="button" data-role="card" data-tone="3"><span className={styles["kb__label"]} data-role="label">검색 필터</span></button></div><div className={styles["kb__cursor"]} data-role="cursor"></div></div><div className={styles["kb__ringbox"]}><div className={styles["kb__dial"]}><svg viewBox="0 0 64 64" width="64" height="64" aria-hidden="true"><circle className={styles["kb__arc"] + " " + styles["kb__arc--bg"]} cx="32" cy="32" r="24"></circle><circle className={styles["kb__arc"] + " " + styles["kb__arc--a"]} data-role="arc-a" cx="32" cy="32" r="24"></circle><circle className={styles["kb__arc"] + " " + styles["kb__arc--b"]} data-role="arc-b" cx="32" cy="32" r="24"></circle></svg><span className={styles["kb__pct"]} data-role="pct"></span></div><span className={styles["kb__ringname"]}>진행률</span><span className={styles["kb__ringdone"]} data-role="done"></span></div></div><p className={styles["kb__meta"]} data-role="meta"></p></div></main>
);
}08 · カードの移動で長さが決まる進捗リング — 計測 累積面積 35.783% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 核心: 弧の長さ = 完了列のカード数 ÷ 全カード数。目標が変わると RING_LERP ずつ追う
function goal() {
return cols[DONE_COLUMN].length / cards.length;
}
function paintRing() {
var rest = CIRC * (1 - ring);
arcA.style.strokeDashoffset = rest.toFixed(2);
arcB.style.strokeDashoffset = rest.toFixed(2);
arcB.style.opacity = (ring * ring).toFixed(3);
pctEl.textContent = Math.round(ring * 100) + '%';
doneEl.textContent = cols[DONE_COLUMN].length + '/' + cards.length;
}
afterStep = function (dt) {
ring += (goal() - ring) * (1 - Math.pow(1 - RING_LERP, dt * 60));
paintRing();
};
09キーボードでカードを動かし文で知らせる
Space か Enter でカードを持ち上げ、矢印キーで列と順序を変え、動くたびに支援技術が読み上げる領域へ文を書き込みます。Esc は最初の列と行に戻して、取り消しを知らせます。レンダリングでは累積面積38.5%、強度平均94.2で、動いたフレームは23個中21個でした。

<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">未着手</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">進行中</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">完了</span></div></div><button class="kb__card" type="button" data-role="card" data-tone="0" aria-pressed="false"><span class="kb__label" data-role="label">ログイン画面</span></button><button class="kb__card" type="button" data-role="card" data-tone="1" aria-pressed="false"><span class="kb__label" data-role="label">決済連携</span></button><button class="kb__card" type="button" data-role="card" data-tone="2" aria-pressed="false"><span class="kb__label" data-role="label">通知設定</span></button><button class="kb__card" type="button" data-role="card" data-tone="3" aria-pressed="false"><span class="kb__label" data-role="label">検索フィルター</span></button><button class="kb__card" type="button" data-role="card" data-tone="0" aria-pressed="false"><span class="kb__label" data-role="label">文書の整理</span></button><button class="kb__card" type="button" data-role="card" data-tone="1" aria-pressed="false"><span class="kb__label" data-role="label">不具合修正</span></button><button class="kb__card" type="button" data-role="card" data-tone="2" aria-pressed="false"><span class="kb__label" data-role="label">リリース確認</span></button></div><div class="kb__cursor" data-role="cursor"></div></div><div class="kb__bar"><p class="kb__live" data-role="live" aria-live="polite">Space で掴み矢印で移動</p><div class="kb__keys"><kbd class="kb__key" data-key="ArrowLeft">←</kbd><kbd class="kb__key" data-key="ArrowRight">→</kbd><kbd class="kb__key" data-key="ArrowUp">↑</kbd><kbd class="kb__key" data-key="ArrowDown">↓</kbd><kbd class="kb__key" data-key=" ">Space</kbd><kbd class="kb__key" data-key="Escape">Esc</kbd></div></div><span hidden data-msg="pick">%card% を掴んだ — %col% 列目・%row% 番目</span><span hidden data-msg="move">%col% 列目・%row% 番目へ移動</span><span hidden data-msg="drop">%card% を置いた — %col% 列目・%row% 番目</span><span hidden data-msg="cancel">取り消し — %card% を元の場所へ</span><span hidden data-msg="hint">Space で掴み矢印で移動</span><p hidden data-role="meta"></p></div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 키로 카드를 고르고 옮긴 뒤 결과를 aria-live 영역에 문장으로 알린다.
// 좌표는 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: pointer;
user-select: none;
touch-action: manipulation;
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;
}
.kb__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; min-height: 20px; }
.kb__live { flex: 1; min-width: 0; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; line-height: 16px; color: $text; }
.kb__keys { display: flex; flex: none; gap: $sp-1; }
.kb__key {
box-sizing: border-box;
min-width: 20px;
height: 20px;
padding: 0 $sp-1;
display: grid;
place-items: center;
border: 1px solid rgba($ring, .45);
border-radius: $r-xs;
font: inherit;
font-size: 11px;
line-height: 16px;
color: $text;
}
.kb__key.is-on { background: $subject-lilac; border-color: $subject-lilac; color: $subject-ink; transform: scale(.92); }
.is-picked { z-index: 4; }
@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: pointer;
user-select: none;
touch-action: manipulation;
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;
}
.kb__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 20px;
}
.kb__live {
flex: 1;
min-width: 0;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
line-height: 16px;
color: #17141a;
}
.kb__keys {
display: flex;
flex: none;
gap: 4px;
}
.kb__key {
box-sizing: border-box;
min-width: 20px;
height: 20px;
padding: 0 4px;
display: grid;
place-items: center;
border: 1px solid rgba(23, 20, 26, 0.45);
border-radius: 4px;
font: inherit;
font-size: 11px;
line-height: 16px;
color: #17141a;
}
.kb__key.is-on {
background: #b9a0ff;
border-color: #b9a0ff;
color: #17141a;
transform: scale(0.92);
}
.is-picked {
z-index: 4;
}
@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', picked: 'is-picked', focus: 'is-focus', on: 'is-on' };
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 LIVE_REGION = q('[data-role="live"]');
var MOVE_STEP = 1;
var FOCUS_RING = 3;
function tpl(name) {
return q('[data-msg="' + name + '"]').textContent;
}
var ANNOUNCE_TEXT = { pick: tpl('pick'), move: tpl('move'), drop: tpl('drop'), cancel: tpl('cancel') };
var ROWS = 4;
var FIXED_COL = 0;
var picked = null;
var origin = { c: 0, r: 0 };
var keyEls = qa('[data-key]');
var pulses = keyEls.map(function () { return 0; });
var SCRIPT = [['250', ' '], ['520', 'ArrowRight'], ['800', 'ArrowRight'], ['1080', 'ArrowDown'], ['1380', 'Enter']];
var demoN = 0;
board.style.setProperty('--focus-ring', FOCUS_RING + 'px');
function initialCols() {
return [[0, 1, 2], [3, 4], [5, 6]];
}
function targetOf(c) {
var s = locate(c.i);
return { x: slotX(s.c), y: slotY(s.r) };
}
function stepCard(c, dt) {
var h = targetOf(c);
spring(c, h.x, h.y, 170, 21, dt);
c.lift += ((c === picked ? 1 : 0) - c.lift) * (1 - Math.pow(0.75, dt * 60));
c.sc = 1 + 0.05 * c.lift;
c.el.style.setProperty('--lift-px', (c.lift * 14).toFixed(1) + 'px');
}
// 옮긴 결과를 문장으로 만들어 보조 기술용 영역(aria-live)에 쓴다
function say(kind, c) {
var s = locate(c.i);
var label = (c.el.querySelector('[data-role="label"]')).textContent;
LIVE_REGION.textContent = ANNOUNCE_TEXT[kind]
.replace('%card%', label)
.replace('%col%', String(s.c + 1))
.replace('%row%', String(s.r + 1));
}
function moveCard(c, nc, nr) {
var s = locate(c.i);
cols[s.c].splice(s.r, 1);
cols[nc].splice(nr, 0, c.i);
}
function setPicked(c) {
if (picked) {
picked.el.setAttribute('aria-pressed', 'false');
picked.el.classList.remove(CLS.picked);
}
picked = c;
if (c) {
c.el.setAttribute('aria-pressed', 'true');
c.el.classList.add(CLS.picked);
}
}
function press(k, c) {
var s = locate(c.i);
if (k === ' ' || k === 'Enter') {
if (picked === c) {
setPicked(null);
say('drop', c);
} else if (!picked) {
origin = { c: s.c, r: s.r };
setPicked(c);
say('pick', c);
}
return;
}
if (picked !== c) return;
if (k === 'Escape') {
moveCard(c, origin.c, Math.min(origin.r, cols[origin.c].length));
setPicked(null);
say('cancel', c);
return;
}
var nc = s.c;
var nr = s.r;
if (k === 'ArrowRight') nc = Math.min(COLS - 1, s.c + MOVE_STEP);
if (k === 'ArrowLeft') nc = Math.max(0, s.c - MOVE_STEP);
if (k === 'ArrowDown') nr = s.r + MOVE_STEP;
if (k === 'ArrowUp') nr = s.r - MOVE_STEP;
nr = nc === s.c ? clamp(nr, 0, cols[nc].length - 1) : Math.min(nr, cols[nc].length);
if (nc === s.c && nr === s.r) return;
moveCard(c, nc, nr);
say('move', c);
}
function pulseKey(k) {
keyEls.forEach(function (el, i) {
if (el.getAttribute('data-key') === k) pulses[i] = 1;
});
}
afterStep = function (dt) {
keyEls.forEach(function (el, i) {
pulses[i] *= Math.pow(0.9, dt * 60);
el.classList.toggle(CLS.on, pulses[i] > 0.25);
});
};
on(root, 'keydown', function (e) {
var ke = e;
var c = cardAt(ke.target);
if (!c) return;
if (ke.key.indexOf('Arrow') !== 0 && ke.key !== ' ' && ke.key !== 'Enter' && ke.key !== 'Escape') return;
ke.preventDefault();
stopDemo();
pulseKey(ke.key);
press(ke.key, c);
sync();
});
function demoAt(t) {
var c = cards[0];
c.el.classList.add(CLS.focus);
while (demoN < SCRIPT.length && t >= Number(SCRIPT[demoN][0])) {
pulseKey(SCRIPT[demoN][1]);
press(SCRIPT[demoN][1], c);
demoN++;
}
}
function demoReset() {
demoN = 0;
setPicked(null);
cols = initialCols();
LIVE_REGION.textContent = tpl('hint');
}
extraSettle = function () {
cards.forEach(function (c) { c.el.classList.remove(CLS.focus); });
};
function metaText() {
return '';
}
boot();
})();// KeyboardMove — vanilla 판(kbn-keyboard-move-announce) 의 React 포트. 키로 카드를 고르고 옮긴 뒤 결과를 aria-live 영역에 문장으로 알린다.
// 소스는 vanilla 와 같은 구조(공용 엔진 + 장면 코드)이고 정식 타입을 단다.
import { useEffect, useRef } from "react";
import styles from "./KeyboardMove.module.scss";
export default function KeyboardMove() {
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"], picked: styles["is-picked"], focus: styles["is-focus"], on: styles["is-on"] };
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 LIVE_REGION = q('[data-role="live"]');
var MOVE_STEP = 1;
var FOCUS_RING = 3;
type Dict = { [k: string]: string };
function tpl(name: string): string {
return q('[data-msg="' + name + '"]').textContent as string;
}
var ANNOUNCE_TEXT: Dict = { pick: tpl('pick'), move: tpl('move'), drop: tpl('drop'), cancel: tpl('cancel') };
var ROWS = 4;
var FIXED_COL = 0;
var picked: Card | null = null;
var origin: Slot = { c: 0, r: 0 };
var keyEls = qa('[data-key]');
var pulses: number[] = keyEls.map(function () { return 0; });
var SCRIPT: string[][] = [['250', ' '], ['520', 'ArrowRight'], ['800', 'ArrowRight'], ['1080', 'ArrowDown'], ['1380', 'Enter']];
var demoN = 0;
board.style.setProperty('--focus-ring', FOCUS_RING + 'px');
function initialCols(): number[][] {
return [[0, 1, 2], [3, 4], [5, 6]];
}
function targetOf(c: Card): Pt {
var s = locate(c.i);
return { x: slotX(s.c), y: slotY(s.r) };
}
function stepCard(c: Card, dt: number): void {
var h = targetOf(c);
spring(c, h.x, h.y, 170, 21, dt);
c.lift += ((c === picked ? 1 : 0) - c.lift) * (1 - Math.pow(0.75, dt * 60));
c.sc = 1 + 0.05 * c.lift;
c.el.style.setProperty('--lift-px', (c.lift * 14).toFixed(1) + 'px');
}
// 옮긴 결과를 문장으로 만들어 보조 기술용 영역(aria-live)에 쓴다
function say(kind: string, c: Card): void {
var s = locate(c.i);
var label = (c.el.querySelector('[data-role="label"]') as HTMLElement).textContent as string;
LIVE_REGION.textContent = ANNOUNCE_TEXT[kind]
.replace('%card%', label)
.replace('%col%', String(s.c + 1))
.replace('%row%', String(s.r + 1));
}
function moveCard(c: Card, nc: number, nr: number): void {
var s = locate(c.i);
cols[s.c].splice(s.r, 1);
cols[nc].splice(nr, 0, c.i);
}
function setPicked(c: Card | null): void {
if (picked) {
picked.el.setAttribute('aria-pressed', 'false');
picked.el.classList.remove(CLS.picked);
}
picked = c;
if (c) {
c.el.setAttribute('aria-pressed', 'true');
c.el.classList.add(CLS.picked);
}
}
function press(k: string, c: Card): void {
var s = locate(c.i);
if (k === ' ' || k === 'Enter') {
if (picked === c) {
setPicked(null);
say('drop', c);
} else if (!picked) {
origin = { c: s.c, r: s.r };
setPicked(c);
say('pick', c);
}
return;
}
if (picked !== c) return;
if (k === 'Escape') {
moveCard(c, origin.c, Math.min(origin.r, cols[origin.c].length));
setPicked(null);
say('cancel', c);
return;
}
var nc = s.c;
var nr = s.r;
if (k === 'ArrowRight') nc = Math.min(COLS - 1, s.c + MOVE_STEP);
if (k === 'ArrowLeft') nc = Math.max(0, s.c - MOVE_STEP);
if (k === 'ArrowDown') nr = s.r + MOVE_STEP;
if (k === 'ArrowUp') nr = s.r - MOVE_STEP;
nr = nc === s.c ? clamp(nr, 0, cols[nc].length - 1) : Math.min(nr, cols[nc].length);
if (nc === s.c && nr === s.r) return;
moveCard(c, nc, nr);
say('move', c);
}
function pulseKey(k: string): void {
keyEls.forEach(function (el: HTMLElement, i: number) {
if (el.getAttribute('data-key') === k) pulses[i] = 1;
});
}
afterStep = function (dt: number) {
keyEls.forEach(function (el: HTMLElement, i: number) {
pulses[i] *= Math.pow(0.9, dt * 60);
el.classList.toggle(CLS.on, pulses[i] > 0.25);
});
};
on(root, 'keydown', function (e: Event) {
var ke = e as KeyboardEvent;
var c = cardAt(ke.target);
if (!c) return;
if (ke.key.indexOf('Arrow') !== 0 && ke.key !== ' ' && ke.key !== 'Enter' && ke.key !== 'Escape') return;
ke.preventDefault();
stopDemo();
pulseKey(ke.key);
press(ke.key, c);
sync();
});
function demoAt(t: number): void {
var c = cards[0];
c.el.classList.add(CLS.focus);
while (demoN < SCRIPT.length && t >= Number(SCRIPT[demoN][0])) {
pulseKey(SCRIPT[demoN][1]);
press(SCRIPT[demoN][1], c);
demoN++;
}
}
function demoReset(): void {
demoN = 0;
setPicked(null);
cols = initialCols();
LIVE_REGION.textContent = tpl('hint');
}
extraSettle = function () {
cards.forEach(function (c: Card) { c.el.classList.remove(CLS.focus); });
};
function metaText(): string {
return '';
}
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><button className={styles["kb__card"]} type="button" data-role="card" data-tone="0" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">로그인 화면</span></button><button className={styles["kb__card"]} type="button" data-role="card" data-tone="1" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">결제 연동</span></button><button className={styles["kb__card"]} type="button" data-role="card" data-tone="2" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">알림 설정</span></button><button className={styles["kb__card"]} type="button" data-role="card" data-tone="3" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">검색 필터</span></button><button className={styles["kb__card"]} type="button" data-role="card" data-tone="0" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">문서 정리</span></button><button className={styles["kb__card"]} type="button" data-role="card" data-tone="1" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">오류 수정</span></button><button className={styles["kb__card"]} type="button" data-role="card" data-tone="2" aria-pressed="false"><span className={styles["kb__label"]} data-role="label">배포 점검</span></button></div><div className={styles["kb__cursor"]} data-role="cursor"></div></div><div className={styles["kb__bar"]}><p className={styles["kb__live"]} data-role="live" aria-live="polite">Space 로 집고 방향키로 옮기세요</p><div className={styles["kb__keys"]}><kbd className={styles["kb__key"]} data-key="ArrowLeft">←</kbd><kbd className={styles["kb__key"]} data-key="ArrowRight">→</kbd><kbd className={styles["kb__key"]} data-key="ArrowUp">↑</kbd><kbd className={styles["kb__key"]} data-key="ArrowDown">↓</kbd><kbd className={styles["kb__key"]} data-key=" ">Space</kbd><kbd className={styles["kb__key"]} data-key="Escape">Esc</kbd></div></div><span hidden data-msg="pick">%card% 집음 — 열 %col%, 순서 %row%</span><span hidden data-msg="move">열 %col%, 순서 %row% 로 이동</span><span hidden data-msg="drop">%card% 놓음 — 열 %col%, 순서 %row%</span><span hidden data-msg="cancel">%card% 취소 — 원래 자리로</span><span hidden data-msg="hint">Space 로 집고 방향키로 옮기세요</span><p hidden data-role="meta"></p></div></main>
);
}09 · キーボードでカードを動かし文で知らせる — 計測 累積面積 38.513% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function press(k, c) {
var s = locate(c.i);
if (k === ' ' || k === 'Enter') {
if (picked === c) {
setPicked(null);
say('drop', c);
} else if (!picked) {
origin = { c: s.c, r: s.r };
setPicked(c);
say('pick', c);
}
return;
}
if (picked !== c) return;
if (k === 'Escape') {
moveCard(c, origin.c, Math.min(origin.r, cols[origin.c].length));
setPicked(null);
say('cancel', c);
return;
}
var nc = s.c;
var nr = s.r;
if (k === 'ArrowRight') nc = Math.min(COLS - 1, s.c + MOVE_STEP);
if (k === 'ArrowLeft') nc = Math.max(0, s.c - MOVE_STEP);
if (k === 'ArrowDown') nr = s.r + MOVE_STEP;
if (k === 'ArrowUp') nr = s.r - MOVE_STEP;
nr = nc === s.c ? clamp(nr, 0, cols[nc].length - 1) : Math.min(nr, cols[nc].length);
if (nc === s.c && nr === s.r) return;
moveCard(c, nc, nr);
say('move', c);
}
値が場面を変えるところ — 追従係数・間隔の大きさ・WIP上限
追従の遅れ、開く空きの大きさ、列が閉じる時点は、三つの定数で変わります。表の数値は、各場面のレンダリング済みフレームを一枚ずつ比べて得たものです。
| 項目 | 見せるもの | 累積面積 % | 強度平均 | 動いたフレーム |
|---|---|---|---|---|
| 01 | カーソル追従と傾き | 36.0% | 66.0 | 23/23 |
| 02 | 置く場所を開く | 22.6% | 138.0 | 23/23 |
| 03 | 離した後の収まり | 27.4% | 76.0 | 20/23 |
| 04 | WIP 上限の圧力 | 29.1% | 116.9 | 23/23 |
| 05 | 端の自動スクロール | 51.0% | 71.2 | 23/23 |
| 06 | 列のカード数バッジ | 37.3% | 142.4 | 23/23 |
| 07 | 優先度の並べ替え | 27.6% | 78.9 | 20/23 |
| 08 | 進捗リング | 35.8% | 151.0 | 23/23 |
| 09 | キーボード移動 | 38.5% | 94.2 | 21/23 |
追従係数がカーソルとの距離を決める
FOLLOW_LERP が0.25なら、距離の90%を詰めるのに8フレーム、約133msかかります。0.1にすると約22フレーム、約364msになり、同じ速さで引いてもカードはカーソルから大きく遅れてついてきます。
間隔の大きさが空きの形を決める
SLOT_GAP はカード一段を単位に数えるので、1なら下のカードは28pxまたは42px押し下がります。0.5にすると14pxまたは21pxだけ押し出され、カードの高さ24pxまたは36pxより小さいため、枠表示と重なります。
WIP 上限が列の閉じる時点を決める
WIP_LIMIT が4のとき、中央の列は最初3枚なので一枚は入り、二枚目は弾かれます。値を2に下げると、その列は最初のフレームから満杯で、圧力ゲージも最初から一番上です。
どこで壊れるか — 狭い画面・空の列に置くとき・キーボードだけのとき
カンバンで本当に困るのは、見た目の崩れより、入るはずのカードが入らない場面です。そのため、ここの五つを先に押さえてください。この九つの場面のソースと CSS をファイルのまま受け取って定数を自分で変えたい方は、下の zip をパスワード n98rrum3 で解凍し、各フォルダを開いてください。
五枚目のカードは下が切れる
盤面の高さは U を単位にカード四行分で固定です。一つの列が五枚になると五枚目は高さの外に出て、下の24px(Uが6pxなら36px)が切れます。02 のように四枚の列に置く場所を五番目の枠として開く場面でいちばん目立つので、カードを増やすなら行数も一緒に上げます。
画面の高さで単位が変わる
U は、ウィンドウの高さが260px未満なら4px、以上なら6pxです。低い埋め込みではカードとバネの移動距離が一緒に縮みますが、よそで固定ピクセルで決めた大きさは縮みません。
空の列に置くと行0に入る
09 でキーボードを使って空の列へ移ると行は0になり、同じ列の中では上下キーが端で止まります。矢印キーで短い列へ渡ると行がその列の長さで切られるので、思ったより高い位置に収まることがあります。
高さの差が大きいとオーバーシュートが切られる
HOVER_PX が既定の18pxなら、03 の行き過ぎは約4.5pxなので OVERSHOOT_CAP の6は働きません。HOVER_PX を約24px より大きくすると上限がバネを抑え、途中で切れたように見えます。
上限が低いと同じ列の並べ替えも止まる
WIP_LIMIT を2にすると、中央の列のカードを持ち上げても、抜いた後に2枚残るので満杯と数えられ、同じ列の中の並べ替えまで断られます。判定は掴んだカードを抜いた後の列の長さで行われ、元の列だけを例外にする処理もないため、並べ替えを許すには、元の列を上限の判定から外す必要があります。
変形三つとアクセシビリティ
| 項目 | 定数 | 値 | 感じ |
|---|---|---|---|
| 01 | FOLLOW_LERP | 0.25 → 0.1 | カーソルから約364ms遅れて着き、傾きも弱まります |
| 02 | SLOT_GAP | 1 → 0.5 | 下のカードは14・21pxだけ押し出され、枠表示と重なります |
| 04 | WIP_LIMIT | 4 → 2 | 中央の列が最初から塞がり、どのカードも入れません |
01 から 05 と 07 のカードは tabindex を付けた div role="button" で、06・08・09 のカードは <button> です。07 にはそれとは別に、並べ替え用の <button> が一つあります。prefers-reduced-motion が reduce のときは、フレームのループも自動の再生も始まらず、入力のたびにすべてのカードがすぐ目標の座標に置かれます。05 では端に止まったポインタはもうスクロールしません。ボードはポインタの移動ごとに一歩だけ進み、09 は動きが即時になっても知らせる文は同じです。09 の知らせる領域は aria-live で読み上げられます。
html css kanban board はカードの順序を入れ替えるだけで足りるか?
カードを持ち上げて置くだけなら、順序の入れ替えで足ります。足りないのは、カーソルに付いてくる動き、行き先に開く空き、列ごとの上限です。01 が追従係数、02 が間隔、04 が圧力でその三つを補い、この三つを足した分が、01・02・04 の動きとして見えます。ポインタイベントの基準は Pointer events のページを参照してください。
kanban board html css js を作る前によくある質問
ライブラリなしで kanban board html css js は作れますか?
作れます。九つの場面は素の JS と CSS だけで、バネも補間も手で書いた数行です。
tailwind css kanban board でも同じ計算を使えますか?
使えます。計算は座標を決める JS で、クラス名には依存しないからです。カードの位置を transform の値として書く部分だけを持っていけば、どのスタイルの仕組みでも同じ動きになります。
kanban board template html css として使うには、まず何を変えますか?
最初に、列の数、カードの数、盤面の高さの行数を決めます。そのあと 01 の FOLLOW_LERP と 04 の WIP_LIMIT を、自分のサービスに合わせて調整してください。








