Task board
StableA column per status, with cards moved by dragging, by keys on their grip or from a menu, and every move announced.
TaskBoard lays tasks out in six status columns and moves cards three ways: a mouse or pen drag with a drop line, the grip handle with Space and the arrows, or the “…” menu. It commits moves through the shared task store (actions.move), so each one gets a toast with Undo. TaskCard is the card on its own, with idle, selected, lifted and dragging states.
Updated View source
On the shared store
import { useState } from 'react'import type { StatusId } from '@rchursin/product/data/sample'import { CreateTaskDialog, DEFAULT_PREFS, labelIndex, TaskBoard, useTaskActions, useTaskState } from '@rchursin/product/tasks'import { useToast } from '@rchursin/product/ui'
/** * The board on the shared task store (the same saved tasks as the Tasks app), so every move * gets a toast with Undo. Drag a card, use its grip with Space and the arrows, or its “…” menu. */export default function TaskBoardExample() { const { tasks, labels } = useTaskState() const actions = useTaskActions() const toast = useToast() const [active, setActive] = useState<string | null>(null) const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set()) const [createIn, setCreateIn] = useState<StatusId | null>(null) const now = new Date()
function toggle(id: string) { const next = new Set(selected)Usage
import { DEFAULT_PREFS, TaskBoard, TaskCard, useTaskActions, useTaskState } from '@rchursin/product/tasks'const { tasks } = useTaskState()const actions = useTaskActions()
<TaskBoard tasks={tasks} actions={actions} now={new Date()} labelName={labelName} props={DEFAULT_PREFS.props} selected={selected} activeId={active} onActiveChange={setActive} onOpen={openTask} onSelect={toggleSelected} onCreateIn={(status) => openCreate({ status })} onDelete={(id) => actions.remove([id])}/>Examples
Card states
Idle (live: click it, or move it from its menu), selected, lifted by keyboard, and the copy that follows the pointer while dragging.
import { useState } from 'react'import { sampleTasks, statuses, type Task } from '@rchursin/product/data/sample'import { DEFAULT_PREFS, labelIndex, TaskCard } from '@rchursin/product/tasks'import { useToast } from '@rchursin/product/ui'
/** A fixed day, so the due dates read the same on every visit */const NOW = new Date(2026, 9, 13, 9, 30)const labels = labelIndex([])
/** * One card in four states. The first is live: click it to open, or move it with its “…” menu. * The others are pictures of what the board draws: `still` cards have no controls, and the * `preview` copy that follows the pointer is hidden from screen readers. */export default function TaskCardStates() { const toast = useToast() const [tasks, setTasks] = useState(() => sampleTasks(NOW).filter((t) => ['RC-128', 'RC-122', 'RC-125', 'RC-118'].includes(t.id))) const [live, selected, lifted, preview] = tasks as [Task, Task, Task, Task]
const moveLive = statusesAPI
TaskBoard props
| Prop | Type | Default | Description |
|---|---|---|---|
tasksrequired | Task[] | – | The tasks to show, already filtered. There is a column for every status, in rank order. |
actionsrequired | TaskActions | – | From useTaskActions(). Moves are committed with actions.move, so the board works on the shared task store. |
nowrequired | Date | – | Due dates on the cards are relative to it. |
labelNamerequired | (id: string) => string | – | Turns a label id into its name. |
propsrequired | Record<PropKey, boolean> | – | Which properties the cards show. |
selectedrequired | ReadonlySet<string> | – | Selected task ids; selected cards say “Selected”. |
activeIdrequired | string | null | – | The card holding the tab stop. null falls back to the first card. |
onActiveChangerequired | (id: string) => void | – | Focus moved to another card. |
onOpenrequired | (id: string) => void | – | Enter or a click on a card. |
onSelectrequired | (id: string, how: 'toggle' | 'range') => void | – | Shift+Space, ⌘/Ctrl-click (toggle) or Shift-click (range). |
onCreateInrequired | (status: StatusId) => void | – | The “+” in a column header. |
onDeleterequired | (id: string) => void | – | Delete in a card’s menu. |
ref | Ref<ViewHandle> | – | focusActive(), move(1 | -1) and focusTask(id), for app-level keys. |
TaskCard props
| Prop | Type | Default | Description |
|---|---|---|---|
taskrequired | Task | – | The task to draw. |
nowrequired | Date | – | For the due date. |
labelNamerequired | (id: string) => string | – | Turns a label id into its name. |
propsrequired | Record<PropKey, boolean> | – | Which properties to show. |
state | 'idle' | 'source' | 'lifted' | 'preview' | 'idle' | source: the original at 40% while dragging. lifted: picked up by keyboard. preview: the copy under the pointer (hidden from screen readers). |
selected | boolean | – | Draws a ring and the word “Selected”. |
tabbable | boolean | true | The card, grip and menu are in the Tab order only while this is true. |
onOpen | (id: string, e: MouseEvent) => void | – | A click or Enter on the card. |
onCardKeyDown | (e: KeyboardEvent<HTMLButtonElement>, id: string) => void | – | Keys on the card (the board moves between cards with it). |
onHandleKeyDown | (e: KeyboardEvent<HTMLButtonElement>, id: string) => void | – | Shows the grip handle and receives its keys. |
onHandleBlur | (id: string) => void | – | The grip lost focus (the board cancels a keyboard move). |
onPointerDown | (e: PointerEvent<HTMLDivElement>, id: string) => void | – | Starts a pointer drag. |
menuItems | MenuItem[] | – | Shows the “…” menu with these items. |
dragHelpId | string | – | id of the hidden drag instructions the grip is described by. |
still | boolean | – | A picture with no controls. |
className | string | – | Extra classes on the card. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Down arrowJ | On a card: the next card in the column. |
| Up arrowK | On a card: the previous card in the column. |
| Left arrowRight arrow | On a card: the card at the same height in the previous or next column (empty columns are skipped). |
| HomeEnd | On a card: the first or last card in the column. |
| Enter | On a card: open the task. |
| ShiftSpace | On a card: select or deselect it. |
| Tab | From the card to its grip and its “…” menu, then on through the other columns’ “+” buttons and out of the board. |
| SpaceEnter | On the grip: pick the card up; press again to drop it. |
| Up arrowDown arrow | While lifted: move it up or down the column. |
| Left arrowRight arrow | While lifted: move it to the previous or next column. |
| CtrlUp arrowCtrlDown arrow | While lifted: move it to the top or bottom of the column. |
| Esc | Cancel the move and put the card back. Tabbing away cancels too, and Esc also cancels a mouse drag. |
- The board is a group named “Board”. Each column is a group named by its <h3>, with a count read as “3 tasks”.
- Each card is a button named “RC-128, Fix test-fixer…” and described by its priority, due date, labels, estimate and assignee.
- The grip has aria-roledescription="draggable", aria-pressed while lifted, and hidden instructions as its description.
- Every step is announced: “Picked up RC-128, Fix test-fixer. In progress, position 1 of 2.”, “Over In review, position 1 of 2.”, then “RC-128 moved from In progress to In review, position 1 of 2.” or “Move cancelled.”
- Dragging is never the only way: the menu moves a card with single presses (WCAG 2.5.7), and touch scrolls the board instead of grabbing a card.
- Focus follows a card into its new column after a keyboard or menu move.
Guidelines
Do
- Keep the “…” menu: it is how people move a card without dragging.
- Give the board a fixed height. Columns scroll on their own, and the board scrolls sideways.
- Pass the filtered tasks: a move among visible cards still lands in the right place in the full column.
Don’t
- Don’t use TaskBoard without the shared store; it commits moves through actions.move and reads the full columns from taskStore.
- Don’t hide the grip from the keyboard. It only fades out for the mouse until you hover or focus the card.
In use
Related
- TasksThe whole task tracker: a keyboard-first list and board, a detail view, bulk edits, and undo for every change.
- Task listThe task list as a data grid: one Tab stop, arrows to move, pickers in the cells, and groups that collapse.
- MenuA menu button with actions, checkbox and radio items, group labels, shortcut hints and type-ahead, in a popover beside its trigger.
- ToastShort past-tense results with at most one action, like “Run archived · Undo”. Read out politely; they wait while hovered or focused.
- Screen-reader announcementsuseAnnounce makes screen readers say a result like “Moved to Done”, politely or, for urgent things, at once. Nothing shows on screen.
Source
The component’s own files, exactly as they run on this site.
/** * Board: one column per status. Three ways to move a card, all announced: * 1. Drag it with a mouse or pen (pointer events; a 2px drop line with a dot shows where it lands, * the original stays at 40%; Esc cancels). Touch scrolls the board instead; use the menu. * 2. Keyboard: focus the grip, Space or Enter picks up, arrows move (⌘/Ctrl+↑↓ to the ends), * Space or Enter drops, Esc cancels. Focus follows the card into its new column. * 3. The "…" menu: Move to <status>, Move to top, Move to bottom. * Between cards: ↑ ↓ (J K) within a column, ← → across columns, Enter opens. One Tab stop. */import { useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState, type FocusEvent, type KeyboardEvent, type MouseEvent, type PointerEvent,