# TaskBoard

> The board view of tasks — one column per status, in status order, with cards that can be dragged to another column to change their status.

Source: https://raft-design.botiverse.build/components/task-board. Generated from the current release; where it differs from what you remember, this page is right.

A TaskBoard shows tasks in one column per status, from Todo to Closed. Dragging a card to another column changes its status. It is one of the two views of a set of tasks; the other is the [TaskList](https://raft-design.botiverse.build/components/task-list.md). When to show which is in [Tasks](https://raft-design.botiverse.build/patterns/tasks.md).

```tsx
import { useCallback, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import {
  ScrollArea,
  ScrollAreaContent,
  ScrollAreaCorner,
  ScrollAreaScrollbar,
  ScrollAreaThumb,
  ScrollAreaViewport,
  TaskBoardColumn,
  TaskBoardColumnBadge,
  TaskBoardColumnChevron,
  TaskBoardColumnCount,
  TaskBoardColumnEmpty,
  TaskBoardColumnEmptyAllowed,
  TaskBoardColumnEmptyBlocked,
  TaskBoardColumnEmptyIdle,
  TaskBoardColumnHeading,
  TaskBoardColumnItems,
  TaskBoardColumnPanel,
  TaskBoardColumnTrigger,
  TaskBoardRoot,
  TaskDndProvider,
  TaskDragOverlay,
  TaskDraggable,
  TaskDropPlaceholder,
  TaskDropZone,
} from "@botiverse/raft-ui";
import type { TaskMoveDetails } from "@botiverse/raft-ui";
import { ChevronDown } from "lucide-react";

import {
  getTaskStatusLabel,
  moveTaskFixture,
  TASK_STATUSES,
} from "#/modules/task-card/task-card.fixtures.ts";
import type { TaskFixture, TaskStatus } from "#/modules/task-card/task-card.fixtures.ts";
import { TaskCardComposition } from "#/modules/task-card/compositions/task-card.composition.tsx";
import { BOARD_DEMO_TASKS } from "#/modules/task-board/task-board.fixtures.ts";

const DEFAULT_COLLAPSED: Record<TaskStatus, boolean> = {
  todo: false,
  "in-progress": false,
  "in-review": false,
  done: false,
  closed: false,
};

const EXPANDED: Record<TaskStatus, boolean> = {
  todo: false,
  "in-progress": false,
  "in-review": false,
  done: false,
  closed: false,
};

export type TaskBoardCompositionProps = {
  tasks?: readonly TaskFixture[];
  blockedDropStatus?: TaskStatus;
  showChannelName?: boolean;
  /** When true, every status column starts expanded (useful for empty-column demos). */
  expandAll?: boolean;
};

function TaskBoardStatusColumn({
  children,
  collapsed,
  dropDisabled,
  dropId,
  status,
  onCollapsedChange,
}: {
  children: ReactNode;
  collapsed: boolean;
  dropDisabled: boolean;
  dropId: string;
  status: TaskStatus;
  onCollapsedChange: (status: TaskStatus, collapsed: boolean) => void;
}) {
  const dropData = useMemo(() => ({ status }), [status]);
  const handleOpenChange = useCallback(
    (open: boolean) => onCollapsedChange(status, !open),
    [onCollapsedChange, status],
  );

  return (
    <TaskBoardColumn
      render={
        <TaskDropZone
          id={dropId}
          data={dropData}
          data-drop-collapsed={collapsed ? "true" : undefined}
          disabled={dropDisabled}
        />
      }
      open={!collapsed}
      onOpenChange={handleOpenChange}
    >
      {children}
    </TaskBoardColumn>
  );
}

function TaskBoardDraggableItem({
  children,
  status,
  task,
}: {
  children: ReactNode;
  status: TaskStatus;
  task: TaskFixture;
}) {
  const dropId = `task-item:${task.id}`;
  const dropData = useMemo(() => ({ status, taskId: task.id }), [status, task.id]);

  return (
    <>
      <TaskDropPlaceholder id={dropId} placement="before" />
      <TaskDraggable id={task.id} dropId={dropId} dropData={dropData} disabled={task.dragDisabled}>
        {children}
      </TaskDraggable>
      <TaskDropPlaceholder id={dropId} placement="after" />
    </>
  );
}

export function TaskBoardComposition({
  tasks: controlledTasks,
  blockedDropStatus,
  showChannelName = false,
  expandAll = false,
}: TaskBoardCompositionProps) {
  const portalRef = useRef<HTMLDivElement>(null);
  const [localTasks, setLocalTasks] = useState<readonly TaskFixture[]>(BOARD_DEMO_TASKS);
  const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
  const [collapsed, setCollapsed] = useState<Record<TaskStatus, boolean>>(
    expandAll ? EXPANDED : DEFAULT_COLLAPSED,
  );
  const [announcement, setAnnouncement] = useState("Ready");

  const tasks = controlledTasks ?? localTasks;
  const draggableTaskIds = useMemo(() => {
    const ids = new Set<string>();
    for (const task of tasks) {
      if (!task.dragDisabled) ids.add(task.id);
    }
    return ids;
  }, [tasks]);
  const isDropAllowed = useCallback(
    (
      activeId: string | number,
      _overId: string | number,
      overData: Record<string, unknown> | undefined,
    ) =>
      isTaskDropTarget(overData) &&
      overData.status !== blockedDropStatus &&
      draggableTaskIds.has(String(activeId)),
    [blockedDropStatus, draggableTaskIds],
  );
  const updateStatus = useCallback(
    (taskId: string, status: TaskStatus) => {
      const task = tasks.find((item) => item.id === taskId);
      if (!task || task.status === status) return;
      const next = moveTaskFixture(tasks, taskId, { status });
      if (controlledTasks === undefined) setLocalTasks(next);
      setAnnouncement(`Task #${task.taskNumber} moved to ${getTaskStatusLabel(status)}`);
    },
    [controlledTasks, tasks],
  );
  const openTask = useCallback((task: TaskFixture) => {
    setAnnouncement(`Opened task #${task.taskNumber}: ${task.title}`);
  }, []);
  const handleDragStart = useCallback(({ active }: { active: { id: string | number } }) => {
    setActiveTaskId(String(active.id));
  }, []);
  const clearActiveTask = useCallback(() => setActiveTaskId(null), []);
  const handleMove = useCallback(
    ({ activeId, overData, placement }: TaskMoveDetails) => {
      if (!isTaskDropTarget(overData)) return;
      const task = tasks.find((item) => item.id === activeId);
      if (!task) return;
      const next = moveTaskFixture(tasks, String(activeId), {
        status: overData.status,
        taskId: overData.taskId,
        placement,
      });
      if (controlledTasks === undefined) setLocalTasks(next);
      setAnnouncement(`Task #${task.taskNumber} moved to ${getTaskStatusLabel(overData.status)}`);
    },
    [controlledTasks, tasks],
  );
  const handleCollapsedChange = useCallback((status: TaskStatus, nextCollapsed: boolean) => {
    setCollapsed((current) => ({ ...current, [status]: nextCollapsed }));
  }, []);
  const activeTask =
    activeTaskId == null ? undefined : tasks.find((task) => task.id === activeTaskId);

  return (
    <ScrollArea ref={portalRef} className="relative size-full min-h-0  min-w-0">
      <ScrollAreaViewport className="p-4">
        <ScrollAreaContent className="min-w-max">
          <TaskDndProvider
            isDropAllowed={isDropAllowed}
            onDragStart={handleDragStart}
            onDragCancel={clearActiveTask}
            onDragEnd={clearActiveTask}
            onMove={handleMove}
          >
            <TaskBoardRoot>
              {TASK_STATUSES.map((status) => {
                const statusTasks = tasks.filter((task) => task.status === status.id);
                const visibleTasks = statusTasks.filter((task) => task.id !== activeTaskId);
                const columnDropId = `task-status:${status.id}`;
                return (
                  <TaskBoardStatusColumn
                    key={status.id}
                    status={status.id}
                    dropId={columnDropId}
                    collapsed={collapsed[status.id]}
                    dropDisabled={!collapsed[status.id] && visibleTasks.length > 0}
                    onCollapsedChange={handleCollapsedChange}
                  >
                    <TaskBoardColumnTrigger>
                      <TaskBoardColumnHeading>
                        <TaskBoardColumnBadge status={status.id}>
                          {status.label}
                        </TaskBoardColumnBadge>
                        <TaskBoardColumnCount>{statusTasks.length}</TaskBoardColumnCount>
                      </TaskBoardColumnHeading>
                      <TaskBoardColumnChevron>
                        <ChevronDown aria-hidden />
                      </TaskBoardColumnChevron>
                    </TaskBoardColumnTrigger>
                    <TaskBoardColumnPanel>
                      <TaskBoardColumnItems>
                        {visibleTasks.length === 0 ? (
                          <>
                            <TaskBoardColumnEmpty>
                              <TaskBoardColumnEmptyIdle>
                                No {status.label.toLowerCase()} tasks.
                              </TaskBoardColumnEmptyIdle>
                              <TaskBoardColumnEmptyAllowed>
                                Drop to set {status.label}
                              </TaskBoardColumnEmptyAllowed>
                              <TaskBoardColumnEmptyBlocked>
                                Cannot move to {status.label}
                              </TaskBoardColumnEmptyBlocked>
                            </TaskBoardColumnEmpty>
                            <TaskDropPlaceholder id={columnDropId} />
                          </>
                        ) : (
                          <>
                            {visibleTasks.map((task) => (
                              <TaskBoardDraggableItem key={task.id} status={status.id} task={task}>
                                <TaskCardComposition
                                  task={task}
                                  onOpen={openTask}
                                  onStatusChange={updateStatus}
                                  portalRef={portalRef}
                                  showChannelName={showChannelName}
                                />
                              </TaskBoardDraggableItem>
                            ))}
                          </>
                        )}
                      </TaskBoardColumnItems>
                    </TaskBoardColumnPanel>
                  </TaskBoardStatusColumn>
                );
              })}
            </TaskBoardRoot>
            <TaskDragOverlay>
              {activeTask == null ? null : (
                <TaskCardComposition
                  task={activeTask}
                  onStatusChange={updateStatus}
                  portalRef={portalRef}
                  showChannelName={showChannelName}
                />
              )}
            </TaskDragOverlay>
          </TaskDndProvider>
          <span className="sr-only" aria-live="polite">
            {announcement}
          </span>
        </ScrollAreaContent>
      </ScrollAreaViewport>
      <ScrollAreaScrollbar>
        <ScrollAreaThumb />
      </ScrollAreaScrollbar>
      <ScrollAreaScrollbar orientation="horizontal">
        <ScrollAreaThumb />
      </ScrollAreaScrollbar>
      <ScrollAreaCorner />
    </ScrollArea>
  );
}

function isTaskDropTarget(value: Record<string, unknown> | undefined): value is {
  status: TaskStatus;
  taskId?: string;
} {
  return (
    value != null &&
    typeof value.status === "string" &&
    TASK_STATUSES.some((status) => status.id === value.status) &&
    (value.taskId == null || typeof value.taskId === "string")
  );
}
```

## When to use

| Situation                                   | Use                                   |
| ------------------------------------------- | ------------------------------------- |
| Tasks by status, with room for five columns | TaskBoard                             |
| Tasks on a phone, or in a narrow panel      | [TaskList](https://raft-design.botiverse.build/components/task-list.md) |
| One task                                    | [TaskCard](https://raft-design.botiverse.build/components/task-card.md) |

## Anatomy

- `TaskBoardRoot`: the row of columns. Required.
- `TaskBoardColumn`: one status. A collapsible section. Required, one per status.
  - `TaskBoardColumnTrigger`: the column heading, which collapses the column.
    - `TaskBoardColumnHeading`: `TaskBoardColumnBadge` (the status pixel and name) and `TaskBoardColumnCount`.
    - `TaskBoardColumnChevron`: the arrow that shows whether the column is open.
  - `TaskBoardColumnPanel` and `TaskBoardColumnItems`: the cards.
  - `TaskBoardColumnEmpty`: what an empty column says, with three texts: `TaskBoardColumnEmptyIdle` normally, `TaskBoardColumnEmptyAllowed` while a card can be dropped there, `TaskBoardColumnEmptyBlocked` while it can't.

Drag and drop comes from the `TaskDnd` parts (`TaskDndProvider`, `TaskDropZone`, `TaskDraggable`), which the board composition wires in.

## States

| State                   | Look                                                                                                               |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Empty column            | A dashed `line-seam` box with one line of text in `foreground-hint`                                                |
| A card can be dropped   | The empty box hides and the drop placeholder shows; a collapsed column gets a dashed `line-strong` outline         |
| A card is over a column | The column keeps its fill; only the drop placeholder marks where the card lands, so one signal says where, not two |
| A card can't be dropped | The empty box says why, in `foreground-muted`                                                                      |

```tsx
import { TASK_FIXTURES } from "#/modules/task-card/task-card.fixtures.ts";

import { TaskBoardComposition } from "./task-board.composition.tsx";

const EMPTY_COLUMN_TASKS = [TASK_FIXTURES[0], TASK_FIXTURES[2]] as const;

/** Idle column empties for In Review / Done / Closed. Drag onto empty columns for Allowed/Blocked. */
export function TaskBoardEmptyColumnsComposition() {
  return <TaskBoardComposition tasks={EMPTY_COLUMN_TASKS} expandAll />;
}
```

## Behavior

- Order the columns Todo, In Progress, In Review, Done, Closed.
- Switch between the board and the list with Board / List at the right end of the filter bar ([Tasks](https://raft-design.botiverse.build/patterns/tasks.md#structure)), and remember the person's choice.
- Scroll the whole board in one viewport, both ways. Columns keep their width and never scroll on their own.
- Mount only the visible cards of a tall column with a virtualizer of your own that reads the board's scroll position, as below.

```tsx
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
  ScrollArea,
  ScrollAreaContent,
  ScrollAreaCorner,
  ScrollAreaScrollbar,
  ScrollAreaThumb,
  ScrollAreaViewport,
  TaskBoardColumn,
  TaskBoardColumnBadge,
  TaskBoardColumnCount,
  TaskBoardColumnHeading,
  TaskBoardColumnItems,
  TaskBoardColumnPanel,
  TaskBoardColumnTrigger,
  TaskBoardRoot,
} from "@botiverse/raft-ui";

import { TaskCardComposition } from "#/modules/task-card/compositions/task-card.composition.tsx";
import type { TaskFixture } from "#/modules/task-card/task-card.fixtures.ts";

const VIRTUAL_TASK_COUNT = 48;
const ESTIMATED_CARD_SIZE = 116;
const CARD_GAP = 10;

function createVirtualTasks(count: number): TaskFixture[] {
  return Array.from({ length: count }, (_, index) => {
    const taskNumber = 200 + index;
    return {
      id: `virtual-task-${taskNumber}`,
      taskNumber,
      channelName: "proj-uiux",
      title: `Virtualized board card #${taskNumber}`,
      description: "Only viewport rows mount; scroll to recycle off-screen cards.",
      status: "todo" as const,
      creatorId: "me",
      assigneeId: "atlas",
    };
  });
}

/**
 * Consumer-owned virtualization demo for tall board columns.
 * Shows mounted vs total so the overscan window is visible while scrolling.
 */
export function TaskBoardVirtualizationComposition() {
  const viewportRef = useRef<HTMLDivElement>(null);
  const itemsRef = useRef<HTMLDivElement>(null);
  const [scrollMargin, setScrollMargin] = useState(0);
  const tasks = useMemo(() => createVirtualTasks(VIRTUAL_TASK_COUNT), []);

  useLayoutEffect(() => {
    const viewport = viewportRef.current;
    const items = itemsRef.current;
    if (!viewport || !items) return;

    const update = () => {
      const next =
        items.getBoundingClientRect().top -
        viewport.getBoundingClientRect().top +
        viewport.scrollTop;
      setScrollMargin((current) => (Math.abs(current - next) < 0.5 ? current : next));
    };

    update();
    const observer = new ResizeObserver(update);
    observer.observe(viewport);
    observer.observe(items);
    return () => observer.disconnect();
  }, []);

  const virtualizer = useVirtualizer({
    count: tasks.length,
    getScrollElement: () => viewportRef.current,
    getItemKey: (index) => tasks[index]!.id,
    estimateSize: () => ESTIMATED_CARD_SIZE,
    gap: CARD_GAP,
    overscan: 6,
    scrollMargin,
  });
  const virtualItems = virtualizer.getVirtualItems();

  return (
    <div className="flex size-full min-h-0  flex-col">
      <div className="flex shrink-0 items-center justify-between gap-3 border-b border-line-hairline px-4 py-2">
        <p className="text-xs text-foreground-muted">
          Mount a virtual stack when a column can grow past the shared viewport. Only visible rows
          plus overscan stay in the DOM.
        </p>
        <p className="shrink-0 font-mono text-xs tabular-nums text-foreground">
          Mounted {virtualItems.length} / {tasks.length}
        </p>
      </div>
      <ScrollArea className="relative min-h-0 w-full flex-1">
        <ScrollAreaViewport ref={viewportRef} className="p-4">
          <ScrollAreaContent>
            <TaskBoardRoot>
              <TaskBoardColumn defaultOpen>
                <TaskBoardColumnTrigger>
                  <TaskBoardColumnHeading>
                    <TaskBoardColumnBadge status="todo">Todo</TaskBoardColumnBadge>
                    <TaskBoardColumnCount>{tasks.length}</TaskBoardColumnCount>
                  </TaskBoardColumnHeading>
                </TaskBoardColumnTrigger>
                <TaskBoardColumnPanel>
                  <TaskBoardColumnItems>
                    <div
                      ref={itemsRef}
                      className="relative w-full"
                      style={{ height: virtualizer.getTotalSize() }}
                    >
                      {virtualItems.map((row) => {
                        const task = tasks[row.index]!;
                        return (
                          <div
                            key={row.key}
                            ref={virtualizer.measureElement}
                            data-index={row.index}
                            className="absolute inset-x-0 top-0"
                            style={{ transform: `translateY(${row.start - scrollMargin}px)` }}
                          >
                            <TaskCardComposition task={task} readOnly />
                          </div>
                        );
                      })}
                    </div>
                  </TaskBoardColumnItems>
                </TaskBoardColumnPanel>
              </TaskBoardColumn>
            </TaskBoardRoot>
          </ScrollAreaContent>
        </ScrollAreaViewport>
        <ScrollAreaScrollbar>
          <ScrollAreaThumb />
        </ScrollAreaScrollbar>
        <ScrollAreaCorner />
      </ScrollArea>
    </div>
  );
}
```

## Content

- Write the empty text the same way as an empty group in a list ([TaskList](https://raft-design.botiverse.build/components/task-list.md#content)).
- While a card is dragged over an empty column, name the status it would get, or say that it can't move there.

## Accessibility

- Each column is a Base UI `Collapsible` rendered as a `section`; its trigger is a button with `aria-expanded`.
- Give each column an accessible name: the status name in the trigger, or `aria-label` on the section.
- Cards can be moved with the keyboard: the drag parts use dnd-kit's keyboard sensor (Space to pick up, the arrow keys to move, Space to drop, Escape to cancel).
- Announce each move in a polite live region: "Task #25 moved to In Review". The board doesn't announce on its own.

## Rules

- Count the cards in every column heading, in Space Mono.

## API

### TaskBoardColumn

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultOpen` | `boolean` | `false` | Whether the collapsible panel is initially open. |
| `disabled` | `boolean` | `false` | Whether the component should ignore user interaction. |
| `onOpenChange` | `(open: boolean, eventDetails: CollapsibleRootChangeEventDetails) => void` |  | Event handler called when the panel is opened or closed. |
| `open` | `boolean` |  | Whether the collapsible panel is currently open. |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskBoardColumnBadge

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `status` (required) | `TaskStatusId` |  |  |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskBoardColumnChevron

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskBoardColumnCount

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskBoardColumnEmpty

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskBoardColumnEmptyAllowed

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskBoardColumnEmptyBlocked

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskBoardColumnEmptyIdle

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskBoardColumnHeading

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskBoardColumnItems

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskBoardColumnPanel

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `hiddenUntilFound` | `boolean` | `false` | Allows the browser's built-in page search to find and expand the panel contents. |
| `keepMounted` | `boolean` | `false` | Whether to keep the element in the DOM while the panel is hidden. This prop is ignored when `hiddenUntilFound` is used. |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskBoardColumnTrigger

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `nativeButton` | `boolean` | `true` | Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`). |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### TaskBoardRoot

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.
