@use 'sprucecss/scss/spruce' as *;
.kanban-board {
@include layout-stack('m');
&__header {
@include layout-stack('xxs');
}
&__title {
font-size: font-size('h4');
margin: 0;
}
&__hint {
color: color('muted');
font-size: config('font-size-sm', $typography);
}
&__columns {
display: grid;
gap: spacer('s');
grid-auto-columns: minmax(16rem, 1fr);
grid-auto-flow: column;
overflow-x: auto;
overscroll-behavior-inline: contain;
padding-block-end: spacer('xs');
scroll-snap-type: inline mandatory;
}
&__column {
background-color: color('surface-muted');
border: config('border-width-base', $display) solid color('border');
border-radius: radius('lg');
display: flex;
flex-direction: column;
gap: spacer('s');
padding: spacer('s');
scroll-snap-align: start;
}
&__column-title {
align-items: center;
display: flex;
font-family: inherit;
font-size: config('font-size-base', $typography);
gap: spacer('xs');
margin: 0;
}
&__count {
background-color: color('surface');
border: config('border-width-base', $display) solid color('border');
border-radius: radius('pill');
color: color('muted');
font-size: 0.75rem;
line-height: 1;
padding: 0.25em 0.6em;
}
&__list {
@include clear-list;
display: flex;
flex-direction: column;
flex-grow: 1;
gap: spacer('xs');
min-block-size: 5rem;
}
&__card {
background-color: color('surface');
border: config('border-width-base', $display) solid color('border');
border-radius: radius('md');
box-shadow: shadow('xs');
margin: 0 !important;
padding: spacer('s');
@include layout-stack('xs');
@include transition($property: 'box-shadow, opacity, outline-color');
&[draggable='true'] {
cursor: grab;
}
&:hover {
box-shadow: shadow('sm');
}
&--dragging {
opacity: 0.4;
}
&--grabbed {
box-shadow: shadow('lg');
outline: 2px solid color('primary');
outline-offset: 2px;
}
}
&__card-header {
align-items: center;
display: flex;
justify-content: space-between;
}
&__tag {
border-radius: radius('sm');
color: color('heading');
font-size: 0.75rem;
font-weight: 700;
line-height: 1;
padding: 0.35em 0.6em;
&--bug {
background-color: color-alpha('danger', 0.15, 'alert');
}
&--feature {
background-color: color-alpha('primary', 0.12);
}
&--design {
background-color: color-alpha('info', 0.15, 'alert');
}
}
&__handle {
@include clear-btn;
border-radius: radius('sm');
color: color('muted');
cursor: grab;
display: inline-flex;
padding: spacer('xxs');
svg {
block-size: 1rem;
inline-size: 1rem;
}
&:hover,
&[aria-pressed='true'] {
background-color: color('surface-muted');
color: color('heading');
}
&:focus-visible {
outline: 2px solid color('focus-ring');
outline-offset: 1px;
}
}
&__card-title {
font-family: inherit;
font-size: config('font-size-base', $typography);
font-weight: 600;
a {
color: color('heading');
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
&__meta {
align-items: center;
color: color('muted');
display: flex;
font-size: 0.8125rem;
gap: spacer('xs');
}
&__avatar {
align-items: center;
aspect-ratio: 1;
background-color: color-alpha('primary', 0.15);
border-radius: radius('pill');
color: color('primary');
display: inline-flex;
font-size: 0.6875rem;
font-weight: 700;
inline-size: 1.75rem;
justify-content: center;
margin-inline-start: auto;
}
}
<section class="kanban-board" aria-labelledby="kanban-title" data-kanban>
<header class="kanban-board__header">
<h3 class="kanban-board__title" id="kanban-title">Sprint 42</h3>
<p class="kanban-board__hint" id="kanban-hint" hidden>Drag a card to move it. With the keyboard, press the move button, use the arrow keys, then press Enter to drop or Escape to cancel.</p>
</header>
<div class="kanban-board__columns">
<section class="kanban-board__column" aria-labelledby="kanban-todo" data-kanban-column>
<h4 class="kanban-board__column-title"><span id="kanban-todo">To do</span> <span class="kanban-board__count" data-kanban-count>3<span class="sr-only"> cards</span></span></h4>
<ul class="kanban-board__list" role="list" data-kanban-list>
<li class="kanban-board__card" data-kanban-card>
<div class="kanban-board__card-header">
<span class="kanban-board__tag kanban-board__tag--bug">Bug</span>
<button class="kanban-board__handle" type="button" aria-pressed="false" aria-describedby="kanban-hint" data-kanban-handle hidden>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
<span class="sr-only">Move “Fix rounding in the tax total”</span>
</button>
</div>
<h5 class="kanban-board__card-title"><a href="#">Fix rounding in the tax total</a></h5>
<div class="kanban-board__meta">
<span>#212</span>
<span>Due <time datetime="2026-10-08">Oct 8</time></span>
<span class="kanban-board__avatar" title="Anna Smith"><span aria-hidden="true">AS</span><span class="sr-only">Assigned to Anna Smith</span></span>
</div>
</li>
<li class="kanban-board__card" data-kanban-card>
<div class="kanban-board__card-header">
<span class="kanban-board__tag kanban-board__tag--feature">Feature</span>
<button class="kanban-board__handle" type="button" aria-pressed="false" aria-describedby="kanban-hint" data-kanban-handle hidden>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
<span class="sr-only">Move “Export orders to CSV”</span>
</button>
</div>
<h5 class="kanban-board__card-title"><a href="#">Export orders to CSV</a></h5>
<div class="kanban-board__meta">
<span>#219</span>
<span>Due <time datetime="2026-10-12">Oct 12</time></span>
<span class="kanban-board__avatar" title="Ben King"><span aria-hidden="true">BK</span><span class="sr-only">Assigned to Ben King</span></span>
</div>
</li>
<li class="kanban-board__card" data-kanban-card>
<div class="kanban-board__card-header">
<span class="kanban-board__tag kanban-board__tag--design">Design</span>
<button class="kanban-board__handle" type="button" aria-pressed="false" aria-describedby="kanban-hint" data-kanban-handle hidden>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
<span class="sr-only">Move “New empty states for reports”</span>
</button>
</div>
<h5 class="kanban-board__card-title"><a href="#">New empty states for reports</a></h5>
<div class="kanban-board__meta">
<span>#223</span>
<span>Due <time datetime="2026-10-15">Oct 15</time></span>
<span class="kanban-board__avatar" title="Lea Moreau"><span aria-hidden="true">LM</span><span class="sr-only">Assigned to Lea Moreau</span></span>
</div>
</li>
</ul>
</section>
<section class="kanban-board__column" aria-labelledby="kanban-progress" data-kanban-column>
<h4 class="kanban-board__column-title"><span id="kanban-progress">In progress</span> <span class="kanban-board__count" data-kanban-count>2<span class="sr-only"> cards</span></span></h4>
<ul class="kanban-board__list" role="list" data-kanban-list>
<li class="kanban-board__card" data-kanban-card>
<div class="kanban-board__card-header">
<span class="kanban-board__tag kanban-board__tag--feature">Feature</span>
<button class="kanban-board__handle" type="button" aria-pressed="false" aria-describedby="kanban-hint" data-kanban-handle hidden>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
<span class="sr-only">Move “Saved filters on the order list”</span>
</button>
</div>
<h5 class="kanban-board__card-title"><a href="#">Saved filters on the order list</a></h5>
<div class="kanban-board__meta">
<span>#204</span>
<span>Due <time datetime="2026-10-06">Oct 6</time></span>
<span class="kanban-board__avatar" title="Anna Smith"><span aria-hidden="true">AS</span><span class="sr-only">Assigned to Anna Smith</span></span>
</div>
</li>
<li class="kanban-board__card" data-kanban-card>
<div class="kanban-board__card-header">
<span class="kanban-board__tag kanban-board__tag--bug">Bug</span>
<button class="kanban-board__handle" type="button" aria-pressed="false" aria-describedby="kanban-hint" data-kanban-handle hidden>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
<span class="sr-only">Move “Webhook retries stop after 3 attempts”</span>
</button>
</div>
<h5 class="kanban-board__card-title"><a href="#">Webhook retries stop after 3 attempts</a></h5>
<div class="kanban-board__meta">
<span>#209</span>
<span>Due <time datetime="2026-10-05">Oct 5</time></span>
<span class="kanban-board__avatar" title="Jon Diaz"><span aria-hidden="true">JD</span><span class="sr-only">Assigned to Jon Diaz</span></span>
</div>
</li>
</ul>
</section>
<section class="kanban-board__column" aria-labelledby="kanban-done" data-kanban-column>
<h4 class="kanban-board__column-title"><span id="kanban-done">Done</span> <span class="kanban-board__count" data-kanban-count>2<span class="sr-only"> cards</span></span></h4>
<ul class="kanban-board__list" role="list" data-kanban-list>
<li class="kanban-board__card" data-kanban-card>
<div class="kanban-board__card-header">
<span class="kanban-board__tag kanban-board__tag--design">Design</span>
<button class="kanban-board__handle" type="button" aria-pressed="false" aria-describedby="kanban-hint" data-kanban-handle hidden>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
<span class="sr-only">Move “Dark mode for the invoice PDF”</span>
</button>
</div>
<h5 class="kanban-board__card-title"><a href="#">Dark mode for the invoice PDF</a></h5>
<div class="kanban-board__meta">
<span>#198</span>
<span>Due <time datetime="2026-10-01">Oct 1</time></span>
<span class="kanban-board__avatar" title="Lea Moreau"><span aria-hidden="true">LM</span><span class="sr-only">Assigned to Lea Moreau</span></span>
</div>
</li>
<li class="kanban-board__card" data-kanban-card>
<div class="kanban-board__card-header">
<span class="kanban-board__tag kanban-board__tag--feature">Feature</span>
<button class="kanban-board__handle" type="button" aria-pressed="false" aria-describedby="kanban-hint" data-kanban-handle hidden>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
<span class="sr-only">Move “Bulk fulfillment”</span>
</button>
</div>
<h5 class="kanban-board__card-title"><a href="#">Bulk fulfillment</a></h5>
<div class="kanban-board__meta">
<span>#187</span>
<span>Due <time datetime="2026-09-29">Sep 29</time></span>
<span class="kanban-board__avatar" title="Ben King"><span aria-hidden="true">BK</span><span class="sr-only">Assigned to Ben King</span></span>
</div>
</li>
</ul>
</section>
</div>
<p class="sr-only" aria-live="assertive" data-kanban-live></p>
</section>
(() => {
document.querySelectorAll('[data-kanban]').forEach((board) => {
const columns = [...board.querySelectorAll('[data-kanban-column]')];
const live = board.querySelector('[data-kanban-live]');
let dragged = null;
let grabbed = null;
let origin = null;
const listOf = (column) => column.querySelector('[data-kanban-list]');
const titleOf = (card) => card.querySelector('.kanban-board__card-title').textContent.trim();
const columnName = (column) => document.getElementById(column.getAttribute('aria-labelledby')).textContent.trim();
const updateCounts = () => {
columns.forEach((column) => {
column.querySelector('[data-kanban-count]').firstChild.nodeValue = listOf(column).children.length;
});
};
const announce = (card, prefix = '') => {
const column = card.closest('[data-kanban-column]');
const cards = [...listOf(column).children];
live.textContent = `${prefix}“${titleOf(card)}” is in ${columnName(column)}, position ${cards.indexOf(card) + 1} of ${cards.length}.`;
updateCounts();
board.dispatchEvent(new CustomEvent('kanban:move', { detail: { card, column } }));
};
board.querySelector('.kanban-board__hint').hidden = false;
board.querySelectorAll('[data-kanban-card]').forEach((card) => {
card.draggable = true;
card.querySelector('[data-kanban-handle]').hidden = false;
});
board.addEventListener('dragstart', (event) => {
dragged = event.target.closest('[data-kanban-card]');
if (!dragged) {
return;
}
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', titleOf(dragged));
requestAnimationFrame(() => dragged.classList.add('kanban-board__card--dragging'));
});
board.addEventListener('dragover', (event) => {
const list = event.target.closest('[data-kanban-list]');
if (!dragged || !list) {
return;
}
event.preventDefault();
const next = [...list.children].find((card) => {
const box = card.getBoundingClientRect();
return card !== dragged && event.clientY < box.top + box.height / 2;
});
if (next !== dragged.nextElementSibling || dragged.parentElement !== list) {
list.insertBefore(dragged, next || null);
}
});
board.addEventListener('drop', (event) => event.preventDefault());
board.addEventListener('dragend', () => {
if (!dragged) {
return;
}
dragged.classList.remove('kanban-board__card--dragging');
announce(dragged);
dragged = null;
});
const drop = (cancel = false) => {
const handle = grabbed.querySelector('[data-kanban-handle]');
if (cancel) {
origin.list.insertBefore(grabbed, origin.next);
handle.focus();
}
grabbed.classList.remove('kanban-board__card--grabbed');
handle.setAttribute('aria-pressed', 'false');
announce(grabbed, cancel ? 'Move cancelled. ' : 'Dropped. ');
grabbed = null;
};
board.addEventListener('click', (event) => {
const handle = event.target.closest('[data-kanban-handle]');
if (!handle) {
return;
}
if (grabbed) {
drop();
return;
}
grabbed = handle.closest('[data-kanban-card]');
origin = { list: grabbed.parentElement, next: grabbed.nextElementSibling };
grabbed.classList.add('kanban-board__card--grabbed');
handle.setAttribute('aria-pressed', 'true');
live.textContent = `Grabbed “${titleOf(grabbed)}”. Use the arrow keys to move, Enter to drop, Escape to cancel.`;
});
board.addEventListener('keydown', (event) => {
if (!grabbed || !event.target.closest('[data-kanban-handle]')) {
return;
}
const list = grabbed.parentElement;
const column = grabbed.closest('[data-kanban-column]');
const isRtl = getComputedStyle(board).direction === 'rtl';
const step = { ArrowLeft: isRtl ? 1 : -1, ArrowRight: isRtl ? -1 : 1 }[event.key];
if (event.key === 'Escape') {
drop(true);
} else if (event.key === 'Enter') {
return;
} else if (event.key === 'ArrowUp' && grabbed.previousElementSibling) {
list.insertBefore(grabbed, grabbed.previousElementSibling);
} else if (event.key === 'ArrowDown' && grabbed.nextElementSibling) {
list.insertBefore(grabbed.nextElementSibling, grabbed);
} else if (step) {
const target = columns[columns.indexOf(column) + step];
if (!target) {
return;
}
const index = [...list.children].indexOf(grabbed);
const targetList = listOf(target);
targetList.insertBefore(grabbed, targetList.children[index] || null);
} else {
return;
}
event.preventDefault();
if (grabbed) {
grabbed.querySelector('[data-kanban-handle]').focus();
grabbed.scrollIntoView({ block: 'nearest', inline: 'nearest' });
announce(grabbed);
}
});
board.addEventListener('focusout', (event) => {
if (grabbed && !grabbed.contains(event.relatedTarget) && event.relatedTarget) {
drop();
}
});
});
})();