Skip to content
DocsSupportPricing
Roadmap (has updates coming soon)XFigma3.3K
Overview
  • Introduction
  • Get Started
  • License Setup
  • Styling
  • Registry
  • MCP Server
  • Agent Skills
  • RTL
  • Changelog
MCP Server
  • Claude
  • CodexCodexCodex
  • Cursor
  • Grok
  • Conductor
  • v0
  • Lovable
  • Replit
  • Bolt
  • OpenCode
  • VS Code
  • GitHub Copilot
  • Kilo Code
  • Zed
  • Antigravity
Components
  • Alert
  • Autocomplete
  • Badge
  • Data GridRebuilt on TanStack Table v9, with pinning now start/end
  • Date Selector
  • Event CalendarNew event calendar component with five views
  • File Upload
  • FiltersSizing and interaction refinements
  • Frame
  • GanttNew gantt component with day to year scales
  • Icon Stack
  • Icon TileNew icon tile component with five surface variants
  • KanbanonValueCommit callback and accessibility improvements
  • Number Field
  • Phone Input
  • Rating
  • Scrollspy
  • SortableonValueCommit callback and accessibility improvements
  • StepperRender prop composition and styling refinements
  • Timeline
  • Tree

Application

  • App Shell
  • Auth
  • Card
  • Chart
  • Dashboard
  • Dialog
  • Empty State
  • Event CalendarThree new event calendar blocks added
  • Form
  • GanttNew gantt block added
  • Kanban Board
  • List
  • Navbar
  • Onboarding
  • Profile
  • Schedule
  • Settings
  • Sheet
  • Stats
  • Timeline
  • Wizard

Solutions

  • Agents
  • AI Ops
  • Analytics
  • Billing
  • Bookings
  • CRM
  • Files
  • Inventory
  • Users

Templates

  • E-commerce
  • SaaS
  • Dashboard
  • Landing
  • All templates

eCommerce

  • Category Card
  • Checkout
  • Comparison
  • Coupon
  • Filter Sidebar
  • Product Card
  • Product Detail
  • Product Grid
  • Receipt
  • Review
  • Shopping Cart
  • Wishlist

Data Grid

  • Base
  • Columns
  • Drag & DropNew drag and drop Data Grid block added
  • EditingNew editable Data Grid block added
  • Expansion
  • Filtering
  • GroupingFour new grouped and tree Data Grid blocks added
  • Virtualization

Marketing

  • Blog
  • Contact
  • CTA
  • FAQ

Resources

  • Components
  • Blocks
  • Docs
  • Support
  • Pricing
  • Roadmap(has updates coming soon)
  • AffiliateSoon

Legal

  • Privacy Policy
  • Terms & Conditions
  • License
  • Refunds
  • Cookies

© 2026 ReUI. All rights reserved.

3.3K

Shadcn Kanban

PreviousNext

Custom Shadcn Kanban for React and Tailwind CSS. A drag-and-drop kanban component designed for seamless item organization across customizable columns.

Base UIRadix UI

Installation

pnpm dlx shadcn@latest add @reui/kanban

Usage

import {
  Kanban,
  KanbanBoard,
  KanbanColumn,
  KanbanColumnContent,
  KanbanColumnHandle,
  KanbanItem,
  KanbanItemHandle,
  KanbanOverlay,

Shadcn Kanban Free Components

Browse 6 production-ready Shadcn Kanban components for dashboards, forms, and product UI. These examples use Base UI primitives from @base-ui/react and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.

Browse all 6 Shadcn Kanban components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the ReUI library.

Shadcn Kanban Board Pro Blocks

This primitive also powers ready-made ReUI Pro blocks: complete kanban board sections assembled on top of the exact component documented here, with realistic data, polished layout details, and production interaction patterns you can copy straight into your app.

Preview all 10 Shadcn Kanban Board Pro blocks in the ReUI blocks gallery.

Icon TileNumber Field

On This Page

InstallationUsageExamplesOverlayPersist to a backendPersisting movesAPI ReferenceKanbanKanbanBoardKanbanColumnKanbanColumnHandleKanbanColumnContentKanbanItemKanbanItemHandleKanbanOverlay
} from "@/components/reui/kanban"
<Kanban
  value={columns}
  onValueChange={setColumns}
  getItemValue={(item) => item.id}
>
  <KanbanBoard>
    {Object.entries(columns).map(([id, items]) => (
      <KanbanColumn key={id} value={id}>
        <KanbanColumnHandle>
          <h3>{id}</h3>
        </KanbanColumnHandle>
        <KanbanColumnContent value={id}>
          {items.map((item) => (
            <KanbanItem key={item.id} value={item.id}>
              <KanbanItemHandle>{item.content}</KanbanItemHandle>
            </KanbanItem>
          ))}
        </KanbanColumnContent>
      </KanbanColumn>
    ))}
  </KanbanBoard>
  <KanbanOverlay>
    <div className="bg-muted size-full rounded-md" />
  </KanbanOverlay>
</Kanban>

Examples

Overlay

Persist to a backend

Persisting moves

Keep value and onValueChange so the board reshuffles live while dragging, and use onValueCommit to save. It fires once per completed drag (never during the hover preview) with the final board and a previousValue snapshot, so you can update optimistically and roll back on error. Its meta.kind tells you whether a card moved ("item") or a column was reordered ("column").

const moveCard = api.board.moveCard.useMutation()
const reorderColumns = api.board.reorderColumns.useMutation()
 
<Kanban
  value={columns}
  onValueChange={setColumns}
  getItemValue={(task) => task.id}
  onValueCommit={(next, meta) => {
    if (meta.kind === "column") {
      reorderColumns.mutate({ order: Object.keys(next) })
      return
    }
    moveCard.mutate(
      {
        id: String(meta.event.active.id),
        to: meta.overContainer,
        index: meta.overIndex,
      },
      {
        onError: () => {
          setColumns(meta.previousValue) // roll back
          toast.error("Could not move the card. Your board was restored.")
        },
      },
    )
  }}
>
  {/* ... */}
</Kanban>

If a user might start another drag before the mutation settles, prefer a refetch in onError instead of restoring the snapshot, so a newer arrangement is not clobbered.

If you do not need the live cross-column preview, onMove is a simpler alternative: it fires once on drop for item moves and lets you apply the move yourself. Column reorders still arrive through onValueChange.

API Reference

Kanban

The root component that provides the kanban context and manages the items state.

PropTypeDefaultDescription
valueRecord<string, T[]>-Required. The current state of columns and their items.
onValueChange(value: Record<string, T[]>) => void-Required. Fired when items move within or between columns. In the default mode it also fires during the drag to render the live preview, so avoid persisting from here directly.
getItemValue(item: T) => string-Required. Function to get a unique identifier for an item.
onValueCommit(value: Record<string, T[]>, meta: KanbanCommitMeta<T>) => void-Fired once per completed drag with the final board and a previousValue snapshot. Use it to persist moves to a backend. See .
onMove(event: KanbanMoveEvent) => void-Opt-in single commit point for item moves. When set, the live preview is disabled and you apply the move yourself.
restoreOnCancelbooleanfalseWhen true, cancelling a drag (for example pressing Escape) restores the board to its pre-drag arrangement.
onDragStart(event: DragStartEvent) => void-Raw dnd-kit passthrough, fired when a drag starts.
onDragEnd(event: DragEndEvent) => void-Raw dnd-kit passthrough, fired after internal cleanup and before the final onValueChange/onMove call. In the default mode, onValueChange has already fired during dragOver.
onDragCancel(event: DragCancelEvent) => void-Raw dnd-kit passthrough, fired when a drag is cancelled.
accessibilityDndContextProps["accessibility"]-dnd-kit accessibility options (announcements and screen reader instructions).
modifiersModifiers-dnd-kit modifiers applied to the drag.
classNamestring-Additional CSS classes for the container.

KanbanCommitMeta<T> is { kind: "item" | "column"; event: DragEndEvent; activeContainer: string; activeIndex: number; overContainer: string; overIndex: number; previousValue: Record<string, T[]> }.


KanbanBoard

The horizontal container for kanban columns.

PropTypeDefaultDescription
classNamestring-Additional CSS classes for the board.

KanbanColumn

An individual column within the kanban board.

PropTypeDefaultDescription
valuestring-Required. The unique identifier for the column.
classNamestring-Additional CSS classes for the column.

KanbanColumnHandle

The drag handle for a column (if columns are sortable).

PropTypeDefaultDescription
classNamestring-Additional CSS classes for the handle.

KanbanColumnContent

The scrollable area within a column that holds the items.

PropTypeDefaultDescription
valuestring-Required. The identifier of the column this content belongs to.
classNamestring-Additional CSS classes for the content area.

KanbanItem

An individual draggable item within a column.

PropTypeDefaultDescription
valuestring-Required. The unique identifier for the item.
disabledbooleanfalseWhether the item is draggable.
classNamestring-Additional CSS classes for the item.

KanbanItemHandle

The drag handle for an individual item.

PropTypeDefaultDescription
classNamestring-Additional CSS classes for the handle.

KanbanOverlay

The ghost element displayed during a drag operation.

PropTypeDefaultDescription
classNamestring-Additional CSS classes for the overlay.
"use client"

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

import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { GripVerticalIcon } from 'lucide-react'

interface Task {
  id: string
  title: string
  priority: "low" | "medium" | "high"
  description?: string
  assignee?: string
  assigneeAvatar?: string
  dueDate?: string
}

const COLUMN_TITLES: Record<string, string> = {
  backlog: "Backlog",
  inProgress: "In Progress",
  review: "Review",
  done: "Done",
}

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="space-y-2.5">
        <div 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>
        </div>
        <div className="text-muted-foreground flex items-center justify-between text-xs">
          {task.assignee && (
            <div className="flex items-center gap-1">
              <Avatar className="size-4">
                <AvatarImage src={task.assigneeAvatar} />
                <AvatarFallback>{task.assignee.charAt(0)}</AvatarFallback>
              </Avatar>
              <span className="line-clamp-1">{task.assignee}</span>
            </div>
          )}
          {task.dueDate && (
            <time className="text-[10px] whitespace-nowrap tabular-nums">
              {task.dueDate}
            </time>
          )}
        </div>
      </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">
                <GripVerticalIcon />
              </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[]>>({
    backlog: [
      {
        id: "1",
        title: "Add authentication",
        priority: "high",
        assignee: "Alex Johnson",
        assigneeAvatar:
          "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
        dueDate: "Jan 10, 2025",
      },
      {
        id: "2",
        title: "Create API endpoints",
        priority: "medium",
        assignee: "Sarah Chen",
        assigneeAvatar:
          "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
        dueDate: "Jan 15, 2025",
      },
      {
        id: "3",
        title: "Write documentation",
        priority: "low",
        assignee: "Michael Rodriguez",
        assigneeAvatar:
          "https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
        dueDate: "Jan 20, 2025",
      },
    ],
    inProgress: [
      {
        id: "4",
        title: "Design system updates",
        priority: "high",
        assignee: "Emma Wilson",
        assigneeAvatar:
          "https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
        dueDate: "Aug 25, 2025",
      },
      {
        id: "5",
        title: "Implement dark mode",
        priority: "medium",
        assignee: "David Kim",
        assigneeAvatar:
          "https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
        dueDate: "Aug 25, 2025",
      },
    ],
    done: [
      {
        id: "7",
        title: "Setup project",
        priority: "high",
        assignee: "Aron Thompson",
        assigneeAvatar:
          "https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
        dueDate: "Sep 25, 2025",
      },
      {
        id: "8",
        title: "Initial commit",
        priority: "low",
        assignee: "James Brown",
        assigneeAvatar:
          "https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
        dueDate: "Sep 20, 2025",
      },
    ],
  })

  return (
    <Kanban
      value={columns}
      onValueChange={setColumns}
      getItemValue={(item) => item.id}
    >
      <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>
  )
}
"use client"

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

import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { GripVerticalIcon } from 'lucide-react'

interface Task {
  id: string
  title: string
  priority: "low" | "medium" | "high"
  description?: string
  assignee?: string
  assigneeAvatar?: string
  dueDate?: string
}

const COLUMN_TITLES: Record<string, string> = {
  backlog: "Backlog",
  inProgress: "In Progress",
  review: "Review",
  done: "Done",
}

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 flex-col gap-2.5">
        <div 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-outline"
                : task.priority === "medium"
                  ? "primary-outline"
                  : "warning-outline"
            }
            className="pointer-events-none h-5 shrink-0 rounded-sm px-1.5 text-[11px] capitalize"
          >
            {task.priority}
          </Badge>
        </div>
        <div className="text-muted-foreground flex items-center justify-between text-xs">
          {task.assignee && (
            <div className="flex items-center gap-1">
              <Avatar className="size-4">
                <AvatarImage src={task.assigneeAvatar} />
                <AvatarFallback>{task.assignee.charAt(0)}</AvatarFallback>
              </Avatar>
              <span className="line-clamp-1">{task.assignee}</span>
            </div>
          )}
          {task.dueDate && (
            <time className="text-[10px] whitespace-nowrap tabular-nums">
              {task.dueDate}
            </time>
          )}
        </div>
      </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">
                <GripVerticalIcon />
              </Button>
            )}
          />
        </CardHeader>
        <CardContent>
          <KanbanColumnContent
            value={value}
            className="flex flex-col gap-2.5 p-0.5"
          >
            {tasks.map((task) => (
              <TaskCard key={task.id} task={task} asHandle={!isOverlay} />
            ))}
          </KanbanColumnContent>
        </CardContent>
      </Card>
    </KanbanColumn>
  )
}

export function Pattern() {
  const [columns, setColumns] = useState<Record<string, Task[]>>({
    backlog: [
      {
        id: "1",
        title: "Add authentication",
        priority: "high",
        assignee: "Alex Johnson",
        assigneeAvatar:
          "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
        dueDate: "Jan 10, 2025",
      },
      {
        id: "2",
        title: "Create API endpoints",
        priority: "medium",
        assignee: "Sarah Chen",
        assigneeAvatar:
          "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
        dueDate: "Jan 15, 2025",
      },
      {
        id: "3",
        title: "Write documentation",
        priority: "low",
        assignee: "Michael Rodriguez",
        assigneeAvatar:
          "https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
        dueDate: "Jan 20, 2025",
      },
    ],
    inProgress: [
      {
        id: "4",
        title: "Design system updates",
        priority: "high",
        assignee: "Emma Wilson",
        assigneeAvatar:
          "https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
        dueDate: "Aug 25, 2025",
      },
      {
        id: "5",
        title: "Implement dark mode",
        priority: "medium",
        assignee: "David Kim",
        assigneeAvatar:
          "https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
        dueDate: "Aug 25, 2025",
      },
    ],
    done: [
      {
        id: "7",
        title: "Setup project",
        priority: "high",
        assignee: "Aron Thompson",
        assigneeAvatar:
          "https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
        dueDate: "Sep 25, 2025",
      },
      {
        id: "8",
        title: "Initial commit",
        priority: "low",
        assignee: "James Brown",
        assigneeAvatar:
          "https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
        dueDate: "Sep 20, 2025",
      },
    ],
  })

  return (
    <Kanban
      value={columns}
      onValueChange={setColumns}
      getItemValue={(item) => item.id}
    >
      <KanbanBoard className="grid auto-rows-fr grid-cols-3">
        {Object.entries(columns).map(([columnValue, tasks]) => (
          <TaskColumn key={columnValue} value={columnValue} tasks={tasks} />
        ))}
      </KanbanBoard>
      <KanbanOverlay>
        {({ value, variant }) => {
          if (variant === "column") {
            const tasks = columns[value] ?? []
            return <TaskColumn value={String(value)} tasks={tasks} isOverlay />
          }

          const task = Object.values(columns)
            .flat()
            .find((task) => task.id === value)

          if (!task) return null

          return <TaskCard task={task} isOverlay />
        }}
      </KanbanOverlay>
    </Kanban>
  )
}
"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 { GripVerticalIcon } from 'lucide-react'

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(meta: KanbanCommitMeta<Task>): 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">
                <GripVerticalIcon />
              </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(meta), {
      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>
  )
}
Persisting moves