UI Components Medium
Drag Kanban Board
Move cards between status columns with native HTML5 DnD and live counts.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0b1020;
--panel: #121a2d;
--panel-2: #18233b;
--line: #2b3d62;
--fg: #eef2ff;
--muted: #9eb1d4;
--accent: #78a9ff;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(1100px 560px at 50% -12%, #1a2647 0%, var(--bg) 62%);
color: var(--fg);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
padding: clamp(1rem, 4vw, 3rem);
}
.demo {
width: min(1040px, 100%);
margin: auto;
}
.board-head h1 { margin: 0 0 .35rem; font-size: 1.35rem; letter-spacing: -.01em; }
.hint { margin: 0 0 1.25rem; color: var(--muted); font-size: .85rem; }
kbd {
background: var(--panel-2);
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 5px;
padding: 0 .35em;
font: inherit;
font-size: .82em;
}
.board {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: .85rem;
align-items: start;
}
@media (max-width: 760px) {
.board { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.col {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 16px;
padding: .75rem;
display: flex;
flex-direction: column;
min-height: 240px;
transition: background .18s, border-color .18s, box-shadow .18s;
}
.col.is-over {
border-color: var(--accent);
background: #16213c;
box-shadow: 0 0 0 1px rgba(120, 169, 255, .35), 0 16px 40px -22px rgba(120, 169, 255, .9);
}
.col-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: .5rem;
margin-bottom: .7rem;
}
.col-head h2 {
margin: 0;
font-size: .76rem;
text-transform: uppercase;
letter-spacing: .1em;
color: var(--muted);
}
.count {
min-width: 1.6rem;
text-align: center;
font-size: .72rem;
font-variant-numeric: tabular-nums;
padding: .1rem .4rem;
border-radius: 999px;
background: var(--panel-2);
border: 1px solid var(--line);
color: var(--muted);
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: .5rem;
flex: 1;
}
.card {
background: linear-gradient(180deg, var(--panel-2), #141c30);
border: 1px solid var(--line);
border-radius: 12px;
padding: .6rem .7rem;
font-size: .86rem;
cursor: grab;
display: flex;
flex-direction: column;
gap: .4rem;
transition: transform .15s, border-color .15s, box-shadow .15s, opacity .15s;
}
.card:hover { border-color: #405a8c; transform: translateY(-1px); }
.card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.card:active { cursor: grabbing; }
.card.dragging { opacity: .35; }
.card.just-moved { animation: pop .32s ease; }
@keyframes pop {
0% { transform: scale(.96); box-shadow: 0 0 0 0 rgba(120, 169, 255, .55); }
100% { transform: none; box-shadow: 0 0 0 10px rgba(120, 169, 255, 0); }
}
.placeholder {
height: 44px;
border: 2px dashed var(--accent);
border-radius: 12px;
background: rgba(120, 169, 255, .1);
}
.tag {
align-self: flex-start;
font-size: .62rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .08em;
padding: .12rem .42rem;
border-radius: 6px;
}
.t-bug { background: rgba(255, 106, 122, .16); color: #ff9aa6; }
.t-feat { background: rgba(120, 169, 255, .16); color: #a8c8ff; }
.t-doc { background: rgba(126, 231, 178, .14); color: #86e3b6; }
.status {
margin-top: 1rem;
min-height: 1.2em;
font-size: .8rem;
color: var(--muted);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; animation: none !important; }
}(() => {
const board = document.getElementById("board");
if (!board) return;
const live = document.getElementById("live");
const cols = [...board.querySelectorAll(".col")];
const lists = cols.map((c) => c.querySelector(".list"));
const placeholder = document.createElement("li");
placeholder.className = "placeholder";
placeholder.setAttribute("aria-hidden", "true");
let dragged = null;
/* ---------- state helpers ---------- */
const label = (card) => card.textContent.replace(/\s+/g, " ").trim();
const statusOf = (card) => card.closest(".col").dataset.status;
function refresh() {
cols.forEach((col) => {
const n = col.querySelectorAll(".card").length;
col.querySelector("[data-count]").textContent = String(n);
});
// Persistence boundary: replace with your own save call.
// save(cols.map(c => ({ status: c.dataset.status,
// cards: [...c.querySelectorAll(".card")].map(label) })));
}
function announce(card) {
const list = card.parentElement;
const index = [...list.querySelectorAll(".card")].indexOf(card) + 1;
const total = list.querySelectorAll(".card").length;
live.textContent = `"${label(card)}" moved to ${statusOf(card)}, position ${index} of ${total}.`;
}
function settle(card) {
card.classList.remove("just-moved");
void card.offsetWidth;
card.classList.add("just-moved");
refresh();
announce(card);
}
/* ---------- pointer / HTML5 drag & drop ---------- */
// Given a list and a pointer Y, find the card the dragged item should sit before.
function cardAfter(list, y) {
const candidates = [...list.querySelectorAll(".card:not(.dragging)")];
return candidates.find((card) => {
const box = card.getBoundingClientRect();
return y < box.top + box.height / 2;
}) || null;
}
board.addEventListener("dragstart", (e) => {
const card = e.target.closest(".card");
if (!card) return;
dragged = card;
card.classList.add("dragging");
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
// Some browsers require data to be set for the drag to begin.
e.dataTransfer.setData("text/plain", label(card));
}
});
board.addEventListener("dragend", () => {
if (!dragged) return;
dragged.classList.remove("dragging");
placeholder.remove();
cols.forEach((c) => c.classList.remove("is-over"));
dragged = null;
});
cols.forEach((col, i) => {
const list = lists[i];
col.addEventListener("dragover", (e) => {
if (!dragged) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
cols.forEach((c) => c.classList.toggle("is-over", c === col));
const before = cardAfter(list, e.clientY);
if (before) list.insertBefore(placeholder, before);
else list.appendChild(placeholder);
});
col.addEventListener("dragleave", (e) => {
if (!col.contains(e.relatedTarget)) col.classList.remove("is-over");
});
col.addEventListener("drop", (e) => {
if (!dragged) return;
e.preventDefault();
const card = dragged;
if (placeholder.parentElement === list) list.replaceChild(card, placeholder);
else list.appendChild(card);
placeholder.remove();
card.classList.remove("dragging");
cols.forEach((c) => c.classList.remove("is-over"));
dragged = null;
settle(card);
card.focus();
});
});
/* ---------- keyboard fallback ---------- */
board.addEventListener("keydown", (e) => {
const card = e.target.closest(".card");
if (!card) return;
const list = card.parentElement;
const colIndex = lists.indexOf(list);
const siblings = [...list.querySelectorAll(".card")];
const pos = siblings.indexOf(card);
let moved = false;
if (e.key === "ArrowLeft" && colIndex > 0) {
lists[colIndex - 1].appendChild(card);
moved = true;
} else if (e.key === "ArrowRight" && colIndex < lists.length - 1) {
lists[colIndex + 1].appendChild(card);
moved = true;
} else if (e.key === "ArrowUp" && pos > 0) {
list.insertBefore(card, siblings[pos - 1]);
moved = true;
} else if (e.key === "ArrowDown" && pos < siblings.length - 1) {
list.insertBefore(card, siblings[pos + 1].nextSibling);
moved = true;
}
if (moved) {
e.preventDefault();
settle(card);
card.focus();
}
});
refresh();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Drag Kanban Board</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Drag Kanban Board">
<header class="board-head">
<h1>Sprint board</h1>
<p class="hint">
Drag a card into another column, or focus a card and press
<kbd>←</kbd> <kbd>→</kbd> to change status and
<kbd>↑</kbd> <kbd>↓</kbd> to reorder.
</p>
</header>
<div class="board" id="board">
<section class="col" data-status="To do" aria-labelledby="h-todo">
<div class="col-head">
<h2 id="h-todo">To do</h2>
<span class="count" data-count>0</span>
</div>
<ul class="list" role="list" aria-labelledby="h-todo">
<li class="card" draggable="true" tabindex="0"><span class="tag t-bug">bug</span>Fix flaky login redirect</li>
<li class="card" draggable="true" tabindex="0"><span class="tag t-feat">feat</span>Add CSV export to reports</li>
<li class="card" draggable="true" tabindex="0"><span class="tag t-doc">docs</span>Document webhook retries</li>
</ul>
</section>
<section class="col" data-status="In progress" aria-labelledby="h-doing">
<div class="col-head">
<h2 id="h-doing">In progress</h2>
<span class="count" data-count>0</span>
</div>
<ul class="list" role="list" aria-labelledby="h-doing">
<li class="card" draggable="true" tabindex="0"><span class="tag t-feat">feat</span>Keyboard shortcut palette</li>
</ul>
</section>
<section class="col" data-status="Review" aria-labelledby="h-review">
<div class="col-head">
<h2 id="h-review">Review</h2>
<span class="count" data-count>0</span>
</div>
<ul class="list" role="list" aria-labelledby="h-review">
<li class="card" draggable="true" tabindex="0"><span class="tag t-bug">bug</span>Race in cache invalidation</li>
</ul>
</section>
<section class="col" data-status="Done" aria-labelledby="h-done">
<div class="col-head">
<h2 id="h-done">Done</h2>
<span class="count" data-count>0</span>
</div>
<ul class="list" role="list" aria-labelledby="h-done">
<li class="card" draggable="true" tabindex="0"><span class="tag t-doc">docs</span>Rewrite onboarding guide</li>
</ul>
</section>
</div>
<p class="status" role="status" aria-live="polite" id="live"></p>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function DragDropKanbanBoard() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>Drag Kanban Board</h2>
<p>Vanilla behavior maps cleanly to Pointer Events and DOM state.</p>
<div className="stack">
{items.map((item, index) => (
<button
className="item"
key={item}
onClick={() => setItems([...items.slice(0, index), ...items.slice(index + 1), item])}
>
{item}
</button>
))}
</div>
</section>
);
}Drag Kanban Board
Move cards between status columns with native HTML5 DnD and live counts.
Support notes
Native HTML5 DnD covers desktop semantics while Pointer Events handle touch-friendly reorder surfaces. Add a persistence callback where the demo updates the DOM.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.