GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

kanban board html css js 9パターン — 掴んだカードが追従し置く場所が開く

kanban board html css js をカードの入れ替えだけで作ると、掴んだカードはカーソルとずれ、置く場所は見えず、満杯の列にも入る — 01番・02番・04番が計算で直す。

カンバンの九つの場面をこのページで見る

格子の各マスは、短い自動の動きをひとりで再生します。マウスやキーボード、ボタンを最初に操作した時点で、動かす主役はあなたの入力に替わります。通信はなく、距離はすべて定数でシードも固定なので、見えるのは「ボードがカードの行き先をどう決め、どう動かすか」だけです。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

kanban board html css js 9パターン — ポインタ・間隔・バネを計算するとボードが手に付く

九つは、一枚のボードを使う順に並べました。01 はカードがカーソルを追う動き、02 は置く場所を開く間隔、03 は離したカードが収まるバネです。04 は上限に近づく列の圧力、05 は端でボードが滑る速さ、06 は列見出しの数字バッジ、07 は並べ替えボタンがカードを動かす順番を扱います。08 は完了列を進捗リングに変え、09 はキーボードでカードを動かして結果を文で知らせます。

こんな画面で使います

タスクボードを紹介する画面なら、カードがカーソルに付いてくる 01 と、離すと収まる 03 が合います。列の間の移動を説明するページには 02、進行中の作業数を絞る解説には 04 が合います。列の多い広いボードは 05、列見出しのカード数表示は 06 の担当です。優先度の並べ替えボタンには 07、プロジェクトの要約カードには 08、ポインタなしで使えるボードには 09 を選んでください。tailwind css kanban board と手書きを比べている場合も、ここの計算は素の JS なので、クラスの付け方が変わっても持ち込めます。

九つはどれも同じ盤面の割り付けを使います。単位 U は、ウィンドウの高さが260px以上なら6px、それ未満なら4pxです。カードの高さは 6U、カード一段は 7U、列見出しは 5U なので、同じコードのまま、小さな埋め込みでは4px、大きな画面では6pxの単位で動きます。

隣の記事とどこが違うか

ドラッグで並べ替える UI 9選は、ドラッグで順番が変わるリストを中心にした記事です。ダッシュボード部品 9選には、動かない三列のカード盤面があります。この記事はその間を埋め、掴んだカードの追従係数、開いた場所の間隔、満杯の列の圧力を、それぞれ一つの数値に固定しました。グラフ側の操作はチャート UI デザイン 9選、順位の入れ替わる行はリーダーボード UI デザイン 9選が扱っています。

技法別のコードで見るカンバン

ライブラリは読み込まず、手書きの JS と CSS だけです。01 から 05 は pointerdown と setPointerCapture でカードを掴みます。06 から 09 はポインタのドラッグを持たず、クリックと並べ替えボタン、キーボードで動かす構成です。どのファイルも動きの設定を matchMedia で読み、フレームは requestAnimationFrame で回します。以下の断片は、動きを決める区間だけをソースのまま抜き出したものです。

01カーソルに追従しカードが動きで傾く

掴んだカードは、毎フレームのポインタとの距離のうち FOLLOW_LERP の割合だけを詰め、そのフレームの移動量に比例して傾きます。止まれば水平に戻ります。この場面は列を変えないので、離したカードは元の場所に帰ります。レンダリングでは累積面積36.0%、強度平均66.0で、動いたフレームは23個中23個でした。

カーソルに追従しカードが動きで傾く
新しいタブで開く

01 · カーソルに追従しカードが動きで傾く — 計測 累積面積 35.966% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FOLLOW_LERPTILT_MAXTILT_PER_MOVELIFT_SHADOW
  // 核心: 掴んだカードは目標との距離の FOLLOW_LERP ぶんだけ追い、その移動量で傾きが決まる
  function stepCard(c, dt) {
    var f = dt * 60;
    if (c === held) {
      var k = reduce ? 1 : 1 - Math.pow(1 - FOLLOW_LERP, f);
      var nx = c.x + (tgt.x - c.x) * k;
      var ny = c.y + (tgt.y - c.y) * k;
      var move = (nx - c.x) / f;
      c.vx = (nx - c.x) / dt;
      c.vy = (ny - c.y) / dt;
      c.x = nx;
      c.y = ny;
      var want = clamp(move * TILT_PER_MOVE, -TILT_MAX, TILT_MAX);
      c.rot += (want - c.rot) * (1 - Math.pow(0.7, f));
      c.lift += (1 - c.lift) * (1 - Math.pow(0.75, f));
    } else {
      var h = targetOf(c);
      spring(c, h.x, h.y, 180, 22, dt);
      c.rot += (0 - c.rot) * (1 - Math.pow(0.8, f));
      c.lift += (0 - c.lift) * (1 - Math.pow(0.75, f));
      settleClass(c, h);
    }
    c.sc = 1 + 0.05 * c.lift;
    c.el.style.setProperty('--lift-px', (c.lift * LIFT_SHADOW).toFixed(1) + 'px');
  }

02置く場所が開く列

ポインタの高さを各カードの中線と比べて入る位置を求め、その位置より下のカードを SLOT_GAP ぶんバネで下げて空きを開けます。単位がカード一段なので、値が1ならカード一枚分の空きがちょうどできます。レンダリングでは累積面積22.6%、強度平均138.0で、動いたフレームは23個中23個でした。

置く場所が開く列
新しいタブで開く

02 · 置く場所が開く列 — 計測 累積面積 22.584% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SLOT_GAPSPRING_KSPRING_DAMPMIDLINE_BIAS
  function insertionAt(px, py) {
    var c = clamp(Math.floor(px / (colW + GAP)), 0, COLS - 1);
    var r = 0;
    for (var k = 0; k < cols[c].length; k++) {
      if (slotY(k) + CARD_H / 2 + MIDLINE_BIAS < py) r = k + 1;
    }
    return { c: c, r: r };
  }
  // 置く場所より後ろのカードは、目標が SLOT_GAP 段ぶん下へ押される
  function targetOf(c) {
    if (c === held) return tgt;
    var s = locate(c.i);
    var y = slotY(s.r);
    if (ins && ins.c === s.c && s.r >= ins.r) y += SLOT_GAP * STEP;
    return { x: slotX(s.c), y: y };
  }

03離すと所定の位置に収まるカード

掴んだカードはポインタより HOVER_PX だけ上に浮き、離すと収まりのバネが空きまで下ろします。目標を一度越えて戻り、影は残りの高さに比例して縮みます。レンダリングでは累積面積27.4%、強度平均76.0で、動いたフレームは23個中20個でした。

離すと所定の位置に収まるカード
新しいタブで開く

03 · 離すと所定の位置に収まるカード — 計測 累積面積 27.447% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SETTLE_KSETTLE_DAMPDROP_SHADOWOVERSHOOT_CAP
  // 核心: 離したカードは SETTLE_K・SETTLE_DAMP のバネで枠に収まり、影は枠との高さの差に比例して縮む
  function stepCard(c, dt) {
    var f = dt * 60;
    if (c === held) {
      var k = reduce ? 1 : 1 - Math.pow(0.5, f);
      c.vx = ((tgt.x - c.x) * k) / dt;
      c.vy = ((tgt.y - c.y) * k) / dt;
      c.x += (tgt.x - c.x) * k;
      c.y += (tgt.y - c.y) * k;
      c.lift += (1 - c.lift) * (1 - Math.pow(0.75, f));
    } else if (c === dropped) {
      var h = targetOf(c);
      spring(c, h.x, h.y, SETTLE_K, SETTLE_DAMP, dt);
      var over = c.y - h.y;
      if (over > OVERSHOOT_CAP) {
        c.y = h.y + OVERSHOOT_CAP;
        if (c.vy > 0) c.vy = 0;
      }
      var want = clamp((h.y - c.y) / HOVER_PX, 0, 1);
      c.lift += (want - c.lift) * (1 - Math.pow(0.5, f));
      if (Math.abs(over) < 0.4 && Math.abs(c.vy) < 12 && Math.abs(c.x - h.x) < 0.5) {
        dropped = null;
        c.el.classList.remove(CLS.held);
      }

04上限に近づく列の見出し

中央の列のカード数を WIP_LIMIT で割った値が圧力になり、見出しのゲージと背景の濃さを上げます。満杯の列には空きが開かないので、置いたカードはバネで元の列へ戻ります。レンダリングでは累積面積29.1%、強度平均116.9で、動いたフレームは23個中23個でした。

上限に近づく列の見出し
新しいタブで開く

04 · 上限に近づく列の見出し — 計測 累積面積 29.077% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WIP_LIMITPRESSURE_LERPRETURN_KTINT_MAX
  function pressureGoal() {
    var n = cols[DOING].length;
    if (held) n = blocked ? WIP_LIMIT : n + (ins && ins.c === DOING ? 1 : 0);
    return Math.min(1, n / WIP_LIMIT);
  }
  function grab(c, px, py) {
    origin = locate(c.i);
    cols[origin.c].splice(origin.r, 1);
    blocked = s.c === DOING && cols[DOING].length >= WIP_LIMIT;
    ins = blocked ? null : s;
  }

05端でボードが動く自動スクロール

ポインタが端の領域に入った深さ(0から1)を RAMP_POWER 乗するので、端に近づくほど一フレームの移動量が急に増えます。深さ0.5なら3.5px、深さ1なら14pxです。これは六列のボードで、レンダリングでは累積面積51.0%、強度平均71.2、動いたフレームは23個中23個でした。

端でボードが動く自動スクロール
新しいタブで開く

05 · 端でボードが動く自動スクロール — 計測 累積面積 50.992% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

EDGE_ZONEMAX_STEPRAMP_POWERCOLUMN_COUNT
  // 核心: 端の領域に入った深さ(0から1)を RAMP_POWER 乗して、一フレームの移動量に使う
  function rampStep(x) {
    var left = clamp((EDGE_ZONE - x) / EDGE_ZONE, 0, 1);
    var right = clamp((x - (boardW - EDGE_ZONE)) / EDGE_ZONE, 0, 1);
    return MAX_STEP * (Math.pow(right, RAMP_POWER) - Math.pow(left, RAMP_POWER));
  }

06カード数が変わる列のバッジ

カードを押すと隣の列の末尾へ移り、その列見出しの数字の帯が新しい件数の位置までバネで滑ります。増えれば上へ、減れば下へ動き、切り替わる瞬間に小さく一度だけ脈打ちます。レンダリングでは累積面積37.3%、強度平均142.4で、動いたフレームは23個中23個でした。

カード数が変わる列のバッジ
新しいタブで開く

06 · カード数が変わる列のバッジ — 計測 累積面積 37.273% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STRIP_KSTRIP_DAMPDIGIT_HEIGHTPULSE_SCALE
  // 核心: 列のカード数が目標の数字の位置で、数字の帯がその位置へバネで動く(増えれば上へ、減れば下へ)
  afterStep = function (dt) {
    strips.forEach(function (s, c) {
      var n = cols[c].length;
      if (n !== s.n) {
        s.flash.classList.toggle(CLS.up, n > s.n);
        s.flash.classList.toggle(CLS.down, n < s.n);
        s.n = n;
        s.pulse = 1;
      }
      s.v += (STRIP_K * (n - s.p) - STRIP_DAMP * s.v) * dt;
      s.p += s.v * dt;
      s.pulse *= Math.pow(0.9, dt * 60);
    });
    paintStrips();
  };

07優先度の並べ替えでカードが移動する場面

並べ替えボタンは、各カードの並べ替え前後の座標差を逆向きに当ててから0へ解き、順位ごとに開始を遅らせます。七枚なら最後の一枚の到着は 420 + 6 × 45 = 690ms です。レンダリングでは累積面積27.6%、強度平均78.9で、動いたフレームは23個中20個でした。

優先度の並べ替えでカードが移動する場面
新しいタブで開く

07 · 優先度の並べ替えでカードが移動する場面 — 計測 累積面積 27.56% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FLIP_DURATIONSTAGGER_STEPPRIORITY_ORDEREASE_POWER
  // 核心: 動かす前(First)と後(Last)の座標の差を逆向きに当て、順番ごとに遅らせて開始し0へ送る
  function toggleSort() {
    sorted = !sorted;
    cols = sorted ? sortedCols() : initialCols();
    var rank = 0;
    cols.forEach(function (list) {
      list.forEach(function (i) {
        var c = cards[i];
        var t = targetOf(c);
        flips[i] = { fx: c.x, fy: c.y, tx: t.x, ty: t.y, start: nowMs, delay: rank * STAGGER_STEP };
        rank++;
      });
    });
    btn.setAttribute('aria-pressed', sorted ? 'true' : 'false');
  }
  function stepCard(c, dt) {
    var f = flips[c.i];
    var p = clamp((nowMs - f.start - f.delay) / FLIP_DURATION, 0, 1);
    var e = 1 - Math.pow(1 - p, EASE_POWER);
    var invX = f.fx - f.tx;
    var invY = f.fy - f.ty;
    c.x = f.tx + invX * (1 - e);
    c.y = f.ty + invY * (1 - e);
    c.lift = p > 0 && p < 1 ? Math.sin(p * Math.PI) : 0;
    c.sc = 1 + 0.06 * c.lift;
    c.rot = clamp((invX * (1 - e)) / 24, -5, 5);
    c.el.style.setProperty('--lift-px', (c.lift * 12).toFixed(1) + 'px');
  }

08カードの移動で長さが決まる進捗リング

完了列のカード数を全カード数で割って弧の長さを求め、カードが列を越えるたびに RING_LERP で新しい目標へ近づきます。四枚なので弧の止まる位置は 0・25・50・75・100% の五つだけです。レンダリングでは累積面積35.8%、強度平均151.0で、動いたフレームは23個中23個でした。

カードの移動で長さが決まる進捗リング
新しいタブで開く

08 · カードの移動で長さが決まる進捗リング — 計測 累積面積 35.783% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RING_RADIUSRING_LERPDONE_COLUMNARC_START
  // 核心: 弧の長さ = 完了列のカード数 ÷ 全カード数。目標が変わると RING_LERP ずつ追う
  function goal() {
    return cols[DONE_COLUMN].length / cards.length;
  }
  function paintRing() {
    var rest = CIRC * (1 - ring);
    arcA.style.strokeDashoffset = rest.toFixed(2);
    arcB.style.strokeDashoffset = rest.toFixed(2);
    arcB.style.opacity = (ring * ring).toFixed(3);
    pctEl.textContent = Math.round(ring * 100) + '%';
    doneEl.textContent = cols[DONE_COLUMN].length + '/' + cards.length;
  }
  afterStep = function (dt) {
    ring += (goal() - ring) * (1 - Math.pow(1 - RING_LERP, dt * 60));
    paintRing();
  };

09キーボードでカードを動かし文で知らせる

Space か Enter でカードを持ち上げ、矢印キーで列と順序を変え、動くたびに支援技術が読み上げる領域へ文を書き込みます。Esc は最初の列と行に戻して、取り消しを知らせます。レンダリングでは累積面積38.5%、強度平均94.2で、動いたフレームは23個中21個でした。

キーボードでカードを動かし文で知らせる
新しいタブで開く

09 · キーボードでカードを動かし文で知らせる — 計測 累積面積 38.513% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LIVE_REGIONMOVE_STEPFOCUS_RINGANNOUNCE_TEXT
  function press(k, c) {
    var s = locate(c.i);
    if (k === ' ' || k === 'Enter') {
      if (picked === c) {
        setPicked(null);
        say('drop', c);
      } else if (!picked) {
        origin = { c: s.c, r: s.r };
        setPicked(c);
        say('pick', c);
      }
      return;
    }
    if (picked !== c) return;
    if (k === 'Escape') {
      moveCard(c, origin.c, Math.min(origin.r, cols[origin.c].length));
      setPicked(null);
      say('cancel', c);
      return;
    }
    var nc = s.c;
    var nr = s.r;
    if (k === 'ArrowRight') nc = Math.min(COLS - 1, s.c + MOVE_STEP);
    if (k === 'ArrowLeft') nc = Math.max(0, s.c - MOVE_STEP);
    if (k === 'ArrowDown') nr = s.r + MOVE_STEP;
    if (k === 'ArrowUp') nr = s.r - MOVE_STEP;
    nr = nc === s.c ? clamp(nr, 0, cols[nc].length - 1) : Math.min(nr, cols[nc].length);
    if (nc === s.c && nr === s.r) return;
    moveCard(c, nc, nr);
    say('move', c);
  }

値が場面を変えるところ — 追従係数・間隔の大きさ・WIP上限

追従の遅れ、開く空きの大きさ、列が閉じる時点は、三つの定数で変わります。表の数値は、各場面のレンダリング済みフレームを一枚ずつ比べて得たものです。

項目 見せるもの 累積面積 % 強度平均 動いたフレーム
01 カーソル追従と傾き 36.0% 66.0 23/23
02 置く場所を開く 22.6% 138.0 23/23
03 離した後の収まり 27.4% 76.0 20/23
04 WIP 上限の圧力 29.1% 116.9 23/23
05 端の自動スクロール 51.0% 71.2 23/23
06 列のカード数バッジ 37.3% 142.4 23/23
07 優先度の並べ替え 27.6% 78.9 20/23
08 進捗リング 35.8% 151.0 23/23
09 キーボード移動 38.5% 94.2 21/23

追従係数がカーソルとの距離を決める

FOLLOW_LERP が0.25なら、距離の90%を詰めるのに8フレーム、約133msかかります。0.1にすると約22フレーム、約364msになり、同じ速さで引いてもカードはカーソルから大きく遅れてついてきます。

間隔の大きさが空きの形を決める

SLOT_GAP はカード一段を単位に数えるので、1なら下のカードは28pxまたは42px押し下がります。0.5にすると14pxまたは21pxだけ押し出され、カードの高さ24pxまたは36pxより小さいため、枠表示と重なります。

WIP 上限が列の閉じる時点を決める

WIP_LIMIT が4のとき、中央の列は最初3枚なので一枚は入り、二枚目は弾かれます。値を2に下げると、その列は最初のフレームから満杯で、圧力ゲージも最初から一番上です。

どこで壊れるか — 狭い画面・空の列に置くとき・キーボードだけのとき

カンバンで本当に困るのは、見た目の崩れより、入るはずのカードが入らない場面です。そのため、ここの五つを先に押さえてください。この九つの場面のソースと CSS をファイルのまま受け取って定数を自分で変えたい方は、下の zip をパスワード n98rrum3 で解凍し、各フォルダを開いてください。

五枚目のカードは下が切れる

盤面の高さは U を単位にカード四行分で固定です。一つの列が五枚になると五枚目は高さの外に出て、下の24px(Uが6pxなら36px)が切れます。02 のように四枚の列に置く場所を五番目の枠として開く場面でいちばん目立つので、カードを増やすなら行数も一緒に上げます。

画面の高さで単位が変わる

U は、ウィンドウの高さが260px未満なら4px、以上なら6pxです。低い埋め込みではカードとバネの移動距離が一緒に縮みますが、よそで固定ピクセルで決めた大きさは縮みません。

空の列に置くと行0に入る

09 でキーボードを使って空の列へ移ると行は0になり、同じ列の中では上下キーが端で止まります。矢印キーで短い列へ渡ると行がその列の長さで切られるので、思ったより高い位置に収まることがあります。

高さの差が大きいとオーバーシュートが切られる

HOVER_PX が既定の18pxなら、03 の行き過ぎは約4.5pxなので OVERSHOOT_CAP の6は働きません。HOVER_PX を約24px より大きくすると上限がバネを抑え、途中で切れたように見えます。

上限が低いと同じ列の並べ替えも止まる

WIP_LIMIT を2にすると、中央の列のカードを持ち上げても、抜いた後に2枚残るので満杯と数えられ、同じ列の中の並べ替えまで断られます。判定は掴んだカードを抜いた後の列の長さで行われ、元の列だけを例外にする処理もないため、並べ替えを許すには、元の列を上限の判定から外す必要があります。

変形三つとアクセシビリティ

項目 定数 値 感じ
01 FOLLOW_LERP 0.25 → 0.1 カーソルから約364ms遅れて着き、傾きも弱まります
02 SLOT_GAP 1 → 0.5 下のカードは14・21pxだけ押し出され、枠表示と重なります
04 WIP_LIMIT 4 → 2 中央の列が最初から塞がり、どのカードも入れません

01 から 05 と 07 のカードは tabindex を付けた div role="button" で、06・08・09 のカードは <button> です。07 にはそれとは別に、並べ替え用の <button> が一つあります。prefers-reduced-motion が reduce のときは、フレームのループも自動の再生も始まらず、入力のたびにすべてのカードがすぐ目標の座標に置かれます。05 では端に止まったポインタはもうスクロールしません。ボードはポインタの移動ごとに一歩だけ進み、09 は動きが即時になっても知らせる文は同じです。09 の知らせる領域は aria-live で読み上げられます。

html css kanban board はカードの順序を入れ替えるだけで足りるか?

カードを持ち上げて置くだけなら、順序の入れ替えで足ります。足りないのは、カーソルに付いてくる動き、行き先に開く空き、列ごとの上限です。01 が追従係数、02 が間隔、04 が圧力でその三つを補い、この三つを足した分が、01・02・04 の動きとして見えます。ポインタイベントの基準は Pointer events のページを参照してください。

kanban board html css js を作る前によくある質問

ライブラリなしで kanban board html css js は作れますか?

作れます。九つの場面は素の JS と CSS だけで、バネも補間も手で書いた数行です。

tailwind css kanban board でも同じ計算を使えますか?

使えます。計算は座標を決める JS で、クラス名には依存しないからです。カードの位置を transform の値として書く部分だけを持っていけば、どのスタイルの仕組みでも同じ動きになります。

kanban board template html css として使うには、まず何を変えますか?

最初に、列の数、カードの数、盤面の高さの行数を決めます。そのあと 01 の FOLLOW_LERP と 04 の WIP_LIMIT を、自分のサービスに合わせて調整してください。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。