> ## Documentation Index
> Fetch the complete documentation index at: https://developers.zite.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Drag-and-drop Kanban board

> A multi-column board with @dnd-kit — including the three defaults you must override for cross-column drops to land correctly.

Build a Kanban / multi-container board with [@dnd-kit](https://dndkit.com) the standard way (`DndContext`, `SortableContext`, `useSortable` on cards) — but three of its defaults misbehave when you drag cards *between* columns.

<Note>
  A single sortable list (one `SortableContext`, no cross-column drops) needs none of this — the defaults work.
</Note>

## The three fixes

**1. Register each column body as a droppable.** `SortableContext` only registers *cards* as drop targets, so a drop on empty column space falls back to the nearest card — the wrong column, and the card snaps back. Give each column its own `useDroppable({ id: columnId })`. (The HTML `id` attribute alone doesn't register a target.)

**2. Detect collisions from the cursor, not the overlay.** The default `closestCorners` measures from the overlay's corners and can pick a card in the neighbouring column near a boundary. Use a pointer-first detector:

```tsx theme={null}
import { pointerWithin, rectIntersection, type CollisionDetection } from '@dnd-kit/core';

const collisionDetection: CollisionDetection = (args) => {
  const pointer = pointerWithin(args);
  return pointer.length > 0 ? pointer : rectIntersection(args);
};
```

**3. Derive the column's `isOver` from context.** `useDroppable.isOver` is false whenever the cursor is over a *card* (then `over.id` is the card's id), so empty-column highlighting breaks. Compute it from `useDndContext`:

```tsx theme={null}
import { useDroppable, useDndContext } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';

function Column({ id, tasks }: { id: string; tasks: Task[] }) {
  const { setNodeRef, isOver: isDirectOver } = useDroppable({ id });
  const { over, active } = useDndContext();
  const overCardHere =
    over != null && over.id !== active?.id && tasks.some(t => t.id === over.id);
  const isOver = isDirectOver || overCardHere;

  return (
    <div ref={setNodeRef} className={isOver ? 'bg-accent' : undefined}>
      <SortableContext items={tasks.map(t => t.id)} strategy={verticalListSortingStrategy}>
        {tasks.map(t => <Card key={t.id} task={t} />)}
      </SortableContext>
    </div>
  );
}
```

## Wiring it together

```tsx theme={null}
import { DndContext, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
import { updateTaskStatus } from 'zitejs/api';

function Board({ columns }: { columns: Record<string, Task[]> }) {
  // Without a distance constraint, every click on a card starts a drag and the
  // card's own click handlers stop firing.
  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));

  const onDragEnd = (e: DragEndEvent) => {
    const taskId = String(e.active.id);
    const toColumn = String(e.over?.id ?? '');
    if (!toColumn) return;
    // Move the card in local state first, then persist.
    updateTaskStatus({ taskId, status: toColumn });
  };

  return (
    <DndContext sensors={sensors} collisionDetection={collisionDetection} onDragEnd={onDragEnd}>
      <div className="flex gap-4">
        {Object.entries(columns).map(([id, tasks]) => <Column key={id} id={id} tasks={tasks} />)}
      </div>
    </DndContext>
  );
}
```

Cards use `useSortable({ id: task.id })` normally. Persist the move in a backend [workflow](/framework/workflows) calling `zite.Tasks.update({ id: taskId, record: { status } })`.

## Gotchas

* **`PointerSensor` needs `activationConstraint: { distance: 5 }`** or clicks on cards register as drags.
* **Update local state optimistically** on drag end, then fire the workflow — don't block the UI on the round-trip.
