CollaborationKanban board
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callKanban board
A board where you and the agent move cards, with drag and drop. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add kanban-boardReact
npx designme add kanban-board --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Kanban board is a task board with columns such as To do, In progress and Done, where people drag cards between columns and the agent moves its own cards as it works. A card the agent is working on gets an accent edge, a progress bar and the thinking dots. It lets a team see the agent's work in the same place as everyone else's.
- When an agent picks up tasks from a shared backlog and the team should watch it progress
- When people and the agent work from one board and either can move a card
- When you need a simple drag and drop board with no extra library
- When new ideas should be added to a column and named on the spot
- Dragging a card shows a floating copy under the pointer and a placeholder where it will land, and the column under the pointer highlights.
- A dropped card gets a small pop, and
onMovereports its id, new column and position within that column. - Setting
progressfrom 0 to 1 marks a card as the agent's work in progress, with a bar that fills. - Cards that arrive later, or are moved by someone else, come in with an entrance animation.
- Each column's + button adds a card at the end with its title selected for typing; Enter saves and
onRenamereports it. - Cards can carry a coloured
tagin one of eight tones and anassigneeavatar, and each column header shows its count. - Under 640px wide, on a phone or in a narrow card, the board shows one column at a time, picked with tabs that count each column's cards (a column's
shortname labels its tab). The + beside the tabs adds to that column, and each card's menu moves it to another column. Control the column withcolumnandonColumnChange.
- Each column's + button has an
aria-label, "Add card" by default, and a tooltip. - The floating copy shown while dragging is
aria-hidden, so it is not read twice. - On a narrow board the column tabs are a group labelled "Column" with
aria-pressedon the one shown, and each card's menu button is labelled "Move card"; its menu lists the columns asmenuitemradios with the card's own column checked. - From the keyboard, Enter, Space or the down arrow opens a menu with the focus on its picked item, or its first (the up arrow, on its last); the arrow keys move through it and wrap, Home and End jump to the ends, Tab closes it and moves on, and closing it gives the focus back to its button.
Usage
Import it and drive it from your own state.
Import
import { KanbanBoard } from '@/components/designme/kanban-board/KanbanBoard';Example
The demo above as a React component, 63 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useEffect, useRef, useState } from 'react';
import { KanbanBoard, type KanbanBoardCard } from './KanbanBoard';
import { useScript, useTakeover } from '../lib/useScript';
/* ---------- Kanban board: the design agent works through its cards while you drag the rest around ---------- */
const COLUMNS = [{ key: 'todo', title: 'To do' }, { key: 'doing', title: 'In progress', short: 'Doing' }, { key: 'done', title: 'Done' }];
const AGENT = { initials: 'DA', color: 'var(--dm-av-b)' }, MAYA = { initials: 'MK', color: 'var(--dm-av-l)' };
const DESIGN = { label: 'Design', tone: 'blue' as const }, COPY = { label: 'Copy', tone: 'purple' as const };
const CARDS: KanbanBoardCard[] = [
{ id: '1', column: 'todo', title: 'Audit checkout for contrast', tag: DESIGN, assignee: AGENT },
{ id: '2', column: 'todo', title: 'Write release notes for pricing', tag: COPY, assignee: MAYA },
{ id: '3', column: 'todo', title: 'Rename layers in Hero', tag: DESIGN, assignee: AGENT },
{ id: '4', column: 'doing', title: 'Export tokens to CSS', tag: { label: 'Code', tone: 'green' }, assignee: AGENT, progress: .4 },
{ id: '5', column: 'doing', title: 'Review annual plan copy', tag: COPY, assignee: { initials: 'JO', color: 'var(--dm-av-m)' } },
{ id: '6', column: 'done', title: 'One CTA per pricing card', tag: DESIGN, assignee: MAYA }
];
export default function KanbanBoardDemo() {
const [cards, setCards] = useState(CARDS);
const latest = useRef(cards);
useEffect(() => { latest.current = cards; });
const put = (next: KanbanBoardCard[]) => { latest.current = next; setCards(next); };
const dragging = useRef(false);
// a narrow board shows one column: it follows the agent's card until the visitor takes over
const box = useRef<HTMLDivElement>(null), mine = useTakeover(box);
const [column, setColumn] = useState('doing');
const follow = (key: string) => { if (!mine()) setColumn(key); };
// every 600ms the agent's card fills a little; when it is done it moves to Done and the agent picks up its next card
useScript(async ({ wait }) => {
for (;;) {
await wait(600);
if (dragging.current) continue;
const list = latest.current, card = list.find(c => c.progress != null);
if (!card) continue;
const progress = Math.min(1, (card.progress ?? 0) + .08);
if (progress < 1) { put(list.map(c => c.id === card.id ? { ...c, progress } : c)); continue; }
put([...list.filter(c => c.id !== card.id), { ...card, column: 'done', progress: undefined }]);
follow('done');
const next = latest.current.find(c => c.column === 'todo' && c.assignee === AGENT);
if (next) wait(900).then(() => {
const now = latest.current.find(c => c.id === next.id); if (!now) return;
const rest = latest.current.filter(c => c.id !== next.id), at = rest.findIndex(c => c.column === 'doing');
rest.splice(at < 0 ? rest.length : at, 0, { ...now, column: 'doing', progress: 0 });
put(rest);
wait(700).then(() => follow('doing'));
});
}
});
return (
<div ref={box} style={{ width: '100%' }}>
<KanbanBoard columns={COLUMNS} cards={cards} column={column} onColumnChange={setColumn}
// a card dropped in Done stops being worked on
onCardsChange={next => put(next.map(c => c.column === 'done' && c.progress != null ? { ...c, progress: undefined } : c))}
onDraggingChange={d => { dragging.current = d; }}
newCard={key => ({ id: String(Date.now()), column: key, title: 'New card', tag: { label: 'Idea', tone: 'pink' }, assignee: MAYA })} />
</div>
);
}
Props
Everything KanbanBoard takes.
| Prop | Type | What it does |
|---|---|---|
columnsrequired | KanbanBoardColumn[] | |
cards | KanbanBoardCard[] | The cards, in order: a column shows its cards in the order they come here. Leave it out to let drags and the + buttons change them |
defaultCards | KanbanBoardCard[] | |
onCardsChange | (cards: KanbanBoardCard[]) => void | |
onMove | (id: string, column: string, index: number) => void | Called when a card is dropped, with the column and its place among that column's other cards |
onDraggingChange | (dragging: boolean) => void | Called with true when a card starts moving with the pointer and false when it lands, for pausing anything that would move cards meanwhile |
newCard | (column: string) => KanbanBoardCard | Makes the card the + button adds to a column. It goes to the end of the column with its title ready to type |
onRename | (id: string, title: string) => void | Called when a card's title is edited |
addLabel | string | The + buttons' tooltip |
column | string | The column a narrow board shows. A board under 640px wide shows one column at a time, picked with tabs that count each column's cards, and each card gets a menu to move it to another column. Defaults to the first column |
defaultColumn | string | |
onColumnChange | (column: string) => void | |
className | string |
Code
The full source, as the CLI installs it.
KanbanBoard.tsxReact · 260 lines
'use client';
import { useEffect, useRef, useState, type AnimationEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Avatar, Seg, Think, Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './kanban-board.css';
export type KanbanBoardTone = 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'yellow' | 'pink' | 'teal';
export interface KanbanBoardColumn {
key: string;
/** Like "In progress" */
title: string;
/** A shorter name for the tabs of a narrow board, like "Doing". Defaults to the title */
short?: string;
}
export interface KanbanBoardCard {
id: string;
/** The key of the column it sits in */
column: string;
title: string;
/** A small coloured label, like { label: 'Design', tone: 'blue' } */
tag?: { label: string; tone?: KanbanBoardTone };
/** Who has it, shown as a small avatar */
assignee?: { initials: string; color: string };
/** 0 to 1 while the agent works on it: the card gets an accent edge, a progress bar and the thinking mark */
progress?: number;
}
export interface KanbanBoardProps {
columns: KanbanBoardColumn[];
/** The cards, in order: a column shows its cards in the order they come here. Leave it out to let drags and the + buttons change them */
cards?: KanbanBoardCard[];
defaultCards?: KanbanBoardCard[];
onCardsChange?: (cards: KanbanBoardCard[]) => void;
/** Called when a card is dropped, with the column and its place among that column's other cards */
onMove?: (id: string, column: string, index: number) => void;
/** Called with true when a card starts moving with the pointer and false when it lands, for pausing anything that would move cards meanwhile */
onDraggingChange?: (dragging: boolean) => void;
/** Makes the card the + button adds to a column. It goes to the end of the column with its title ready to type */
newCard?: (column: string) => KanbanBoardCard;
/** Called when a card's title is edited */
onRename?: (id: string, title: string) => void;
/** The + buttons' tooltip */
addLabel?: string;
/** The column a narrow board shows. A board under 640px wide shows one column at a time, picked with tabs that count each
column's cards, and each card gets a menu to move it to another column. Defaults to the first column */
column?: string;
defaultColumn?: string;
onColumnChange?: (column: string) => void;
className?: string;
}
const TONE: Record<KanbanBoardTone, string> = { blue: 'dm-b', purple: 'dm-l', green: 'dm-m', orange: 'dm-p', red: 'dm-r', yellow: 'dm-y', pink: 'dm-k', teal: 'dm-t' };
const uid = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
/** What a card shows under its title; the card itself and the ghost under the pointer both use it */
function Face({ card, children }: { card: KanbanBoardCard; children?: ReactNode }) {
const working = card.progress != null;
return (
<>
{working && <div className="dm-kbx-pr"><i style={{ width: Math.max(0, Math.min(1, card.progress ?? 0)) * 100 + '%' }} /></div>}
<span className="dm-f">
{card.tag && <span className={cx('dm-tag', card.tag.tone && TONE[card.tag.tone])}>{card.tag.label}</span>}
{working && <Think />}
{card.assignee && <Avatar initials={card.assignee.initials} color={card.assignee.color} />}
{children}
</span>
</>
);
}
function Card({ card, entering: enters, dragging, editing, onDown, onDone, setRef, moveOpen, onMoveMenu }: {
card: KanbanBoardCard; entering: boolean; dragging: boolean; editing: boolean;
onDown: (e: ReactPointerEvent<HTMLDivElement>) => void; onDone: (title: string) => void; setRef: (el: HTMLDivElement | null) => void;
moveOpen: boolean; onMoveMenu: (button: HTMLButtonElement, key: boolean) => void;
}) {
// a card that arrives after the board appears comes in once, then drops the entrance so a drag can fade it
const [entering, setEntering] = useState(enters);
const title = useRef<HTMLSpanElement>(null);
useEffect(() => {
const t = title.current; if (!editing || !t) return;
t.focus(); document.getSelection()?.selectAllChildren(t);
}, [editing]);
return (
<div ref={setRef} className={cx('dm-kbx-k', card.progress != null && 'dm-agent', dragging && 'dm-drag', entering && 'dm-enter')} onPointerDown={onDown}
onAnimationEnd={(e: AnimationEvent) => { if (e.target === e.currentTarget && entering) setEntering(false); }}>
{/* keyed by the title, so an edit always starts from what was saved */}
<span key={card.title} ref={title} className="dm-t" contentEditable={editing} suppressContentEditableWarning
onBlur={editing ? e => onDone(e.currentTarget.textContent ?? '') : undefined}
onKeyDown={editing ? e => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } } : undefined}>{card.title}</span>
<Face card={card}>
<Tip label="Move to" off={moveOpen}>
<button type="button" className="dm-ib dm-kbx-mv" aria-label="Move card" aria-haspopup="menu" aria-expanded={moveOpen} onClick={e => onMoveMenu(e.currentTarget, e.detail === 0)}><Icon name="more" size="xs" /></button>
</Tip>
</Face>
</div>
);
}
interface Drag { id: string; w: number; h: number; x: number; y: number }
interface Spot { column: string; index: number }
/** A board where you and the agent move cards, with drag and drop. */
export function KanbanBoard({ columns, cards, defaultCards = [], onCardsChange, onMove, onDraggingChange, newCard, onRename, addLabel = 'Add card', column, defaultColumn, onColumnChange, className }: KanbanBoardProps) {
const [list, setList] = useControllable(cards, defaultCards, onCardsChange);
const latest = useRef(list);
useEffect(() => { latest.current = list; });
const [drag, setDrag] = useState<Drag | null>(null);
const [spot, setSpot] = useState<Spot | null>(null);
const [over, setOver] = useState<string | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [dropped, setDropped] = useState<{ id: string; n: number } | null>(null);
const cols = useRef(new Map<string, HTMLDivElement>());
const els = useRef(new Map<string, HTMLDivElement>());
const stop = useRef<() => void>(undefined);
useEffect(() => () => stop.current?.(), []);
// cards that are new, or moved by someone else, come in with the entrance; a dropped card gets its own little pop
const mounted = useRef(false);
const seen = useRef(new Map<string, string>());
useEffect(() => { mounted.current = true; seen.current = new Map(list.map(c => [c.id, c.column])); });
const landing = useRef<string | null>(null);
const enters = (c: KanbanBoardCard) => mounted.current && seen.current.get(c.id) !== c.column && landing.current !== c.id;
useIsoLayoutEffect(() => {
if (!dropped) return;
landing.current = null;
els.current.get(dropped.id)?.animate([{ transform: 'scale(1.04)' }, { transform: 'none' }], { duration: 350, easing: 'cubic-bezier(.2,.8,.2,1)' });
}, [dropped]);
/* ---------- a narrow board: one column at a time, and a menu on each card to move it ---------- */
const [shown, setShown] = useControllable(column, defaultColumn ?? columns[0]?.key ?? '', onColumnChange);
const bar = useRef<HTMLDivElement>(null);
useIsoLayoutEffect(() => {
// the column coming into view comes in, the way the site's does
const el = cols.current.get(shown); if (!el || !bar.current?.getClientRects().length) return;
el.classList.remove('dm-enter'); void el.offsetWidth; el.classList.add('dm-enter');
}, [shown]);
const moveMenu = usePopover({ align: 'end' });
const [moving, setMoving] = useState<string | null>(null);
// opened with Enter or Space, the menu takes the focus on the card's column
const openMove = (id: string, button: HTMLButtonElement, key = false) => {
const again = moveMenu.open && moving === id;
moveMenu.trigger.ref.current = button; setMoving(id);
if (again) moveMenu.setOpen(false); else if (key) moveMenu.openFocused(); else moveMenu.setOpen(true);
};
// picked with the keyboard, the focus stays on the column's tab once the card has gone to its new column
const board = useRef<HTMLDivElement>(null);
const moveTo = (to: string) => {
if (moveMenu.pop.ref.current?.contains(document.activeElement)) setTimeout(() => { const b = moveMenu.trigger.ref.current; if (!b || !b.isConnected || !b.getClientRects().length) board.current?.querySelector<HTMLElement>('.dm-kbx-tabs [aria-pressed="true"]')?.focus({ preventScroll: true }); }, 200);
moveMenu.closeSoon();
const card = latest.current.find(c => c.id === moving); if (!card || card.column === to) return;
const rest = latest.current.filter(c => c.id !== card.id), index = rest.filter(c => c.column === to).length;
setList([...rest, { ...card, column: to }]); onMove?.(card.id, to, index);
};
const down = (card: KanbanBoardCard) => (e: ReactPointerEvent<HTMLDivElement>) => {
if (e.button || editing === card.id || (e.target as Element).closest('.dm-kbx-mv')) return;
const rc = e.currentTarget.getBoundingClientRect(), sx = e.clientX, sy = e.clientY, ox = sx - rc.left, oy = sy - rc.top;
let moved = false, at: Spot | null = null;
const move = (ev: PointerEvent) => {
if (!moved && Math.hypot(ev.clientX - sx, ev.clientY - sy) < 4) return;
if (!moved) { moved = true; onDraggingChange?.(true); }
setDrag({ id: card.id, w: rc.width, h: rc.height, x: ev.clientX - ox, y: ev.clientY - oy });
const col = document.elementsFromPoint(ev.clientX, ev.clientY).find(x => [...cols.current.values()].includes(x as HTMLDivElement));
const key = col && [...cols.current.entries()].find(([, el]) => el === col)?.[0];
setOver(key ?? null);
if (!key || !col) return;
// the placeholder goes before the first card whose middle is below the pointer
const sibs = [...col.querySelectorAll<HTMLElement>('.dm-kbx-list > .dm-kbx-k:not(.dm-drag)')];
const after = sibs.findIndex(s => { const b = s.getBoundingClientRect(); return ev.clientY < b.top + b.height / 2; });
at = { column: key, index: after < 0 ? sibs.length : after };
setSpot(at);
};
const up = () => {
stop.current?.();
if (!moved) return;
if (at) {
const rest = latest.current.filter(c => c.id !== card.id), same = rest.filter(c => c.column === at!.column), before = same[at.index];
const moving = { ...latest.current.find(c => c.id === card.id)!, column: at.column };
const next = [...rest]; before ? next.splice(next.indexOf(before), 0, moving) : next.push(moving);
setList(next); onMove?.(card.id, at.column, at.index);
}
setDrag(null); setSpot(null); setOver(null);
landing.current = card.id;
setDropped(d => ({ id: card.id, n: (d?.n ?? 0) + 1 }));
onDraggingChange?.(false);
};
stop.current = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); stop.current = undefined; };
document.addEventListener('pointermove', move); document.addEventListener('pointerup', up);
};
const add = (column: string) => {
const card = newCard?.(column) ?? { id: uid(), column, title: 'New card', tag: { label: 'Idea', tone: 'pink' } };
setList([...latest.current, card]); setEditing(card.id);
};
const rename = (id: string, text: string) => {
const title = text.trim() || 'New card';
setEditing(null);
setList(latest.current.map(c => c.id === id ? { ...c, title } : c)); onRename?.(id, title);
};
const ghost = drag && list.find(c => c.id === drag.id);
return (
<div data-dm="" ref={board} className={cx('dm-kbx', className)}>
<div ref={bar} className="dm-kbx-bar">
<Seg className="dm-kbx-tabs" label="Column" value={shown} onChange={setShown}
options={columns.map(col => { const n = list.filter(c => c.column === col.key).length; return { value: col.key, label: <>{col.short ?? col.title}<span key={n} className="dm-n dm-num dm-enter">{n}</span></> }; })} />
<Tip label={addLabel}><button type="button" className="dm-ib dm-kbx-add" aria-label={addLabel} onClick={() => add(shown)}><Icon name="plus" size="xs" /></button></Tip>
</div>
<div className="dm-kbx-cols">
{columns.map(col => {
const here = list.filter(c => c.column === col.key);
const items: ReactNode[] = [];
const hole = <div key="dm-kbx-ph" className="dm-kbx-ph" style={{ height: drag?.h }} />;
let n = 0;
for (const c of here) {
if (c.id !== drag?.id) { if (spot?.column === col.key && spot.index === n) items.push(hole); n++; }
items.push(<Card key={c.id} card={c} entering={enters(c)} dragging={c.id === drag?.id} editing={editing === c.id} onDown={down(c)}
moveOpen={moveMenu.open && moving === c.id} onMoveMenu={(b, key) => openMove(c.id, b, key)}
onDone={t => rename(c.id, t)} setRef={el => { if (el) els.current.set(c.id, el); else els.current.delete(c.id); }} />);
}
if (spot?.column === col.key && spot.index >= n) items.push(hole);
return (
<div key={col.key} className={cx('dm-kbx-c', over === col.key && 'dm-over', shown === col.key && 'dm-on')} ref={el => { if (el) cols.current.set(col.key, el); else cols.current.delete(col.key); }}>
<div className="dm-kbx-ch">
<b>{col.title}</b>
<span className="dm-n dm-num">{here.length}</span>
<Tip label={addLabel}><button type="button" className="dm-ib" aria-label={addLabel} onClick={() => add(col.key)}><Icon name="plus" size="xs" /></button></Tip>
</div>
<div className="dm-kbx-list">{items}</div>
</div>
);
})}
</div>
<div {...moveMenu.pop} className={cx(moveMenu.pop.className, 'dm-kbx-mp')} role="menu" aria-label="Move to">
<div className="dm-ph">Move to</div>
{columns.map(col => {
const here = list.find(c => c.id === moving)?.column === col.key;
return (
<button key={col.key} type="button" className="dm-mi" role="menuitemradio" aria-checked={here} onClick={() => moveTo(col.key)}>
<span>{col.title}</span><span className="dm-ck"><Icon name="check" size="xs" /></span>
</button>
);
})}
</div>
{ghost && drag && typeof document !== 'undefined' && createPortal(
<div data-dm="" className={cx('dm-kbx-k dm-ghost', ghost.progress != null && 'dm-agent')} style={{ left: drag.x, top: drag.y, width: drag.w }} aria-hidden="true">
<span className="dm-t">{ghost.title}</span>
<Face card={ghost} />
</div>, document.body)}
</div>
);
}
kanban-board.cssReact · 47 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Kanban ---------- */
/* the board never scrolls: under 640px it shows one column at a time, so the three columns always fit */
.dm-kbx{width:100%;position:relative;container:kbx/inline-size}
.dm-kbx-cols{display:grid;grid-template-columns:repeat(3,minmax(200px,1fr));gap:var(--dm-s-3);min-width:640px}
/* each column has room for five cards: the four Done ends the demo with, and space for one more below them, so the board
keeps one height as the agent fills Done */
.dm-kbx-c{display:flex;flex-direction:column;gap:var(--dm-s-2);padding:10px;border-radius:var(--dm-r-card);background:color-mix(in srgb,var(--dm-fg) 3.5%,transparent);transition:background .25s,box-shadow .25s;min-height:432px}
.dm-kbx-c.dm-over{background:var(--dm-sel);box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--dm-accent-mark,var(--dm-accent)) 40%,transparent)}
.dm-kbx-ch{display:flex;align-items:center;gap:var(--dm-s-2);padding:2px var(--dm-s-1) var(--dm-s-1);font-size:13px}
.dm-kbx-ch b{font-weight:500}
.dm-kbx-ch .dm-n{font-size:12px;color:var(--dm-muted)}
.dm-kbx-ch .dm-ib{margin-left:auto;width:24px;height:24px}
.dm-kbx-list{display:flex;flex-direction:column;gap:var(--dm-s-2);flex:1}
.dm-kbx-k{padding:11px var(--dm-s-3);border-radius:13px;background:var(--dm-surface);box-shadow:var(--dm-shadow);display:flex;flex-direction:column;gap:var(--dm-s-2);cursor:grab;transition:box-shadow .25s,transform .3s var(--dm-ease),opacity .2s;touch-action:none;user-select:none}
@media (hover:hover) and (pointer:fine){.dm-kbx-k:hover{box-shadow:var(--dm-shadow-pop)}}
.dm-kbx-k.dm-drag{opacity:.35}
.dm-kbx-k.dm-ghost{position:fixed;z-index:100;pointer-events:none;transform:rotate(2.5deg) scale(1.03);box-shadow:var(--dm-shadow-pop);cursor:grabbing}
.dm-kbx-k .dm-t{font-size:13px;line-height:1.4}
.dm-kbx-k .dm-f{display:flex;align-items:center;gap:6px}
.dm-kbx-k .dm-f .dm-av{width:20px;height:20px;font-size:9px;margin-left:auto}
.dm-kbx-k .dm-f .dm-think{color:var(--dm-accent-fg,var(--dm-accent))}
.dm-kbx-k.dm-agent{box-shadow:0 0 0 1.5px var(--dm-accent-mark,var(--dm-accent)),var(--dm-shadow)}
.dm-kbx-ph{border-radius:13px;background:color-mix(in srgb,var(--dm-accent) 8%,transparent);border:1.5px dashed color-mix(in srgb,var(--dm-accent-mark,var(--dm-accent)) 50%,transparent)}
.dm-kbx-pr{height:3px;border-radius:2px;background:var(--dm-line);overflow:hidden}
.dm-kbx-pr i{display:block;height:100%;background:var(--dm-accent-mark,var(--dm-accent));width:0;transition:width .3s linear}
/* a narrow board, on a phone or in a narrow card: one column at a time, picked with tabs, and each card moves with its menu */
.dm-kbx-bar,.dm-kbx-mv{display:none}
@container kbx (max-width:639px){
.dm-kbx-bar{display:flex;align-items:center;gap:var(--dm-s-2);margin-bottom:var(--dm-s-2)}
.dm-kbx-tabs{flex:1;display:flex;min-width:0}
.dm-kbx-tabs button{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:6px;min-width:0;padding:0 var(--dm-s-2);white-space:nowrap}
.dm-kbx-tabs .dm-n{font-size:12px;color:var(--dm-muted)}
.dm-kbx-add{flex:none;width:30px;height:30px;color:var(--dm-muted)}
@media (hover:hover) and (pointer:fine){.dm-kbx-add:hover{color:var(--dm-fg)}}
.dm-kbx-cols{grid-template-columns:minmax(0,1fr);min-width:0}
.dm-kbx-c:not(.dm-on){display:none}
.dm-kbx-ch{display:none}
/* room for five cards, the four Done ends with and one more, so the board keeps one height as the agent fills Done and
you switch tabs */
.dm-kbx-c{min-height:396px}
.dm-kbx-mv{display:grid;width:24px;height:24px;margin:-4px -4px -4px 0;color:var(--dm-muted)}
.dm-kbx-mv[aria-expanded="true"]{color:var(--dm-fg)}
@media (hover:hover) and (pointer:fine){.dm-kbx-mv:hover{color:var(--dm-fg)}}
}
.dm-kbx-mp{min-width:176px}
kanban-board.htmlHTML · 8 lines
<div class="card wide" data-c="kanban">
<div class="stage-in">
<div class="kbx">
<div class="kbx-cols"></div>
</div>
</div>
</div>
kanban-board.cssHTML · 46 lines
/* ---------- Kanban ---------- */
/* the board never scrolls: under 640px it shows one column at a time, so the three columns always fit */
.kbx{width:100%;position:relative;container:kbx/inline-size}
.kbx-cols{display:grid;grid-template-columns:repeat(3,minmax(200px,1fr));gap:var(--s-3);min-width:640px}
/* each column has room for five cards: the four Done ends the demo with, and space for one more below them, so the board
keeps one height as the agent fills Done */
.kbx-c{display:flex;flex-direction:column;gap:var(--s-2);padding:10px;border-radius:var(--r-card);background:color-mix(in srgb,var(--fg) 3.5%,transparent);transition:background .25s,box-shadow .25s;min-height:432px}
.kbx-c.over{background:var(--sel);box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--accent-mark,var(--accent)) 40%,transparent)}
.kbx-ch{display:flex;align-items:center;gap:var(--s-2);padding:2px var(--s-1) var(--s-1);font-size:13px}
.kbx-ch b{font-weight:500}
.kbx-ch .n{font-size:12px;color:var(--muted)}
.kbx-ch .ib{margin-left:auto;width:24px;height:24px}
.kbx-list{display:flex;flex-direction:column;gap:var(--s-2);flex:1}
.kbx-k{padding:11px var(--s-3);border-radius:13px;background:var(--surface);box-shadow:var(--shadow);display:flex;flex-direction:column;gap:var(--s-2);cursor:grab;transition:box-shadow .25s,transform .3s var(--ease),opacity .2s;touch-action:none;user-select:none}
@media (hover:hover) and (pointer:fine){.kbx-k:hover{box-shadow:var(--shadow-pop)}}
.kbx-k.drag{opacity:.35}
.kbx-k.ghost{position:fixed;z-index:100;pointer-events:none;transform:rotate(2.5deg) scale(1.03);box-shadow:var(--shadow-pop);cursor:grabbing}
.kbx-k .t{font-size:13px;line-height:1.4}
.kbx-k .f{display:flex;align-items:center;gap:6px}
.kbx-k .f .av{width:20px;height:20px;font-size:9px;margin-left:auto}
.kbx-k .f .think{color:var(--accent-fg,var(--accent))}
.kbx-k.agent{box-shadow:0 0 0 1.5px var(--accent-mark,var(--accent)),var(--shadow)}
.kbx-ph{border-radius:13px;background:color-mix(in srgb,var(--accent) 8%,transparent);border:1.5px dashed color-mix(in srgb,var(--accent-mark,var(--accent)) 50%,transparent)}
.kbx-pr{height:3px;border-radius:2px;background:var(--line);overflow:hidden}
.kbx-pr i{display:block;height:100%;background:var(--accent-mark,var(--accent));width:0;transition:width .3s linear}
/* a narrow board, on a phone or in a narrow card: one column at a time, picked with tabs, and each card moves with its menu */
.kbx-bar,.kbx-mv{display:none}
@container kbx (max-width:639px){
.kbx-bar{display:flex;align-items:center;gap:var(--s-2);margin-bottom:var(--s-2)}
.kbx-tabs{flex:1;display:flex;min-width:0}
.kbx-tabs button{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:6px;min-width:0;padding:0 var(--s-2);white-space:nowrap}
.kbx-tabs .n{font-size:12px;color:var(--muted)}
.kbx-add{flex:none;width:30px;height:30px;color:var(--muted)}
@media (hover:hover) and (pointer:fine){.kbx-add:hover{color:var(--fg)}}
.kbx-cols{grid-template-columns:minmax(0,1fr);min-width:0}
.kbx-c:not(.on){display:none}
.kbx-ch{display:none}
/* room for five cards, the four Done ends with and one more, so the board keeps one height as the agent fills Done and
you switch tabs */
.kbx-c{min-height:396px}
.kbx-mv{display:grid;width:24px;height:24px;margin:-4px -4px -4px 0;color:var(--muted)}
.kbx-mv[aria-expanded="true"]{color:var(--fg)}
@media (hover:hover) and (pointer:fine){.kbx-mv:hover{color:var(--fg)}}
}
.kbx-mp{min-width:176px}
kanban-board.jsHTML · 69 lines
DM.def('kanban', (r, c) => {
// each column has a short name for the tabs a narrow board shows
const COLS = [['todo','To do','To do'],['doing','In progress','Doing'],['done','Done','Done']];
let K = [
{ id:1, c:'todo', t:'Audit checkout for contrast', tag:['b','Design'], who:['DA','var(--av-b)'] },
{ id:2, c:'todo', t:'Write release notes for pricing', tag:['l','Copy'], who:['MK','var(--av-l)'] },
{ id:3, c:'todo', t:'Rename layers in Hero', tag:['b','Design'], who:['DA','var(--av-b)'] },
{ id:4, c:'doing', t:'Export tokens to CSS', tag:['m','Code'], who:['DA','var(--av-b)'], run:.4 },
{ id:5, c:'doing', t:'Review annual plan copy', tag:['l','Copy'], who:['JO','var(--av-m)'] },
{ id:6, c:'done', t:'One CTA per pricing card', tag:['b','Design'], who:['MK','var(--av-l)'] }
];
// the agent's timer repaints the board, so it waits while a card is dragged, a new title is typed or a card's menu is open
const box = $('.kbx', r), wrap = $('.kbx-cols', r); let dragging = false, editing = false;
wrap.innerHTML = COLS.map(cl => `<div class="kbx-c" data-col="${cl[0]}"><div class="kbx-ch"><b>${cl[1]}</b><span class="n num"></span><button class="ib" aria-label="Add card" data-tip="Add card">${I('plus','xs')}</button></div><div class="kbx-list"></div></div>`).join('');
/* a narrow board shows one column at a time: tabs with each column's count pick it, + adds a card to it, and a card's
menu moves it to another column, since there is no other column on screen to drag it to */
wrap.insertAdjacentHTML('beforebegin', `<div class="kbx-bar"><div class="seg2 kbx-tabs" role="group" aria-label="Column">${COLS.map(cl => `<button aria-pressed="false" data-v="${cl[0]}">${cl[2]}<span class="n num"></span></button>`).join('')}</div><button class="ib kbx-add" aria-label="Add card" data-tip="Add card">${I('plus','xs')}</button></div>`);
box.insertAdjacentHTML('beforeend', `<div class="pop kbx-mp" role="menu" aria-label="Move to"><div class="ph">Move to</div>${COLS.map(cl => `<button class="mi" role="menuitemradio" aria-checked="false" data-close data-to="${cl[0]}"><span>${cl[1]}</span><span class="ck">${I('check','xs')}</span></button>`).join('')}</div>`);
const tabs = $('.kbx-tabs', r), menu = $('.kbx-mp', r); let cur = 'doing', moving = null;
const segc = seg(tabs, v => show(v));
const narrow = () => !!$('.kbx-bar', r).getClientRects().length;
function show(col){
const was = cur; cur = col; $$('button', tabs).forEach(b => b.setAttribute('aria-pressed', b.dataset.v === col)); segc.place();
$$('.kbx-c', wrap).forEach(x => x.classList.toggle('on', x.dataset.col === col));
if (was !== col && narrow()) enter($('.kbx-c.on', wrap));
}
// what the person in the demo would do to follow the agent's card; once you take over, the board stays where you put it
const follow = col => c.auto(0, () => show(col));
const card = k => `<div class="kbx-k${k.run != null ? ' agent' : ''}" data-id="${k.id}"><span class="t">${k.t}</span>${k.run != null ? `<div class="kbx-pr"><i style="width:${k.run * 100}%"></i></div>` : ''}<span class="f"><span class="tag ${k.tag[0]}">${k.tag[1]}</span>${k.run != null ? '<span class="think on"><i></i><i></i><i></i><i></i></span>' : ''}${AV(k.who[0], k.who[1])}<button class="ib kbx-mv" aria-label="Move card" data-tip="Move to" data-pop=".kbx-mp" data-pop-align="end" aria-haspopup="menu">${I('more','xs')}</button></span></div>`;
let painted = false;
function paint(){ COLS.forEach(cl => { const col = $(`.kbx-c[data-col="${cl[0]}"]`, wrap), L = K.filter(k => k.c === cl[0]); $('.kbx-list', col).innerHTML = L.map(card).join(''); $('.n', col).textContent = L.length;
const n = $(`[data-v="${cl[0]}"] .n`, tabs); if (n.textContent !== String(L.length)){ n.textContent = L.length; if (painted) enter(n); } }); painted = true; bind(); }
function bind(){
$$('.kbx-k', wrap).forEach(el => el.onpointerdown = e => {
if (e.button || e.target.closest('.kbx-mv')) return; const k = K.find(x => x.id === +el.dataset.id), rc = el.getBoundingClientRect(), ox = e.clientX - rc.left, oy = e.clientY - rc.top;
let ghost = null, ph = null, moved = false;
const move = ev => {
if (!moved && Math.hypot(ev.clientX - e.clientX, ev.clientY - e.clientY) < 4) return;
if (!moved){ moved = true; dragging = true; ghost = el.cloneNode(true); ghost.classList.add('ghost'); ghost.style.width = rc.width + 'px'; document.body.appendChild(ghost); el.classList.add('drag'); ph = html(`<div class="kbx-ph" style="height:${rc.height}px"></div>`); }
ghost.style.left = ev.clientX - ox + 'px'; ghost.style.top = ev.clientY - oy + 'px';
const col = document.elementsFromPoint(ev.clientX, ev.clientY).find(x => x.classList?.contains('kbx-c') && wrap.contains(x));
$$('.kbx-c', wrap).forEach(x => x.classList.toggle('over', x === col));
if (col){ const list = $('.kbx-list', col), sibs = $$('.kbx-k:not(.drag)', list); const after = sibs.find(s => { const b = s.getBoundingClientRect(); return ev.clientY < b.top + b.height / 2; }); after ? list.insertBefore(ph, after) : list.appendChild(ph); }
};
const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up);
dragging = false; if (!moved) return; ghost.remove(); const col = ph.closest('.kbx-c');
if (col){ const list = $('.kbx-list', col); const idx = [...list.children].filter(x => !x.classList.contains('drag')).indexOf(ph); K = K.filter(x => x !== k); k.c = col.dataset.col; if (k.c === 'done') delete k.run;
const same = K.filter(x => x.c === k.c); const before = same[idx]; before ? K.splice(K.indexOf(before), 0, k) : K.push(k); }
$$('.kbx-c', wrap).forEach(x => x.classList.remove('over')); paint(); const n = $(`.kbx-k[data-id="${k.id}"]`, wrap); n.animate([{ transform:'scale(1.04)' }, { transform:'none' }], { duration:350, easing:'cubic-bezier(.2,.8,.2,1)' }); };
document.addEventListener('pointermove', move); document.addEventListener('pointerup', up);
c.offs.push(() => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); ghost?.remove(); });
});
// the menu says where the card is now; picking another column sends it to the end of that one
$$('.kbx-mv', wrap).forEach(b => b.onclick = () => { moving = K.find(x => x.id === +b.closest('.kbx-k').dataset.id); $$('.mi', menu).forEach(m => m.setAttribute('aria-checked', !!moving && m.dataset.to === moving.c)); });
}
// picked with the keyboard, the focus stays on the column's tab once the card has gone to its new column
$$('.mi', menu).forEach(m => m.onclick = () => { const k = moving, kb = menu.contains(document.activeElement); moving = null; if (!k || !K.includes(k) || k.c === m.dataset.to) return;
const el = $(`.kbx-k[data-id="${k.id}"]`, wrap), go = () => { K = K.filter(x => x !== k); k.c = m.dataset.to; if (k.c === 'done') delete k.run; K.push(k); paint(); const t = kb && $(`[data-v="${cur}"]`, tabs); if (t) t.focus({ preventScroll:true }); };
if (REDUCE || !el) return go();
el.animate([{ opacity:1, transform:'none' }, { opacity:0, transform:'translateX(12px)' }], { duration:200, easing:'ease-in', fill:'forwards' }).onfinish = go; });
const add = col => { const k = { id:Date.now(), c:col, t:'New card', tag:['k','Idea'], who:['MK','var(--av-l)'] }; K.push(k); paint(); const el = $(`.kbx-k[data-id="${k.id}"]`, wrap); enter(el); const t = $('.t', el); editing = true; t.contentEditable = 'true'; t.focus({ preventScroll:true }); document.getSelection().selectAllChildren(t); t.onblur = () => { editing = false; t.contentEditable = 'false'; k.t = t.textContent.trim() || 'New card'; }; t.onkeydown = e => { if (e.key === 'Enter'){ e.preventDefault(); t.blur(); } }; };
$$('.kbx-ch .ib', wrap).forEach(b => b.onclick = () => add(b.closest('.kbx-c').dataset.col));
$('.kbx-add', r).onclick = () => add(cur);
paint(); show(cur);
c.every(600, () => { if (dragging || editing || menu.classList.contains('open')) return; const k = K.find(x => x.run != null); if (!k) return; k.run = Math.min(1, k.run + .08); const el = $(`.kbx-k[data-id="${k.id}"] .kbx-pr i`, wrap); if (el) el.style.width = k.run * 100 + '%';
if (k.run >= 1){ K = K.filter(x => x !== k); k.c = 'done'; delete k.run; K.push(k); follow('done'); paint(); const n = $(`.kbx-k[data-id="${k.id}"]`, wrap); enter(n);
const nx = K.find(x => x.c === 'todo' && x.who[0] === 'DA'); if (nx){ c.later(900, function promote(){ if (dragging || editing || menu.classList.contains('open')){ c.later(300, promote); return; } K = K.filter(x => x !== nx); nx.c = 'doing'; nx.run = 0; K.splice(K.findIndex(x => x.c === 'doing'), 0, nx); paint(); enter($(`.kbx-k[data-id="${nx.id}"]`, wrap)); c.later(700, () => follow('doing')); }); } } });
});
Questions
What people ask about it.
- How do I show the agent working through its cards?
- Control the board with
cardsand raise a card'sprogresstoward 1 as the agent works. When it finishes, move the card to your done column and clearprogress, as the example does. - How do I stop the agent from moving cards while someone is dragging?
- Use
onDraggingChange. It is called with true when a card starts moving with the pointer and false when it lands, so you can pause your updates in between. - Can cards be moved with the keyboard?
- Dragging uses pointer events only. On a narrow board each card also has a Move card button that opens a Move to menu of the columns. You can always move cards from code by changing a card's
columnincards. - What does a new card look like?
- By default it is titled "New card" with a pink Idea tag. Pass
newCard(column)to return your own card, such as one already assigned to the current user.
More in Collaboration
Threads, inboxes and boards for teams working alongside an agent.