GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS Kanban Board: 9 Demos With Springs and Lerp

A css kanban board that only swaps card order leaves the held card off the cursor, hides the drop slot and accepts cards into a full column — scenes 01, 02 and

See All Nine Kanban Scenes Running on This Page

Each cell in the grid plays a short scripted run on its own, and the first real pointer move, key press or button click you make hands control to your input. Nothing is fetched, every distance is a constant and the seed is fixed, so what you watch is only how a board decides where a card goes and how it gets there.

Auto-plays · click a tile to jump to its section · all nine in one zip

CSS Kanban Board: 9 Demos Where Pointer, Gap and Spring Are Computed

The nine scenes follow the order in which one board gets used. Scene 01 is how a card trails the cursor, 02 is the gap that opens a drop slot and 03 is the spring that lets a released card settle. Scene 04 shows the pressure of a column filling toward its limit, 05 the speed at which the board slides near an edge, 06 the digit badge in a column header and 07 the order in which a sort button moves cards. Scene 08 turns the done column into a progress ring, and 09 moves a card by keyboard and says what happened in a sentence.

Where a Board Like This Fits

A product tour that introduces a task board starts with 01 and 03, because a card that follows the cursor is the first thing viewers watch on that screen. A page that explains moves between columns wants 02, and a help article about limiting work in progress wants 04. A wide board with many columns is the job of 05, and 06 suits the card count in a column header. A sort-by-priority button belongs with 07, a project summary card with 08, and a board that must work without a pointer with 09. If you are comparing a tailwind css kanban board with a hand-written one, note that the calculations here live in plain JS, so they carry over to any class system.

All nine scenes share one board grid. The unit U is 6px when the window is at least 260px tall and 4px otherwise, a card is 6U high, one card step is 7U and a column header is 5U, so the same code runs in a small embed and a large one with every distance scaled by U.

How This Set Differs From Neighboring Posts

Drag to Reorder UI: 9 Lists, One Drop Index centers on lists whose order changes by dragging, and 9 Dashboard Components, Not the Whole Page holds a static three-column board of cards. This set fills the space between them: the follow factor of a held card, the gap of an open slot and the pressure of a full column are each fixed as a number. Chart-side interaction is covered by Chart UI Design: 9 Interactions You Can Drag, and ordered rows with rank changes by Leaderboard UI Design: 9 Rank-Change List Patterns.

Code for Each Kanban Technique

Everything here is hand-written JS and CSS, and no library is loaded. Scenes 01 to 05 grab a card with pointerdown and setPointerCapture, while 06 to 09 have no pointer drag and move cards by click, sort button or keyboard. Every file reads the motion setting through matchMedia and runs its frames with requestAnimationFrame. Below, each snippet is only the stretch of source that decides the movement, copied as it stands.

01A Card That Follows the Cursor and Tilts With Its Motion

The held card closes only a FOLLOW_LERP share of its gap to the pointer every frame, and it leans in proportion to that frame's movement before returning to level when it stops. This scene never changes the column, so a released card goes back to where it started. The render logged 36.0% cumulative area, an intensity mean of 66.0 and motion in 23/23 frames.

A Card That Follows the Cursor and Tilts With Its Motion
Open in a new tab

01 · A Card That Follows the Cursor and Tilts With Its Motion — Measured area 35.966% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FOLLOW_LERPTILT_MAXTILT_PER_MOVELIFT_SHADOW
  // Core: the held card closes only FOLLOW_LERP of its distance to the target, and that movement sets the tilt
  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');
  }

02A Column That Opens a Drop Slot

The pointer height is compared with the midline of each card to find which index the card would take, and every card below that index moves down by SLOT_GAP on a spring to open the space. Because the unit is one card step, a value of 1 leaves exactly one card of room. The render logged 22.6% cumulative area, an intensity mean of 138.0 and motion in 23/23 frames.

A Column That Opens a Drop Slot
Open in a new tab

02 · A Column That Opens a Drop Slot — Measured area 22.584% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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 };
  }
  // Cards after the drop slot get their target pushed down by SLOT_GAP steps
  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 };
  }

03A Card That Settles Into Its Slot on Release

The held card floats HOVER_PX above the pointer, and on release a settle spring carries it down to the slot, past the target once and back. Its shadow shrinks in proportion to the height still left to travel. The render logged 27.4% cumulative area, an intensity mean of 76.0 and motion in 20/23 frames.

A Card That Settles Into Its Slot on Release
Open in a new tab

03 · A Card That Settles Into Its Slot on Release — Measured area 27.447% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

SETTLE_KSETTLE_DAMPDROP_SHADOWOVERSHOOT_CAP
  // Core: a released card settles into the slot on the SETTLE_K and SETTLE_DAMP spring, and its shadow shrinks with the height left to the slot
  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);
      }

04A Column Header That Fills Toward Its Limit

The cards in the middle column divided by WIP_LIMIT become a pressure value, and that value raises the header gauge and the background tint. Over a full column no slot opens, so a dropped card rides a spring back to the column it came from. The render logged 29.1% cumulative area, an intensity mean of 116.9 and motion in 23/23 frames.

A Column Header That Fills Toward Its Limit
Open in a new tab

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 · See the password

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;
  }

05Board Auto-Scroll Near the Edge

How deep the pointer has entered the edge zone, from 0 to 1, is raised to RAMP_POWER, so the distance moved per frame grows sharply toward the very edge. A depth of 0.5 gives 3.5px per frame and a depth of 1 gives 14px. This is the six-column board, and the render logged 51.0% cumulative area, an intensity mean of 71.2 and motion in 23/23 frames.

Board Auto-Scroll Near the Edge
Open in a new tab

05 · Board Auto-Scroll Near the Edge — Measured area 50.992% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

EDGE_ZONEMAX_STEPRAMP_POWERCOLUMN_COUNT
  // Core: the depth inside the edge zone (0 to 1) is raised to RAMP_POWER and used as the movement per frame
  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));
  }

06A Column Badge That Changes With the Card Count

Pressing a card sends it to the end of the next column, and the digit strip in that header slides to the new count on a spring. It travels up when the number rises and down when it falls, with one small pulse at the moment of change. The render logged 37.3% cumulative area, an intensity mean of 142.4 and motion in 23/23 frames.

A Column Badge That Changes With the Card Count
Open in a new tab

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 · See the password

STRIP_KSTRIP_DAMPDIGIT_HEIGHTPULSE_SCALE
  // Core: the card count of a column is the target digit position, and the digit strip springs to it (up when it rises, down when it falls)
  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();
  };

07Priority Sort That Moves Each Card

The sort button takes each card's coordinate before and after sorting, applies the difference in reverse and then releases it to zero, with a later start for each rank. With seven cards the last one lands at 420 + 6 × 45 = 690ms. The render logged 27.6% cumulative area, an intensity mean of 78.9 and motion in 20/23 frames.

Priority Sort That Moves Each Card
Open in a new tab

07 · Priority Sort That Moves Each Card — Measured area 27.56% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

FLIP_DURATIONSTAGGER_STEPPRIORITY_ORDEREASE_POWER
  // Core: apply the difference between the coordinate before (First) and after (Last) the move in reverse, then send it to 0 with a later start for each rank
  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');
  }

08A Progress Ring Set by Card Moves

Cards in the done column divided by all cards give the arc length, and each time a card crosses a column the arc follows its new target by RING_LERP. With four cards the arc has only five stops, 0, 25, 50, 75 and 100%. The render logged 35.8% cumulative area, an intensity mean of 151.0 and motion in 23/23 frames.

A Progress Ring Set by Card Moves
Open in a new tab

08 · A Progress Ring Set by Card Moves — Measured area 35.783% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

RING_RADIUSRING_LERPDONE_COLUMNARC_START
  // Core: arc length = done-column cards / all cards; when the target changes the arc follows it by 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();
  };

09Moving a Card by Keyboard With a Spoken Update

Space or Enter picks a card up, the arrow keys change its column and position, and every move writes a sentence into a region that assistive technology reads out. Esc sends the card back to its first column and row and announces the cancel. The render logged 38.5% cumulative area, an intensity mean of 94.2 and motion in 21/23 frames.

Moving a Card by Keyboard With a Spoken Update
Open in a new tab

09 · Moving a Card by Keyboard With a Spoken Update — Measured area 38.513% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

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);
  }

Where the Values Change the Scene — Follow Factor, Gap Size, WIP Limit

Three constants set how closely a card follows the hand, how big the open slot is and when a column shuts. The numbers in the table come from comparing the rendered frames of each scene one by one.

Scene What it shows Cumulative area % Intensity mean Frames moving
01 Cursor follow and tilt 36.0% 66.0 23/23
02 Opening the drop slot 22.6% 138.0 23/23
03 Settling after release 27.4% 76.0 20/23
04 WIP limit pressure 29.1% 116.9 23/23
05 Edge auto-scroll 51.0% 71.2 23/23
06 Column count badge 37.3% 142.4 23/23
07 Priority sort 27.6% 78.9 20/23
08 Progress ring 35.8% 151.0 23/23
09 Keyboard move 38.5% 94.2 21/23

How the Follow Factor Sets the Distance to the Cursor

With FOLLOW_LERP at 0.25 the card needs 8 frames, about 133ms, to close 90% of the gap. At 0.1 it needs about 22 frames, about 364ms, so at the same drag speed the card trails the cursor by a wide margin.

How the Gap Size Sets the Shape of the Open Slot

SLOT_GAP counts in card steps, so 1 pushes the lower cards down by 28px or 42px. At 0.5 they move only 14px or 21px, less than the card height of 24px or 36px, and they overlap the slot marker.

When the WIP Limit Shuts a Column

With WIP_LIMIT at 4 the middle column starts at 3 cards, so one card fits and the second bounces back. Drop the value to 2 and that column is full from the first frame, with the pressure gauge already at the top.

Where It Breaks — Narrow Screens, Dropping Into an Empty Column, Keyboard Only

A Fifth Card Is Cut Off at the Bottom

The board height is fixed at four card rows in units of U. A column that opens a fifth slot lands past that height and loses its bottom 24px, which is most visible in a scene like 02 that opens slots, so raise the row count whenever you raise the cards.

The Unit Changes With Window Height

U is 4px under a 260px window height and 6px at or above it. A low embed shrinks the cards and the spring distances together, while anything you sized in fixed pixels elsewhere does not shrink with them.

Dropping Into an Empty Column Lands on Row 0

In 09 a keyboard move into an empty column gives row 0, and up and down stop at the ends inside one column. When you cross to a shorter column by arrow key the row is cut to that column's length, so the card can land higher than you expect.

A Large Height Gap Cuts the Overshoot

At the default HOVER_PX of 18 scene 03 overshoots by about 4.5px, so OVERSHOOT_CAP at 6 never triggers. Raise HOVER_PX past about 24px and the cap clamps the spring, which then looks cut off midway.

A Low Limit Blocks Reordering Inside the Same Column

Set WIP_LIMIT to 2 and even lifting a middle-column card leaves 2 behind, which counts as full, so reordering within that column is refused too. The check counts the column without the card you hold and has no exception for that card's own column, so to allow reordering you would need to skip the check when the drop target is the column the card came from.

If you want the source and styles of all nine kanban scenes as files, so you can change the constants yourself, unzip the archive below with the password n98rrum3 and open each folder.

Three Variants and Accessibility

Scene Constant Value Effect
01 FOLLOW_LERP 0.25 → 0.1 The card arrives about 364ms behind the cursor and the tilt weakens
02 SLOT_GAP 1 → 0.5 Lower cards move half a step, so the cards below clear only half of the slot
04 WIP_LIMIT 4 → 2 The middle column is blocked from the start and no card can enter

Cards in 01 to 05 and 07 are div role="button" elements with a tabindex, while the cards in 06, 08 and 09 are <button> elements, and 07 adds a sort <button>. When prefers-reduced-motion is set to reduce, no frame loop and no scripted run starts, and each input places every card at its target coordinate at once. In 05 a pointer resting at the edge no longer scrolls, because the board moves one step per pointer move, and in 09 the announcement sentences stay the same even though the move is instant. Only 09 has an announcement region, and it is read through aria-live.

Is Swapping Card Order Enough for an HTML CSS Kanban Board?

If all you need is to pick a card up and put it down, swapping order is enough. What it leaves out is a card that tracks the cursor, a slot that opens where the card will go and any limit on a column. Scene 01 fills the first with a follow factor, 02 fills the second with a gap and 04 fills the third with pressure, and for a board you show off or hand to users those three layers are what a user sees that a plain order swap does not show. The reference for pointer events is the Pointer events page.

Questions People Ask Before Building a CSS Kanban Board

Do I need a library for a kanban board html css js build?

No. The nine scenes use plain JS and CSS, and the springs and interpolation are a few lines written by hand.

Can a tailwind css kanban board use the same calculations?

Yes, because the calculation is JS that decides coordinates and does not depend on class names. Take only the part that writes the card position as a transform value and the same motion appears whether the card is a Tailwind-classed div or a plain one.

What do I change first in a kanban board template html css?

Start with the number of columns, the number of cards and the row count of the board height. Then tune FOLLOW_LERP in 01 and WIP_LIMIT in 04 to suit your service.

Enter the archive password

The password is inside this article. You will find it as you read.