05Board Auto-Scroll Near the Edge
The depth of the pointer inside the edge zone is raised to a power to set the horizontal scroll step, so the step grows toward the very edge.
Best forExplaining a wide board with many columns
EDGE_ZONEMAX_STEPRAMP_POWERCOLUMN_COUNT

<main class="stage">
<div class="kb is-demo" id="kb"><div class="kb__board" data-role="board" tabindex="0" role="group" aria-label="Kanban board — arrow keys scroll sideways"><div class="kb__track" data-role="track"><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">Backlog</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">Analysis</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">Design</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">Build</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">Review</span></div></div><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">Ship</span></div></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="0" aria-pressed="false"><span class="kb__label" data-role="label">Login screen</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="1" aria-pressed="false"><span class="kb__label" data-role="label">Payment link</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="2" aria-pressed="false"><span class="kb__label" data-role="label">Alert settings</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="3" aria-pressed="false"><span class="kb__label" data-role="label">Search filter</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="0" aria-pressed="false"><span class="kb__label" data-role="label">Docs cleanup</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="1" aria-pressed="false"><span class="kb__label" data-role="label">Bug fix</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="2" aria-pressed="false"><span class="kb__label" data-role="label">Release check</span></div></div><div class="kb__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 Board Auto-Scroll Near the Edge — Measured area 50.992% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Kanban Board: 9 Demos With Springs and Lerp
The post this demo lives in
CSS Kanban Board: 9 Demos With Springs and LerpA css kanban board that only swaps card order leaves the held card off the cursor, hides the drop slot and accepts cards See all demos in this post
If you like this effect - similar demos
Dashboard UI03 Scroll that follows the cursorCommand Palette UI Design — 9 That Really Open
Dashboard UI06 A Dense Bar Chart Where Bars Near the Pointer Widen Like a Fisheye LensBar Chart Animation: 9 Demos From Spring to Race
Dashboard UI01 A Word Board Sized by Counting the TextWord Cloud JavaScript: 9 Scenes, Spiral to 3D