Nº 024 of 520
Kanban board
Four 292px columns with live counts, labelled cards with avatars, pointer-event drag-and-drop with a 3° tilted ghost and a dashed drop placeholder, plus [ ] keyboard moves.
Preview
Use it
Free to use · credit appreciated1 Once, in your project
npx skills add SusanAcharya/Design-Lounge 2 Then ask
Using the Design Lounge skill, build "Kanban board" (piece kanban-board) in my stack. Match its brief; restyle it onto my product's theme.
No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.
<!-- Design Lounge Nº 024 · "Kanban board" · designlounge.vercel.app -->
# Kanban board
> **Build brief for a coding agent.** Rebuild this piece in the reader's stack. If they haven't said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don't "improve" them.
## What it is
A sprint board for a product team ("Fjord Bank · Mobile 4.2"): four columns — Backlog, In progress, Review, Done — each with a coloured status dot, a live card count, a scrollable card list and an "Add card" button. Cards carry coloured label pills, a title, an issue ID, a due date and an initials avatar. Drag-and-drop is written with pointer events (no HTML5 DnD): after 4px of movement the card is replaced by a dashed placeholder and a tilted ghost copy follows the pointer; the column under the pointer tints teal; releasing drops the card where the placeholder is. Cards are focusable and `[` / `]` move them between columns for keyboard users. Below 1280 the board scrolls horizontally with snap points. Single family (Schibsted Grotesk) at four weights; one teal accent.
## Reference behaviour
1. Initial state: 60px white header (title, "Sprint 31 · 12 cards", a keyboard hint at right, "Filter" and a teal "New card" button). Board with four columns: counts 4 / 3 / 2 / 3. Cards at rest have a 1px `--line` border and a 1px shadow; cursor is `grab`.
2. Hover a card: border darkens to `--line-2`. No lift.
3. Press and move a card ≥ 4px: a **ghost** (a clone, `position: fixed`, rotated 3° and scaled 1.02, teal border, deep shadow) appears under the pointer offset by the original grab point; the original card is removed from flow (`display: none`) and a **placeholder** (dashed 1.5px `--line-2` border, 6 % teal fill, same height as the card) takes its place.
4. Move across columns: the column whose list is under the pointer gets class `over` (background `--accent-soft`). The placeholder moves to the position before the first card whose vertical midpoint is below the pointer, or to the end of the list.
5. Release: the placeholder is replaced by the card, the ghost is removed, tints clear, both column counts update, the card receives focus and a polite live region says "<title> moved to <column>".
6. Pointer cancel (e.g. window blur) behaves like release at the current placeholder position.
7. Keyboard: Tab to a card (2px teal outline), press `]` to move it to the top of the next column or `[` to the previous; counts update; focus stays on the card; the live region announces the move.
8. "Add card": appends a card titled "Untitled card" with a fresh ID (FB-4xx), no labels, and focuses it; the count increments.
9. At widths where four 292px columns don't fit, the board scrolls horizontally with `scroll-snap-type: x proximity`; column lists scroll vertically on their own.
## Structure
```
1280 × 800
┌────────────────────────────────────────────────────────────────────────────┐
│ Fjord Bank · Mobile 4.2 Sprint 31 · 12 cards Drag… [ ] [Filter] [New]│ 60
├────────────────────────────────────────────────────────────────────────────┤
│ 28 ┌ Backlog ─── 4 ┐ ┌ In progress 3 ┐ ┌ Review ──── 2 ┐ ┌ Done ────── 3 ┐ │ 24
│ │ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │
│ │ │ Feature │ │ │ │ Feature │ │ │ │ Feature │ │ │ │ Bug │ │ │
│ │ │ Scheduled…│ │ │ │ Card free…│ │ │ │ Split a p…│ │ │ │ Biometric…│ │ │
│ │ │ FB-412 ▭14│ │ │ │ FB-397 AL│ │ │ FB-388 SV│ │ │ │ FB-380 JR│ │ │
│ │ └───────────┘ │ │ └───────────┘ │ │ └───────────┘ │ │ └───────────┘ │ │
│ │ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │
│ │ │ … │ │ │ │ … │ │ │ │ … │ │ │ │ … │ │ │
│ │ (list scrolls)│ │ │ │ │ │ │ │
│ │ [+ Add card] │ │ [+ Add card] │ │ [+ Add card] │ │ [+ Add card] │ │
│ └───────────────┘ └───────────────┘ └───────────────┘ └───────────────┘ │
… 181 more lines. Copy or download for the full brief. Loading the source…Brief
221 linesBuild brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.
What it is
A sprint board for a product team (“Fjord Bank · Mobile 4.2”): four columns — Backlog, In progress, Review, Done — each with a coloured status dot, a live card count, a scrollable card list and an “Add card” button. Cards carry coloured label pills, a title, an issue ID, a due date and an initials avatar. Drag-and-drop is written with pointer events (no HTML5 DnD): after 4px of movement the card is replaced by a dashed placeholder and a tilted ghost copy follows the pointer; the column under the pointer tints teal; releasing drops the card where the placeholder is. Cards are focusable and [ / ] move them between columns for keyboard users. Below 1280 the board scrolls horizontally with snap points. Single family (Schibsted Grotesk) at four weights; one teal accent.
Reference behaviour
- Initial state: 60px white header (title, “Sprint 31 · 12 cards”, a keyboard hint at right, “Filter” and a teal “New card” button). Board with four columns: counts 4 / 3 / 2 / 3. Cards at rest have a 1px
--lineborder and a 1px shadow; cursor isgrab. - Hover a card: border darkens to
--line-2. No lift. - Press and move a card ≥ 4px: a ghost (a clone,
position: fixed, rotated 3° and scaled 1.02, teal border, deep shadow) appears under the pointer offset by the original grab point; the original card is removed from flow (display: none) and a placeholder (dashed 1.5px--line-2border, 6 % teal fill, same height as the card) takes its place. - Move across columns: the column whose list is under the pointer gets class
over(background--accent-soft). The placeholder moves to the position before the first card whose vertical midpoint is below the pointer, or to the end of the list. - Release: the placeholder is replaced by the card, the ghost is removed, tints clear, both column counts update, the card receives focus and a polite live region says ”
moved to <column>”. - Pointer cancel (e.g. window blur) behaves like release at the current placeholder position.
- Keyboard: Tab to a card (2px teal outline), press
]to move it to the top of the next column or[to the previous; counts update; focus stays on the card; the live region announces the move. - “Add card”: appends a card titled “Untitled card” with a fresh ID (FB-4xx), no labels, and focuses it; the count increments.
- At widths where four 292px columns don’t fit, the board scrolls horizontally with
scroll-snap-type: x proximity; column lists scroll vertically on their own.
Structure
1280 × 800
┌────────────────────────────────────────────────────────────────────────────┐
│ Fjord Bank · Mobile 4.2 Sprint 31 · 12 cards Drag… [ ] [Filter] [New]│ 60
├────────────────────────────────────────────────────────────────────────────┤
│ 28 ┌ Backlog ─── 4 ┐ ┌ In progress 3 ┐ ┌ Review ──── 2 ┐ ┌ Done ────── 3 ┐ │ 24
│ │ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │
│ │ │ Feature │ │ │ │ Feature │ │ │ │ Feature │ │ │ │ Bug │ │ │
│ │ │ Scheduled…│ │ │ │ Card free…│ │ │ │ Split a p…│ │ │ │ Biometric…│ │ │
│ │ │ FB-412 ▭14│ │ │ │ FB-397 AL│ │ │ FB-388 SV│ │ │ │ FB-380 JR│ │ │
│ │ └───────────┘ │ │ └───────────┘ │ │ └───────────┘ │ │ └───────────┘ │ │
│ │ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │
│ │ │ … │ │ │ │ … │ │ │ │ … │ │ │ │ … │ │ │
│ │ (list scrolls)│ │ │ │ │ │ │ │
│ │ [+ Add card] │ │ [+ Add card] │ │ [+ Add card] │ │ [+ Add card] │ │
│ └───────────────┘ └───────────────┘ └───────────────┘ └───────────────┘ │
└────────────────────────────────────────────────────────────────────────────┘
columns 292px wide, 16px gap, 28px board padding; 4 × 292 + 3 × 16 + 56 = 1272 ≤ 1280
<header>:<h1>,.crumb,.hint(with<kbd>), two.btns.<div class="board">: flex row,overflow-x: auto; overflow-y: hidden; scroll-snap-type: x proximity.<section class="col" style="--c: <dot colour>">:<h2>(dot, name,.ncount),<div class="list" data-col="Name">(flex column,overflow-y: auto, 8px gap),<button class="add">.<article class="card" tabindex="0">:.labels(0–2.labpills),<p>title,.meta(.id, 14px calendar or check icon, date,.avavatar with--acolour).
- Drag artefacts created at runtime:
.card.ghostappended to<body>,.phplaceholder inserted into a list. <p class="sr" aria-live="polite">visually hidden status line.
Tokens
:root {
/* colour — cool light greys, white cards, teal accent, four label hues */
--bg: #f3f4f6; /* page */
--col: #e9ebee; /* column surface */
--card: #ffffff; /* card, header */
--line: #d9dce2; /* card border, header rule */
--line-2: #c3c8d0; /* hover border, placeholder dash, button border */
--ink: #161a1f;
--ink-2: #5b6470; /* ids, counts */
--ink-3: #8a929e; /* dates, hint, Backlog dot */
--accent: #0e7c7b; /* primary button, focus, ghost border, Done dot */
--accent-soft: #d7efec; /* column "over" tint */
--accent-ink: #f2fbfa;
--ph-fill: rgba(14, 124, 123, .06);
--l-bug: #b42318; --l-bug-bg: #fde8e6;
--l-feat: #175cd3; --l-feat-bg: #e3ecfb; /* also the In progress dot */
--l-design: #8a3ffc; --l-design-bg: #efe6ff;
--l-infra: #b54708; --l-infra-bg: #fdefd9; /* also the Review dot */
--av-1: #0e7c7b; --av-2: #8a3ffc; --av-3: #b54708; --av-4: #175cd3;
/* type */
--font: "Schibsted Grotesk", system-ui, sans-serif;
--fs-h1: 17px; --fs-col: 13px; --fs-card: 14px; --fs-meta: 12px; --fs-label: 11px; --fs-av: 10px;
/* layout */
--col-w: 292px; --gap: 16px; --board-pad: 24px 28px; --header-h: 60px;
--r: 10px; /* column */
--r-card: 8px; --r-btn: 8px; --r-s: 6px;
--card-pad: 12px 12px 10px; --list-gap: 8px; --av: 24px; --dot: 8px;
--drag-threshold: 4px; --ghost-tilt: 3deg; --ghost-scale: 1.02;
/* elevation */
--shadow-card: 0 1px 2px rgba(22, 26, 31, .06);
--shadow-ghost: 0 18px 40px -12px rgba(22, 26, 31, .35), 0 2px 6px rgba(22, 26, 31, .12);
/* motion */
--t-micro: 140ms; --t-layout: 240ms;
--ease: cubic-bezier(.2, .7, .2, 1);
}
Typography
| Role | Family | Size | Weight | Line-height | Tracking | Case / notes |
|---|---|---|---|---|---|---|
| Board title | Schibsted Grotesk | 17px | 600 | 1.2 | −0.01em | sentence |
| Crumb / hint | Schibsted Grotesk | 13px / 12px | 400 | 1.4 | 0 | --ink-3; <kbd> 1px --line-2 border, 4px radius |
| Buttons | Schibsted Grotesk | 13px | 500 | 1 | 0 | 34px tall, 14px side padding |
| Column name | Schibsted Grotesk | 13px | 600 | 1.2 | +0.01em | sentence; 8px dot before |
| Count pill | Schibsted Grotesk | 12px | 500 | 1.4 | 0 | tabular; white pill, 1px --line border, min-width 22px |
| Card title | Schibsted Grotesk | 14px | 500 | 1.35 | 0 | sentence, wraps to 2 lines |
| Label pill | Schibsted Grotesk | 11px | 600 | 1.4 | +0.01em | sentence; padding 2px 7px, radius 999px |
| Meta (id/date) | Schibsted Grotesk | 12px | 500 / 400 | 1.4 | 0 | tabular; id --ink-2, date --ink-3 |
| Avatar | Schibsted Grotesk | 10px | 700 | 1 | 0 | UPPERCASE initials on 24px circle |
Motion
| Element | Trigger | Property | From → To | Duration | Easing |
|---|---|---|---|---|---|
.card | hover | border-color | --line → --line-2 | 140ms | --ease |
.card.ghost | drag start | transform (static) | rotate(3deg) scale(1.02), follows pointer via left/top each pointermove; no transition | — | — |
.col | pointer enters list | background | --col → --accent-soft | 140ms | --ease |
.ph | pointer moves | DOM position | re-inserted before the card whose midpoint is below the pointer | instant | — |
.card | drop | DOM position | replaces .ph; no animation (the placeholder already showed the slot) | — | — |
.btn, .add | hover | background / colour | transparent → --card; --ink-2 → --ink | 140ms | --ease |
Reduced motion: transitions 1ms; the ghost is not rotated or scaled (transform: none), still follows the pointer.
States
- Card rest: white, 1px
--lineborder,--shadow-card, cursorgrab,user-select: none,touch-action: none. - Card hover: border
--line-2. - Card focus-visible:
outline: 2px solid --accent; outline-offset: 2px. - Card lifted (
.lift):display: nonefor the duration of the drag (the placeholder holds the slot). - Ghost (
.ghost): fixed,pointer-events: none, z-index 10, widthcalc(292px − 20px), height copied from the source card, teal border,--shadow-ghost, cursorgrabbing. - Placeholder (
.ph): dashed 1.5px--line-2border,--ph-fill, radius 8px, height = card height,flex: none. - Column over (
.over): background--accent-soft. - Count pill: updates on drop, keyboard move and add.
- Add button: dashed 1px
--line-2border, transparent; hover white fill. - Empty column: list keeps
min-height: 60pxso it remains a drop target.
Accessibility
- Columns are
<section>s with an<h2>each; the count is inside the heading so it is announced (“Backlog 4”). - Cards are
<article tabindex="0">; the whole card is the focus stop. Keyboard move:[previous column,]next column (prepends to the target list, keeps focus). Document the keys in the header hint. - A visually hidden
aria-live="polite"paragraph announces every move (“Split a payment between two accounts moved to Done”). - Pointer drag uses
setPointerCaptureso the drag survives the pointer leaving the card;pointercancelends the drag safely. - Label pills have ≥ 4.5:1 text contrast on their tints (
#B42318on#FDE8E66.3:1,#175CD3on#E3ECFB5.6:1,#8A3FFCon#EFE6FF4.9:1,#B54708on#FDEFD95.4:1). Dates in--ink-3on white are 3.9:1 — meta only; raise to--ink-2if they must pass AA. - Hit targets: cards ≥ 96px tall; buttons 34px (raise to 40px on touch layouts); avatars are decorative (initials repeat the assignee, add a
titlein production). - Icons are inline SVG on a 24 grid, 1.75px stroke,
currentColor.
Responsive rules
- ≥ 1280: four columns visible, no horizontal scroll.
- 1024–1279: board scrolls horizontally; columns stay 292px; snap to column starts. Header hint hidden below 1100.
- 768–1023: board padding 16px; header buttons collapse to icons.
- < 640: columns 84vw wide, one column per snap; card labels wrap; keyboard hint hidden. Drag still works with touch because
touch-action: noneis set on cards.
Acceptance checklist
- Columns are 292px wide with a 16px gap and 28px board padding; header is 60px.
- Each column heading shows a coloured 8px dot and a count pill that equals the number of cards in that column at all times.
- Drag starts only after the pointer moves ≥ 4px from pointerdown; a plain click does not lift the card.
- While dragging, a ghost clone (rotated 3°, scaled 1.02, teal border, deep shadow) follows the pointer at the original grab offset.
- The source card leaves the flow and a dashed placeholder of identical height marks the drop slot; the placeholder moves as the pointer crosses card midpoints.
- The column under the pointer tints
#D7EFEC; the tint clears on drop or cancel. - Dropping places the card where the placeholder was, updates both counts, focuses the card and announces the move in a live region.
-
pointercancelends the drag without leaving a ghost or placeholder behind. - Focused cards move with
[and]; moves are announced; focus is retained. - “Add card” appends a focused “Untitled card” with a new FB-4xx id and increments the count.
- Below 1272px the board scrolls horizontally with
scroll-snap-type: x proximity; lists scroll vertically independently. - Cards use
user-select: noneandtouch-action: noneso text selection and page panning do not fight the drag. - No HTML5
draggableattribute is used; behaviour is identical with mouse, pen and touch.
Implementation notes
Threshold, ghost and placeholder in one pointermove handler. Capture the pointer on down, but do nothing visible until 4px of travel:
board.addEventListener('pointerdown', e => {
const card = e.target.closest('.card'); if (!card || e.button !== 0) return;
drag = { card, x0: e.clientX, y0: e.clientY, r: card.getBoundingClientRect(), on: false };
card.setPointerCapture(e.pointerId);
});
board.addEventListener('pointermove', e => {
if (!drag) return; const dx = e.clientX - drag.x0, dy = e.clientY - drag.y0;
if (!drag.on) { if (Math.hypot(dx, dy) < 4) return; drag.on = true;
drag.ghost = drag.card.cloneNode(true); drag.ghost.classList.add('ghost');
drag.ghost.style.height = drag.r.height + 'px'; document.body.appendChild(drag.ghost);
drag.ph = Object.assign(document.createElement('div'), { className: 'ph' });
drag.ph.style.height = drag.r.height + 'px'; drag.card.after(drag.ph); drag.card.classList.add('lift'); }
drag.ghost.style.left = drag.r.left + dx + 'px'; drag.ghost.style.top = drag.r.top + dy + 'px';
const list = document.elementFromPoint(e.clientX, e.clientY)?.closest('.list'); if (!list) return;
const next = [...list.querySelectorAll('.card:not(.lift)')]
.find(c => { const r = c.getBoundingClientRect(); return e.clientY < r.top + r.height / 2; });
next ? list.insertBefore(drag.ph, next) : list.appendChild(drag.ph);
});
Ghost styling — fixed, non-interactive, and sized like a column card so elementFromPoint never hits it:
.card.ghost { position: fixed; z-index: 10; pointer-events: none; width: calc(var(--col-w) - 20px);
transform: rotate(3deg) scale(1.02); box-shadow: var(--shadow-ghost);
border-color: var(--accent); transition: none; }
.ph { border: 1.5px dashed var(--line-2); border-radius: 8px; background: var(--ph-fill); flex: none; }
Drop and announce — replace the placeholder, recount, then speak:
function end() {
if (!drag) return; const d = drag; drag = null; if (!d.on) return;
d.ph.replaceWith(d.card); d.card.classList.remove('lift'); d.ghost.remove();
document.querySelectorAll('.col.over').forEach(c => c.classList.remove('over'));
counts(); d.card.focus();
live.textContent = `${d.card.querySelector('p').textContent} moved to ${d.card.closest('.list').dataset.col}`;
}
board.addEventListener('pointerup', end); board.addEventListener('pointercancel', end);
Common mistakes: forgetting pointer-events: none on the ghost (every elementFromPoint then returns the ghost); leaving the source card visible at opacity 0 (it keeps its slot, so you get two gaps); using overflow-y: auto on the board instead of on each list (the ghost then scrolls the board); omitting touch-action: none (touch drags pan the page instead).
Details
- Palette
- Type
- Schibsted Grotesk
- Motion
- Subtle motion
- Build
- Multi-step
- Tags
On the shelves