06A Column Badge That Changes With the Card Count
When a column count changes, a digit strip moves to the new value on a spring, and the direction depends on whether the count rose or fell.
Best forThe card count in a column header
STRIP_KSTRIP_DAMPDIGIT_HEIGHTPULSE_SCALE

<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__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">Doing</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">Done</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">Login screen</span></button><button class="kb__card" type="button" data-role="card" data-tone="1"><span class="kb__label" data-role="label">Payment link</span></button><button class="kb__card" type="button" data-role="card" data-tone="2"><span class="kb__label" data-role="label">Alert settings</span></button><button class="kb__card" type="button" data-role="card" data-tone="3"><span class="kb__label" data-role="label">Search filter</span></button><button class="kb__card" type="button" data-role="card" data-tone="0"><span class="kb__label" data-role="label">Docs cleanup</span></button><button class="kb__card" type="button" data-role="card" data-tone="1"><span class="kb__label" data-role="label">Bug fix</span></button><button class="kb__card" type="button" data-role="card" data-tone="2"><span class="kb__label" data-role="label">Release check</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 A Column Badge That Changes With the Card Count — Measured area 37.273% · 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 UI02 A Flip Digit Card Whose Shadow Turns With the Hinge AngleCSS Countdown Timer: 9 Clocks That Drain and Flip
Dashboard UI08 Threshold badge that fires when the line is crossedStock Ticker UI: 9 Live Price Widgets, No API Key
Dashboard UI04 A Pie Chart That Glides Its Slice Angles to New Shares When the Period ChangesPie Chart CSS: 9 Slices That Pop, Tilt and Morph