To do2
Done1
To do2
Done1
import { useRef, useState } from 'react';
type Cols = { todo: string[]; done: string[] };
type Drag = { id: string; x: number; y: number; dx: number; dy: number; col: keyof Cols; at: number };
const X = { todo: 10, done: 124 };
const CW = 106;
const TOP = 36;
const ROW = 40;
const css = `
.card-kanban { position: relative; width: 240px; height: 200px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); user-select: none; -webkit-user-select: none; }
.card-kanban__col { position: absolute; top: 8px; bottom: 8px; width: ${CW + 8}px; margin-left: -4px; border-radius: 12px; transition: background-color 0.2s; }
.card-kanban__col--target { background: light-dark(#ebedf0, #232529); }
.card-kanban__head { position: absolute; top: 14px; margin: 0; font-size: 11px; font-weight: 600; color: light-dark(#50535b, #c6c8cd); }
.card-kanban__head span { margin-left: 4px; color: light-dark(#5f626a, #a7abb2); font-variant-numeric: tabular-nums; }
.card-kanban__slot { position: absolute; width: ${CW}px; height: 34px; box-sizing: border-box; border: 1.5px dashed light-dark(#6d7179, #7d8087); border-radius: 9px; }
.card-kanban__chip {
position: absolute;
left: 0;
top: 0;
display: flex;
align-items: center;
gap: 6px;
box-sizing: border-box;
width: ${CW}px;
height: 34px;
padding: 0 10px;
border: 0;
border-radius: 9px;
background: light-dark(#dbdde1, #2e3035);
color: light-dark(#373a40, #f1f2f3);
font: inherit;
font-size: 11.5px;
text-align: left;
white-space: nowrap;
cursor: grab;
touch-action: none;
box-shadow: 0 1px 2px var(--card-kanban-shadow);
transition: transform 0.4s cubic-bezier(0.3, 1.25, 0.5, 1), box-shadow 0.2s;
}
.card-kanban { --card-kanban-shadow: light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5)); }
.card-kanban__chip:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.card-kanban__chip--done { color: light-dark(#50535b, #c6c8cd); }
.card-kanban__chip svg { flex: none; width: 12px; height: 12px; color: light-dark(#50535b, #c6c8cd); }
.card-kanban__chip--held { z-index: 3; cursor: grabbing; box-shadow: 0 12px 20px -8px var(--card-kanban-shadow); transition: box-shadow 0.2s; }
.card-kanban__live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.card-kanban__chip { transition: none; }
}
`;
// Where every chip sits: the held one is taken out and slotted in where it would land.
function layout(cols: Cols, drag: Drag | null) {
const out: Cols = { todo: cols.todo.filter((t) => t !== drag?.id), done: cols.done.filter((t) => t !== drag?.id) };
if (drag) out[drag.col].splice(drag.at, 0, drag.id);
return out;
}
export function KanbanCard() {
const [cols, setCols] = useState<Cols>({ todo: ['Write brief', 'Pick type'], done: ['Moodboard'] });
const [drag, setDrag] = useState<Drag | null>(null);
const [said, setSaid] = useState('');
const box = useRef<HTMLElement>(null);
const moved = useRef(false);
const view = layout(cols, drag);
const local = (e: React.PointerEvent) => {
const r = box.current!.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
};
const target = (x: number, y: number, id: string) => {
const col: keyof Cols = x < 120 ? 'todo' : 'done';
const n = cols[col].filter((t) => t !== id).length;
return { col, at: Math.max(0, Math.min(n, Math.round((y - TOP - 17) / ROW))) };
};
const move = (id: string, col: keyof Cols) => {
setCols((c) => {
const next = layout(c, { id, col, at: c[col].filter((t) => t !== id).length, x: 0, y: 0, dx: 0, dy: 0 });
return next;
});
setSaid(`Moved ${id} to ${col === 'todo' ? 'To do' : 'Done'}`);
};
return (
<>
<style>
{css}
</style>
<article ref={box} className="card-kanban" aria-label="Board">
{(['todo', 'done'] as const).map((c) => (
<div key={c}>
<span className={`card-kanban__col${drag?.col === c ? ' card-kanban__col--target' : ''}`} style={{ left: X[c] }} aria-hidden="true" />
<p className="card-kanban__head" style={{ left: X[c] + 4 }}>
{c === 'todo' ? 'To do' : 'Done'}
<span>{view[c].length}</span>
</p>
</div>
))}
{drag && <span className="card-kanban__slot" style={{ left: X[drag.col], top: TOP + drag.at * ROW }} aria-hidden="true" />}
{(['todo', 'done'] as const).flatMap((c) =>
view[c].map((id, i) => {
const held = drag?.id === id;
// The held chip follows the pointer, tilted; the rest glide to their slots.
const t = held ? `translate(${drag.x - drag.dx}px, ${drag.y - drag.dy}px) rotate(4deg) scale(1.05)` : `translate(${X[c]}px, ${TOP + i * ROW}px)`;
return (
<button
key={id}
type="button"
className={`card-kanban__chip${c === 'done' ? ' card-kanban__chip--done' : ''}${held ? ' card-kanban__chip--held' : ''}`}
style={{ transform: t }}
aria-label={`${id}, in ${c === 'todo' ? 'To do' : 'Done'}. Press to move it to ${c === 'todo' ? 'Done' : 'To do'}.`}
onPointerDown={(e) => {
if (e.button) return;
e.currentTarget.setPointerCapture(e.pointerId);
const p = local(e);
moved.current = false;
setDrag({ id, x: p.x, y: p.y, dx: p.x - X[c], dy: p.y - (TOP + i * ROW), col: c, at: i });
}}
onPointerMove={(e) => {
if (!drag || drag.id !== id) return;
const p = local(e);
if (Math.hypot(p.x - drag.dx - X[c], p.y - drag.dy - (TOP + i * ROW)) > 4) moved.current = true;
setDrag({ ...drag, x: p.x, y: p.y, ...target(p.x, p.y, id) });
}}
onPointerUp={() => {
if (!drag) return;
if (moved.current) {
setCols(layout(cols, drag));
if (drag.col !== (cols.todo.includes(id) ? 'todo' : 'done')) setSaid(`Moved ${id} to ${drag.col === 'todo' ? 'To do' : 'Done'}`);
}
setDrag(null);
}}
onPointerCancel={() => setDrag(null)}
onClick={() => {
// A click (or Enter) without a drag sends the chip to the other column.
if (moved.current) moved.current = false;
else move(id, c === 'todo' ? 'done' : 'todo');
}}
>
{c === 'done' && (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
)}
{id}
</button>
);
}),
)}
<span className="card-kanban__live" aria-live="polite">
{said}
</span>
</article>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardKanban(el) on the mounted element instead.
<article class="card-kanban" aria-label="Board">
<div>
<span class="card-kanban__col" style="left:10px" aria-hidden="true"></span>
<p class="card-kanban__head" style="left:14px">To do<span>2</span></p>
</div>
<div>
<span class="card-kanban__col" style="left:124px" aria-hidden="true"></span>
<p class="card-kanban__head" style="left:128px">Done<span>1</span></p>
</div>
<button type="button" class="card-kanban__chip" style="transform:translate(10px, 36px)" aria-label="Write brief, in To do. Press to move it to Done.">Write brief</button>
<button type="button" class="card-kanban__chip" style="transform:translate(10px, 76px)" aria-label="Pick type, in To do. Press to move it to Done.">Pick type</button>
<button type="button" class="card-kanban__chip card-kanban__chip--done" style="transform:translate(124px, 36px)" aria-label="Moodboard, in Done. Press to move it to To do.">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12.5l4.5 4.5L19 7.5"></path>
</svg>
Moodboard
</button>
<span class="card-kanban__live" aria-live="polite"></span>
</article>.card-kanban { position: relative; width: 240px; height: 200px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); user-select: none; -webkit-user-select: none; }
.card-kanban__col { position: absolute; top: 8px; bottom: 8px; width: 114px; margin-left: -4px; border-radius: 12px; transition: background-color 0.2s; }
.card-kanban__col--target { background: light-dark(#ebedf0, #232529); }
.card-kanban__head { position: absolute; top: 14px; margin: 0; font-size: 11px; font-weight: 600; color: light-dark(#50535b, #c6c8cd); }
.card-kanban__head span { margin-left: 4px; color: light-dark(#5f626a, #a7abb2); font-variant-numeric: tabular-nums; }
.card-kanban__slot { position: absolute; width: 106px; height: 34px; box-sizing: border-box; border: 1.5px dashed light-dark(#6d7179, #7d8087); border-radius: 9px; }
.card-kanban__chip {
position: absolute;
left: 0;
top: 0;
display: flex;
align-items: center;
gap: 6px;
box-sizing: border-box;
width: 106px;
height: 34px;
padding: 0 10px;
border: 0;
border-radius: 9px;
background: light-dark(#dbdde1, #2e3035);
color: light-dark(#373a40, #f1f2f3);
font: inherit;
font-size: 11.5px;
text-align: left;
white-space: nowrap;
cursor: grab;
touch-action: none;
box-shadow: 0 1px 2px var(--card-kanban-shadow);
transition: transform 0.4s cubic-bezier(0.3, 1.25, 0.5, 1), box-shadow 0.2s;
}
.card-kanban { --card-kanban-shadow: light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5)); }
.card-kanban__chip:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.card-kanban__chip--done { color: light-dark(#50535b, #c6c8cd); }
.card-kanban__chip svg { flex: none; width: 12px; height: 12px; color: light-dark(#50535b, #c6c8cd); }
.card-kanban__chip--held { z-index: 3; cursor: grabbing; box-shadow: 0 12px 20px -8px var(--card-kanban-shadow); transition: box-shadow 0.2s; }
.card-kanban__live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.card-kanban__chip { transition: none; }
}function initCardKanban(root) {
var X = { todo: 10, done: 124 };
var TOP = 36;
var ROW = 40;
var NAME = { todo: 'To do', done: 'Done' };
var TICK =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M5 12.5l4.5 4.5L19 7.5"></path></svg>';
var colEls = root.querySelectorAll('.card-kanban__col');
var counts = root.querySelectorAll('.card-kanban__head span');
var live = root.querySelector('.card-kanban__live');
var slot = document.createElement('span');
slot.className = 'card-kanban__slot';
slot.setAttribute('aria-hidden', 'true');
var chips = {};
var cols = { todo: [], done: [] };
root.querySelectorAll('.card-kanban__chip').forEach(function (chip) {
var id = chip.textContent.trim();
chips[id] = chip;
cols[chip.classList.contains('card-kanban__chip--done') ? 'done' : 'todo'].push(id);
});
var drag = null;
var moved = false;
function without(list, id) {
return list.filter(function (t) {
return t !== id;
});
}
// Where every chip sits: the held one is taken out and slotted in where it would land.
function layout(c, d) {
var out = { todo: without(c.todo, d && d.id), done: without(c.done, d && d.id) };
if (d) out[d.col].splice(d.at, 0, d.id);
return out;
}
function columnOf(id) {
return cols.todo.indexOf(id) > -1 ? 'todo' : 'done';
}
function dress(chip, id, c) {
var done = c === 'done';
if (chip.classList.contains('card-kanban__chip--done') !== done) {
chip.classList.toggle('card-kanban__chip--done', done);
chip.innerHTML = (done ? TICK : '') + id;
}
chip.setAttribute('aria-label', id + ', in ' + NAME[c] + '. Press to move it to ' + (done ? 'To do' : 'Done') + '.');
}
function render() {
var view = layout(cols, drag);
['todo', 'done'].forEach(function (c, k) {
colEls[k].classList.toggle('card-kanban__col--target', !!drag && drag.col === c);
counts[k].textContent = view[c].length;
view[c].forEach(function (id, i) {
var chip = chips[id];
var held = !!drag && drag.id === id;
chip.classList.toggle('card-kanban__chip--held', held);
// The held chip follows the pointer, tilted; the rest glide to their slots.
chip.style.transform = held
? 'translate(' + (drag.x - drag.dx) + 'px, ' + (drag.y - drag.dy) + 'px) rotate(4deg) scale(1.05)'
: 'translate(' + X[c] + 'px, ' + (TOP + i * ROW) + 'px)';
dress(chip, id, c);
});
});
if (drag) {
slot.style.left = X[drag.col] + 'px';
slot.style.top = TOP + drag.at * ROW + 'px';
if (!slot.parentNode) root.insertBefore(slot, chips[cols.todo[0] || cols.done[0]]);
} else if (slot.parentNode) {
slot.remove();
}
}
// Keep the document order the same as the board, so Tab walks it column by column.
function reorder() {
var focused = document.activeElement;
cols.todo.concat(cols.done).forEach(function (id) {
root.insertBefore(chips[id], live);
});
if (focused && focused !== document.activeElement && root.contains(focused)) focused.focus({ preventScroll: true });
}
function local(e) {
var r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function target(x, y, id) {
var col = x < 120 ? 'todo' : 'done';
var n = without(cols[col], id).length;
return { col: col, at: Math.max(0, Math.min(n, Math.round((y - TOP - 17) / ROW))) };
}
function say(id, col) {
live.textContent = 'Moved ' + id + ' to ' + NAME[col];
}
function onDown(e) {
if (e.button) return;
var chip = e.currentTarget;
var id = chip.textContent.trim();
var c = columnOf(id);
var i = cols[c].indexOf(id);
var p = local(e);
chip.setPointerCapture(e.pointerId);
moved = false;
drag = { id: id, x: p.x, y: p.y, dx: p.x - X[c], dy: p.y - (TOP + i * ROW), x0: p.x, y0: p.y, col: c, at: i };
render();
}
function onMove(e) {
if (!drag || drag.id !== e.currentTarget.textContent.trim()) return;
var p = local(e);
if (Math.hypot(p.x - drag.x0, p.y - drag.y0) > 4) moved = true;
var t = target(p.x, p.y, drag.id);
drag.x = p.x;
drag.y = p.y;
drag.col = t.col;
drag.at = t.at;
render();
}
function onUp() {
if (!drag) return;
var d = drag;
drag = null;
if (moved) {
var from = columnOf(d.id);
cols = layout(cols, d);
if (d.col !== from) say(d.id, d.col);
reorder();
}
render();
}
function onCancel() {
drag = null;
render();
}
// A click (or Enter) without a drag sends the chip to the other column.
function onClick(e) {
if (moved) {
moved = false;
return;
}
var id = e.currentTarget.textContent.trim();
var col = columnOf(id) === 'todo' ? 'done' : 'todo';
cols = layout(cols, { id: id, col: col, at: without(cols[col], id).length });
say(id, col);
reorder();
render();
}
var list = Object.keys(chips).map(function (id) {
return chips[id];
});
list.forEach(function (chip) {
chip.addEventListener('pointerdown', onDown);
chip.addEventListener('pointermove', onMove);
chip.addEventListener('pointerup', onUp);
chip.addEventListener('pointercancel', onCancel);
chip.addEventListener('click', onClick);
});
return function () {
list.forEach(function (chip) {
chip.removeEventListener('pointerdown', onDown);
chip.removeEventListener('pointermove', onMove);
chip.removeEventListener('pointerup', onUp);
chip.removeEventListener('pointercancel', onCancel);
chip.removeEventListener('click', onClick);
});
};
}
document.querySelectorAll('.card-kanban').forEach(initCardKanban);Goal: Create a "Kanban" card.
Visual behavior: A two-column board: drag a task across and it lifts and tilts as you carry it, a dashed gap opens where it will land, and it settles into the other column as the rest glide over.
Technique:
- Every chip is absolutely placed and moved by transform, so the layout is just a list per column. While dragging, the held chip is taken out and slotted in where the pointer would drop it, and the others glide to their new rows.
- The held chip follows the pointer with pointer capture, tilted and scaled with a deeper shadow; the target column lights up and a dashed slot marks the landing spot.
- A click without a drag moves the chip to the other column, so it works without dragging at all.
Accessibility: Each chip is a button whose label names its column and says that pressing moves it; Enter or Space moves it, and every move is announced in a polite live region. Under prefers-reduced-motion the chips jump to their places.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Kanban" card.
Visual behavior: A two-column board: drag a task across and it lifts and tilts as you carry it, a dashed gap opens where it will land, and it settles into the other column as the rest glide over.
Technique:
- Every chip is absolutely placed and moved by transform, so the layout is just a list per column. While dragging, the held chip is taken out and slotted in where the pointer would drop it, and the others glide to their new rows.
- The held chip follows the pointer with pointer capture, tilted and scaled with a deeper shadow; the target column lights up and a dashed slot marks the landing spot.
- A click without a drag moves the chip to the other column, so it works without dragging at all.
Accessibility: Each chip is a button whose label names its column and says that pressing moves it; Enter or Space moves it, and every move is announced in a polite live region. Under prefers-reduced-motion the chips jump to their places.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
A two-column board: drag a task across and it lifts and tilts as you carry it, a dashed gap opens where it will land, and it settles into the other column as the rest glide over.
Each chip is a button whose label names its column and says that pressing moves it; Enter or Space moves it, and every move is announced in a polite live region. Under prefers-reduced-motion the chips jump to their places.
Ridge walk, 6am. Double-click the photo.
Standup moved to 10:30
1. Collect
Save anything you like into a collection as you browse.