# Kanban board persisted to a backend

> Persist moves with onValueCommit, optimistic updates, and Sonner rollback

A shadcn/ui block from the [@reui](https://reui.io) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/reui/c-kanban-6).

- Page, with a live preview: https://shadcn.exchange/r/reui/c-kanban-6
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/reui/c-kanban-6-light-cdb46632b0df5d9c.png
- Registry item: https://reui.io/r/new-york-v4/c-kanban-6.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://reui.io/r/new-york-v4/c-kanban-6.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `sonner`
- Registry items: `@reui/badge`, `button`, `card`, `@reui/kanban`

## Files

### `c-kanban-6.tsx`

```tsx
"use client"

import { ComponentProps, useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
  Kanban,
  KanbanBoard,
  KanbanColumn,
  KanbanColumnContent,
  KanbanColumnHandle,
  KanbanItem,
  KanbanItemHandle,
  KanbanOverlay,
  type KanbanCommitMeta,
} from "@/components/reui/kanban"
import { toast } from "sonner"

import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"

interface Task {
  id: string
  title: string
  priority: "low" | "medium" | "high"
}

const COLUMN_TITLES: Record<string, string> = {
  todo: "To Do",
  inProgress: "In Progress",
  done: "Done",
}

// Simulated backend. In a real app this would be a tRPC mutation or a fetch
// to your API. It rejects roughly one in four calls so the optimistic
// rollback path is easy to see.
function persistBoard(): Promise<void> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.random() < 0.25) {
        reject(new Error("Network error"))
      } else {
        resolve()
      }
    }, 700)
  })
}

interface TaskCardProps extends Omit<
  ComponentProps<typeof KanbanItem>,
  "value" | "children"
> {
  task: Task
  asHandle?: boolean
  isOverlay?: boolean
}

function TaskCard({ task, asHandle, isOverlay, ...props }: TaskCardProps) {
  const cardContent = (
    <Card>
      <CardContent className="flex items-center justify-between gap-2">
        <span className="line-clamp-1 text-sm font-medium">{task.title}</span>
        <Badge
          variant={
            task.priority === "high"
              ? "destructive-light"
              : task.priority === "medium"
                ? "primary-light"
                : "warning-light"
          }
          className="pointer-events-none h-5 shrink-0 rounded-sm px-1.5 text-xs capitalize"
        >
          {task.priority}
        </Badge>
      </CardContent>
    </Card>
  )

  return (
    <KanbanItem value={task.id} {...props}>
      {asHandle && !isOverlay ? (
        <KanbanItemHandle>{cardContent}</KanbanItemHandle>
      ) : (
        cardContent
      )}
    </KanbanItem>
  )
}

interface TaskColumnProps extends Omit<
  ComponentProps<typeof KanbanColumn>,
  "children"
> {
  tasks: Task[]
  isOverlay?: boolean
}

function TaskColumn({ value, tasks, isOverlay, ...props }: TaskColumnProps) {
  return (
    <KanbanColumn value={value} {...props}>
      <Card className="mb-2.5">
        <CardHeader className="flex items-center justify-between">
          <div className="flex items-center gap-2.5">
            <span className="text-sm font-semibold">
              {COLUMN_TITLES[value]}
            </span>
            <Badge variant="outline">{tasks.length}</Badge>
          </div>
          <KanbanColumnHandle
            render={(props) => (
              <Button {...props} size="icon-xs" variant="ghost">
                <IconPlaceholder
                  lucide="GripVerticalIcon"
                  tabler="IconGripVertical"
                  hugeicons="DragDropVerticalIcon"
                  phosphor="DotsSixVerticalIcon"
                  remixicon="RiDraggable"
                />
              </Button>
            )}
          />
        </CardHeader>
        <CardContent>
          <KanbanColumnContent value={value} className="flex flex-col gap-2.5">
            {tasks.map((task) => (
              <TaskCard
                key={task.id}
                task={task}
                asHandle={!isOverlay}
                isOverlay={isOverlay}
              />
            ))}
          </KanbanColumnContent>
        </CardContent>
      </Card>
    </KanbanColumn>
  )
}

export function Pattern() {
  const [columns, setColumns] = useState<Record<string, Task[]>>({
    todo: [
      { id: "1", title: "Add authentication", priority: "high" },
      { id: "2", title: "Create API endpoints", priority: "medium" },
      { id: "3", title: "Write documentation", priority: "low" },
    ],
    inProgress: [
      { id: "4", title: "Design system updates", priority: "high" },
      { id: "5", title: "Implement dark mode", priority: "medium" },
    ],
    done: [{ id: "6", title: "Setup project", priority: "low" }],
  })

  // Fires once per completed drag, never during the hover preview. The first
  // argument is the final board (already shown, because onValueChange applied
  // it live), so we only need meta.previousValue to roll back on failure.
  const handleValueCommit = (
    _next: Record<string, Task[]>,
    meta: KanbanCommitMeta<Task>
  ) => {
    const previous = meta.previousValue
    const label =
      meta.kind === "column"
        ? `Reordered "${COLUMN_TITLES[meta.activeContainer] ?? meta.activeContainer}"`
        : `Moved to "${COLUMN_TITLES[meta.overContainer] ?? meta.overContainer}"`

    toast.promise(persistBoard(), {
      loading: "Saving board...",
      success: () => label,
      error: () => {
        // Roll back to the pre-drag arrangement. In production prefer a
        // refetch here so a newer drag is not clobbered by this snapshot.
        setColumns(previous)
        return "Could not save. Board restored."
      },
    })
  }

  return (
    <Kanban
      value={columns}
      onValueChange={setColumns}
      getItemValue={(item) => item.id}
      onValueCommit={handleValueCommit}
    >
      <KanbanBoard className="grid auto-rows-fr grid-cols-3">
        {Object.entries(columns).map(([columnValue, tasks]) => (
          <TaskColumn key={columnValue} value={columnValue} tasks={tasks} />
        ))}
      </KanbanBoard>
      <KanbanOverlay className="bg-muted/10 rounded-md border-2 border-dashed" />
    </Kanban>
  )
}
```
