Scrumboard
A real Kanban / Scrum board built on @dnd-kit — drag cards across columns, quick-add, full task modal, filters, and localStorage persistence.
Overview
The Scrumboard is a horizontally-scrolling board of status columns (Backlog / To Do / In Progress /
Review / Done) holding draggable task cards. It is not a static demo: cards can be dragged across
columns and reordered within a column, quick-added via an inline composer, edited in a full modal,
duplicated, moved via a card menu, and deleted — and the whole working board persists to
localStorage so it survives reloads.
Drag-and-drop is powered by @dnd-kit (@dnd-kit/core + @dnd-kit/sortable
@dnd-kit/utilities), with both pointer and keyboard sensors. The page is full-bleed (see Layout System — full-bleed routes): it renders its own toolbar instead of aPageHeaderand sizes itself to the viewport minus the app header and (measured) footer.
All board state, drag handlers, and task CRUD live in the useBoard view-model hook; the page
component (ScrumboardPage) is layout + wiring only, and owns just the search/filter state (which
both the toolbar and the board consume). Column titles and priority labels are i18n keys resolved
at render (the scrumboard namespace); task titles / descriptions / labels stay literal demo content.
Architecture & files
| File | Responsibility |
|---|---|
src/pages/apps/ScrumboardPage.tsx | Full-bleed page. Sets up the dnd sensors + DndContext, owns search / priority / assignee filter state, and wires the toolbar, columns, drag overlay, and task modal to useBoard. |
src/components/scrumboard/useBoard.ts | The view-model: board state, persistence effect, the sequential task-id generator, drag handlers (onDragStart/onDragOver/onDragEnd), and task CRUD (quickAddTask/moveTask/deleteTask/duplicateTask/saveTask), plus edit-modal state. |
src/components/scrumboard/ScrumboardToolbar.tsx | The full-bleed toolbar that replaces PageHeader: title + breadcrumb, member avatars, search, a priority/assignee filter Popover, a Reset button (only while filtering), and the Add button. |
src/components/scrumboard/BoardColumn.tsx | One column: droppable region (useDroppable), a SortableContext over its tasks, the inline quick-add composer, and an illustrated empty state (magnifier when filtered → no matches, plus badge when genuinely empty). |
src/components/scrumboard/TaskCard.tsx | One sortable card (useSortable): title, description, labels, priority flag, due date, attachment/comment counts, assignee avatars, and a portaled three-dot Dropdown (edit / duplicate / move-to / delete). Also renders the lifted drag-overlay preview. |
src/components/scrumboard/TaskModal.tsx | Create / edit dialog on the Modal primitive: title, description, column, priority, due date, a tag-style label editor, and an assignee picker. |
src/data/scrumboard.ts | Types (Task, Column, BoardData, BoardMember, Priority, …), the seed board + members, persistence (STORAGE_KEY, loadBoard/saveBoard/clearBoard, initialBoard), maxTaskSeq, and formatDueDate. |
Data model
export type Priority = 'high' | 'medium' | 'low'
export type CardAccent = 'red' | 'yellow' | 'orange' | 'blue' | 'purple'
export type ColumnAccent = 'muted' | 'red' | 'yellow' | 'purple' | 'green'
export interface Task {
id: string
title: string
description?: string
priority: Priority
accent?: CardAccent // left-border color
dueDate?: string // ISO YYYY-MM-DD
labels?: string[]
attachments?: number
comments?: number
assigneeIds: string[]
}
export interface Column {
id: string
titleKey: string // i18n key, e.g. 'scrumboard:colTodo'
accent: ColumnAccent
taskIds: string[] // ordering lives here
}
export interface BoardData {
columns: Column[]
tasks: Record<string, Task> // tasks keyed by id
members: BoardMember[]
}Ordering is a pure array operation. Both column order and per-column task order live in the
taskIds arrays, so dnd-kit reorders (via arrayMove) and cross-column moves are just array splices —
the tasks map is a flat lookup.
Persistence
The working board is saved on every change by an effect in useBoard (saveBoard(board)), and
loaded on mount via loadBoard() (falling back to a fresh initialBoard() seed if absent or corrupt).
export const STORAGE_KEY = 'scrumboard-state-v1'STORAGE_KEY is registered in src/lib/appStorage.ts so "Reset to defaults" wipes it (see
Architecture & Routing). New task ids continue
the t<n> sequence past the persisted maximum (maxTaskSeq), so ids never collide after a reload.
Usage
The board is already wired into the app — navigate to /apps/scrumboard (Applications → Scrum
Board in the sidebar). To render it yourself, the page is self-contained:
import {ScrumboardPage} from '@/pages/apps/ScrumboardPage'
;<ScrumboardPage />The route is registered full-bleed in src/routes.tsx:
{path: '/apps/scrumboard', element: <ScrumboardPage />, fullBleed: true},To reuse just the state logic (e.g. in a custom board layout), call the hook:
import {useBoard} from '@/components/scrumboard/useBoard'
function MyBoard() {
const {board, quickAddTask, moveTask, deleteTask} = useBoard()
// board.columns / board.tasks / board.members are ready to render
}API / Props
useBoard()
Returns the board view-model:
| Key | Type | Description |
|---|---|---|
board | BoardData | The live working board (columns, tasks map, members). |
activeTask | Task | null | The card currently being dragged (for the DragOverlay). |
editing | {taskId; columnId; isNew} | null | Edit-modal state (null when closed). |
editingDraft | Task | null | The task to seed the modal with (blank draft when isNew). |
modalColumns | {id; label}[] | Columns with translated labels, for the modal's column select. |
setEditing | (v) => void | Open/close the modal directly. |
moveTargetsFor | (columnId) => MoveTarget[] | The other columns a card can be moved to (card menu). |
onDragStart / onDragOver / onDragEnd | dnd-kit handlers | Wired into DndContext. |
quickAddTask | (columnId, title, description?) => void | Append a card (defaults: medium priority, due today, first member assigned). |
moveTask | (taskId, toColumnId) => void | Move a card to the top of another column. |
deleteTask / duplicateTask | (taskId) => void | Delete / duplicate a card. |
openEdit | (taskId) => void | Open the modal on an existing card. |
openTop | () => void | Open the modal to create a new card in the first column. |
saveTask | (task, columnId) => void | Persist a create/edit from the modal. |
Every mutator fires a success toast (taskAdded / taskMoved / taskDeleted / taskDuplicated /
taskUpdated).
TaskCardProps (TaskCard.tsx)
| Prop | Type | Description |
|---|---|---|
task | Task | The card data. |
members | BoardMember[] | For resolving assignee avatars. |
moveTargets | MoveTarget[] | Columns for the quick-move submenu (excludes its own). |
onOpen / onEdit / onDuplicate / onDelete | () => void | Card actions. |
onMove | (toColumnId) => void | Move-to action. |
overlay? | boolean | When true, renders the lifted drag-overlay preview (no sortable wiring). |
BoardColumnProps (BoardColumn.tsx)
column, tasks (already filtered), members, filtered (drives the empty-state variant),
moveTargetsFor, and the on*Task / onQuickAdd callbacks.
TaskModalProps (TaskModal.tsx)
open, isNew, task, columnId, columns: {id; label}[], members, onClose,
onSave(task, columnId).
Sensors
const sensors = useSensors(
// Small activation distance so a click (open modal / menu) never starts a drag.
useSensor(PointerSensor, {activationConstraint: {distance: 5}}),
// Space picks up/drops a card; Enter stays free to open the detail modal on a focused card.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
keyboardCodes: {start: ['Space'], cancel: ['Escape'], end: ['Space']},
}),
)Configuration & customization
Change / add / rename columns
Columns are defined in src/data/scrumboard.ts. To add a column, add a Column entry (unique id, a
titleKey in the scrumboard namespace, an accent, and its initial taskIds) and add the label to
the locale files:
// src/data/scrumboard.ts
const columns: Column[] = [
{id: 'backlog', titleKey: 'scrumboard:colBacklog', accent: 'muted', taskIds: ['t3', 't8', 't9']},
// add a new column:
{id: 'blocked', titleKey: 'scrumboard:colBlocked', accent: 'red', taskIds: []},
// …
]// src/locales/en/scrumboard.json
{"colBlocked": "Blocked"}ColumnAccent (muted | red | yellow | purple | green) maps to the header status-dot color and the
count badge tone in BoardColumn.tsx (dotColor / badgeTone). The move-to menu, column select in
the modal, and per-column empty states all derive from columns automatically.
Existing browsers
The persisted board (scrumboard-state-v1) freezes the columns at first run. To see new/renamed
columns during development, clear that key (or run "Reset to defaults").
Card accents & priorities
CardAccent (red | orange | yellow | blue | purple) sets the card's left-border color via literal
token classes in TaskCard.tsx (accentBorder). Priority drives the flag color (priorityColor)
and label key (priorityKey). Both use semantic tokens (border-l-danger, text-warning, …), so
they re-skin and dark-mode automatically.
Members
Edit the members array in src/data/scrumboard.ts. Avatars are self-hosted absolute paths
(/avatars/avatar-N.png) — see avatars in Layout System. Members flow into
the toolbar avatar stack, the assignee filter, the modal assignee picker, and card avatars.
Due-date display
formatDueDate(value) formats an ISO YYYY-MM-DD value to MMM d and returns {label, danger} —
today and overdue dates are flagged danger (styled red on the card). Non-ISO values render as-is.
Examples
Programmatically seed and drive a board
import {DndContext, closestCorners} from '@dnd-kit/core'
import {useBoard} from '@/components/scrumboard/useBoard'
import {BoardColumn} from '@/components/scrumboard/BoardColumn'
function MiniBoard() {
const {board, onDragStart, onDragOver, onDragEnd, quickAddTask, moveTask, deleteTask, duplicateTask, openEdit, moveTargetsFor} =
useBoard()
return (
<DndContext collisionDetection={closestCorners} onDragStart={onDragStart} onDragOver={onDragOver} onDragEnd={onDragEnd}>
<div className="flex gap-4">
{board.columns.map((col) => (
<BoardColumn
key={col.id}
column={col}
tasks={col.taskIds.map((id) => board.tasks[id]).filter(Boolean)}
members={board.members}
filtered={false}
moveTargetsFor={moveTargetsFor}
onQuickAdd={quickAddTask}
onOpenTask={openEdit}
onEditTask={openEdit}
onDuplicateTask={duplicateTask}
onDeleteTask={deleteTask}
onMoveTask={moveTask}
/>
))}
</div>
</DndContext>
)
}Add a task from code
const {quickAddTask, board} = useBoard()
quickAddTask(board.columns[0].id, 'Write release notes', 'Summarize the 2.4 changes')
// → appended to the first column, medium priority, due today, first member assignedBest practices
- Keep logic in
useBoard. Follow the feature-view-model convention (seeCLAUDE.md): the page stays render-only, mutations and persistence stay in the hook. - Order via
taskIds, look up viatasks. Never scatter task objects into columns — the split keeps drag reordering a pure array operation and the tasks map a single source. - Use tokens for colors. Add or adjust an accent by editing the literal token maps, never by hardcoding hex or raw palette classes.
- Store keys, not copy. Column titles and priority labels are i18n keys; add the label to
en/scrumboard.json(and other locales) when you add a column. - Register new persisted keys. If you add another
localStoragekey, add it toAPP_LOCAL_KEYSinsrc/lib/appStorage.tsso Reset clears it.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| A click on a card starts a drag | The PointerSensor activation distance (5px) prevents this; if you rebuild the sensors, keep an activationConstraint. |
| Enter doesn't open the card modal | The KeyboardSensor is bound to Space (not Enter) for pick-up/drop, leaving Enter free. TaskCard composes the drag key handler with an Enter-to-open handler — don't override keyboardCodes. |
| Three-dot menu is clipped by the column's scroll area | The card Dropdown is portaled to escape the column's overflow. Keep portal on it. |
| Renamed a column but the board still shows the old title | The persisted board (scrumboard-state-v1) was seeded before the rename. Clear the key or "Reset to defaults". |
| New task ids collide after reload | The id generator seeds from maxTaskSeq(board) on load — don't hand-assign t<n> ids that skip the sequence. |
| Board overflows the viewport / footer overlaps | The page height is calc(100dvh - var(--app-header-height) - <footerH>px); footerH is measured only when config.fixedFooter. This is standard full-bleed sizing. |
FAQ
Can I add swimlanes or a per-column WIP limit? Not out of the box — the model is columns + a flat
task map. Both are straightforward extensions: a WIP limit is a number on Column checked in
quickAddTask/drag handlers; swimlanes need a grouping key on Task and a nested render.
Is this AG Grid or a table? No — it is a bespoke dnd-kit board. AG Grid is used only for the data grid pages (see Tables & Data Grid).
Does drag-and-drop work with the keyboard? Yes. Focus a card, press Space to pick it up, arrow to a position, Space to drop, Escape to cancel.
Where do the demo tasks come from? The seed in src/data/scrumboard.ts. Due dates are generated
relative to today (iso(offset)) so the demo always looks current.
How do I clear the board? clearBoard() removes the persisted key; "Reset to defaults" clears it
along with every other app key and reloads to the seed.
Notes for designers & content editors
- Column names & all board chrome (priority labels, button labels, empty-state text, toasts) are
i18n keys in
src/locales/<lng>/scrumboard.json. Edit copy there — never in the components. - Colors come from semantic tokens; changing a skin re-colors dots, badges, card accents, and the priority flags automatically. Don't hardcode hex.
- Task content (titles, descriptions, labels) is literal demo data in
src/data/scrumboard.ts— safe to replace wholesale for a preview. - Empty states are inline SVGs drawn with the raw token vars (
var(--surface),var(--border),var(--muted-foreground)), so they re-skin and dark-mode with the rest of the app.
Related
Was this page helpful?
