04A Column Header That Fills Toward Its Limit
Cards divided by the limit raise the header gauge and background tint, and a full column refuses a drop so the card returns to its original column.
Best forExplaining a work-in-progress limit
WIP_LIMITPRESSURE_LERPRETURN_KTINT_MAX

<main class="stage">
<div class="kb is-demo" id="kb"><div class="kb__board" data-role="board"><div class="kb__track" data-role="track"><div class="kb__col" data-role="col"><div class="kb__head"><span class="kb__name">To do</span><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">Doing</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">Done</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">Login screen</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="1" aria-pressed="false"><span class="kb__label" data-role="label">Payment link</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="2" aria-pressed="false"><span class="kb__label" data-role="label">Alert settings</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="3" aria-pressed="false"><span class="kb__label" data-role="label">Search filter</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="0" aria-pressed="false"><span class="kb__label" data-role="label">Docs cleanup</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="1" aria-pressed="false"><span class="kb__label" data-role="label">Bug fix</span></div><div class="kb__card" role="button" tabindex="0" data-role="card" data-tone="2" aria-pressed="false"><span class="kb__label" data-role="label">Release check</span></div></div><div class="kb__cursor" data-role="cursor"></div></div><p class="kb__meta" data-role="meta"></p></div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 열의 카드 수 ÷ 한도 비율을 보간해 게이지와 배경 농도를 올리고, 찬 열은 카드를 되돌려 보낸다.
// 좌표는 JS 가 계산해 transform 으로만 넣는다. 열·카드 크기는 화면 높이에 맞춘 4px 격자 한 단위(U)에서 나온다.
$bg: $stage-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 A Column Header That Fills Toward Its Limit — Measured area 29.077% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Kanban Board: 9 Demos With Springs and Lerp
The post this demo lives in
CSS Kanban Board: 9 Demos With Springs and LerpA css kanban board that only swaps card order leaves the held card off the cursor, hides the drop slot and accepts cards See all demos in this post
If you like this effect - similar demos
Dashboard UI03 A 3D Pie Chart That Tilts the Disc and Draws Its Side WallPie Chart CSS: 9 Slices That Pop, Tilt and Morph
Dashboard UI03 A Drill Down That Zooms a Cell Into Its Inner ChildrenTreemap Chart: 9 Canvas Layout Scenes
Dashboard UI01 Breadcrumb + page header9 Dashboard Components, Not the Whole Page