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