Skip to content
Star on GitHub
CollaborationKanban board
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Kanban 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.

$npx designme add kanban-board
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add kanban-board

React

$npx designme add kanban-board --react

The 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.

02

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
03

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>
  );
}
04

Props

Everything KanbanBoard takes.

PropTypeWhat it does
columnsrequiredKanbanBoardColumn[]
cardsKanbanBoardCard[]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
defaultCardsKanbanBoardCard[]
onCardsChange(cards: KanbanBoardCard[]) => void
onMove(id: string, column: string, index: number) => voidCalled when a card is dropped, with the column and its place among that column's other cards
onDraggingChange(dragging: boolean) => voidCalled 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) => KanbanBoardCardMakes 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) => voidCalled when a card's title is edited
addLabelstringThe + buttons' tooltip
columnstringThe 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
defaultColumnstring
onColumnChange(column: string) => void
classNamestring
05

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')); }); } } });
});
06

Questions

What people ask about it.

How do I show the agent working through its cards?
Control the board with cards and raise a card's progress toward 1 as the agent works. When it finishes, move the card to your done column and clear progress, 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 column in cards.
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.
07

More in Collaboration

Threads, inboxes and boards for teams working alongside an agent.

CommentsA design thread where you can @mention the agent and it replies.
NotificationsAn inbox where agent updates and mentions arrive live.
BannerAn announcement on a tint of its tone, with its own line drawing.
Invite to collaborateAdd teammates by email as chips, set their role, send.
ReactionsQuick reactions on an agent update, with counts that pop.
Profile menuThe account menu behind an avatar: switch workspace, theme, shortcuts and sign out.
Context switcherThe organisation, project and environment you are in, each part a searchable switcher.
Filter menuAdd filters from a menu, see them as chips, and watch the count change.
All 10 in Collaboration →