Kanban board

Drag cards between columns, watch the limits, and flip the same board into swimlanes.

Billing v2

Press Space to pick up the card. Arrow keys move it, Space drops it, Escape puts it back.

Drag a card by mouse, or press and hold on touch. Tab to a card and press Space to move it with the arrow keys.

Sample project and people. An interaction study, nothing is saved.

Key CSS & implementation tricks

// Touch must hold still briefly, so a swipe still scrolls.
if (e.pointerType !== 'mouse') {
  pending.timer = setTimeout(() => begin(pending.x, pending.y), 220);
}
// Only a live drag blocks scrolling; this must be non-passive.
document.addEventListener('touchmove', e => {
  if (drag) e.preventDefault();
}, { passive: false });

slot.style.height = `${rect.height}px`;
el.replaceWith(slot);   // the gap keeps its exact size
layer.append(el);       // same element, now in a fixed layer

function flip(mutate) {
  const before = new Map();
  for (const el of cardEls.values())
    before.set(el, el.getBoundingClientRect()); // visible, mid-flight
  for (const el of before.keys()) flips.get(el)?.cancel();
  mutate();
  for (const [el, a] of before) {
    const b = el.getBoundingClientRect();
    flips.set(el, el.animate([
      { transform: `translate(${a.left - b.left}px, ${a.top - b.top}px)` },
      { transform: 'none' },
    ], { duration: 340, easing }));
  }
}
// Hit-testing ignores transforms, so sliding cards can't flicker the slot.
siblings.findIndex(el => y < el.offsetTop + el.offsetHeight / 2);

drag.vx += ((drag.px - drag.lastX) - drag.vx) * .3;
const lean = Math.max(-7, Math.min(7, 1.6 + drag.vx * .45));
drag.r += (lean - drag.r) * .18;
drag.el.style.translate = `${drag.x}px ${drag.y}px`;
drag.el.style.rotate = `${drag.r.toFixed(2)}deg`;

// Drop: settle from the exact lifted pose.
el.animate([
  { translate: `${from.x - to.left}px ${from.y - to.top}px`,
    rotate: `${from.r}deg`, scale: 1.03 },
  { translate: '0 0', rotate: '0deg', scale: 1 },
], { duration: 360, easing });

export function wouldExceed(cards, id, status) {
  const card = cards.find(item => item.id === id);
  if (!card || card.status === status) return false;
  const { count, limit } = load(cards, status);
  return limit != null && count + 1 > limit;
}
.cell[data-target][data-warn] { background: var(--warn-bg); }

// Moving a focused element normally blurs it.
try { parent.moveBefore(el, ref); }
catch { parent.insertBefore(el, ref); }
if (focused && document.activeElement !== el) el.focus({ preventScroll: true });