PVR Tech Studio

Scrumboard

A real Kanban / Scrum board built on @dnd-kit — drag cards across columns, quick-add, full task modal, filters, and localStorage persistence.

8 min read
Updated July 15, 2026

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 a PageHeader and 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

FileResponsibility
src/pages/apps/ScrumboardPage.tsxFull-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.tsThe 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.tsxThe 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.tsxOne 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.tsxOne 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.tsxCreate / edit dialog on the Modal primitive: title, description, column, priority, due date, a tag-style label editor, and an assignee picker.
src/data/scrumboard.tsTypes (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:

KeyTypeDescription
boardBoardDataThe live working board (columns, tasks map, members).
activeTaskTask | nullThe card currently being dragged (for the DragOverlay).
editing{taskId; columnId; isNew} | nullEdit-modal state (null when closed).
editingDraftTask | nullThe 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) => voidOpen/close the modal directly.
moveTargetsFor(columnId) => MoveTarget[]The other columns a card can be moved to (card menu).
onDragStart / onDragOver / onDragEnddnd-kit handlersWired into DndContext.
quickAddTask(columnId, title, description?) => voidAppend a card (defaults: medium priority, due today, first member assigned).
moveTask(taskId, toColumnId) => voidMove a card to the top of another column.
deleteTask / duplicateTask(taskId) => voidDelete / duplicate a card.
openEdit(taskId) => voidOpen the modal on an existing card.
openTop() => voidOpen the modal to create a new card in the first column.
saveTask(task, columnId) => voidPersist a create/edit from the modal.

Every mutator fires a success toast (taskAdded / taskMoved / taskDeleted / taskDuplicated / taskUpdated).

TaskCardProps (TaskCard.tsx)

PropTypeDescription
taskTaskThe card data.
membersBoardMember[]For resolving assignee avatars.
moveTargetsMoveTarget[]Columns for the quick-move submenu (excludes its own).
onOpen / onEdit / onDuplicate / onDelete() => voidCard actions.
onMove(toColumnId) => voidMove-to action.
overlay?booleanWhen 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.

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 assigned

Best practices

  • Keep logic in useBoard. Follow the feature-view-model convention (see CLAUDE.md): the page stays render-only, mutations and persistence stay in the hook.
  • Order via taskIds, look up via tasks. 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 localStorage key, add it to APP_LOCAL_KEYS in src/lib/appStorage.ts so Reset clears it.

Troubleshooting

SymptomCause / fix
A click on a card starts a dragThe PointerSensor activation distance (5px) prevents this; if you rebuild the sensors, keep an activationConstraint.
Enter doesn't open the card modalThe 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 areaThe card Dropdown is portaled to escape the column's overflow. Keep portal on it.
Renamed a column but the board still shows the old titleThe persisted board (scrumboard-state-v1) was seeded before the rename. Clear the key or "Reset to defaults".
New task ids collide after reloadThe id generator seeds from maxTaskSeq(board) on load — don't hand-assign t<n> ids that skip the sequence.
Board overflows the viewport / footer overlapsThe 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.

Was this page helpful?