# Three Column Task Board

> Kanban board with To Do, In Progress, and Done columns, drag and drop between them, and cards that open a task detail dialog.

A shadcn/ui block from the [@7ovr](https://7ovr.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/7ovr/kanban-1).

- Page, with a live preview: https://shadcn.exchange/r/7ovr/kanban-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/7ovr/kanban-1-light-e74b8b4dec7f5bf5.png
- Registry item: https://7ovr.com/r/kanban-1.json
- Categories: kanban
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://7ovr.com/r/kanban-1.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: `@base-ui/react`, `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`
- Registry items: `avatar`, `badge`, `card`, `dialog`, `scroll-area`

## Files

### `registry/blocks/kanban/1/kanban-block.tsx`

```tsx
"use client"

import * as React from "react"
import {
  DndContext,
  DragOverlay,
  KeyboardSensor,
  PointerSensor,
  closestCorners,
  useDroppable,
  useSensor,
  useSensors,
  type DragEndEvent,
  type DragOverEvent,
  type DragStartEvent,
} from "@dnd-kit/core"
import {
  SortableContext,
  arrayMove,
  sortableKeyboardCoordinates,
  useSortable,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog"
import { ScrollArea } from "@/components/ui/scroll-area"

type TaskLabel = "Bug" | "Feature" | "Docs" | "Design" | "Infra" | "Research"

const labelVariant: Record<
  TaskLabel,
  "default" | "secondary" | "outline" | "destructive"
> = {
  Bug: "destructive",
  Feature: "default",
  Docs: "secondary",
  Design: "secondary",
  Infra: "outline",
  Research: "outline",
}

type Task = {
  id: string
  title: string
  label: TaskLabel
  assignee: string
  initials: string
  avatarSrc: string
  priority: "high" | "medium" | "low"
}

type Column = {
  id: string
  title: string
  accent: string
  tasks: Task[]
}

const initialColumns: Column[] = [
  {
    id: "todo",
    title: "To Do",
    accent: "bg-muted-foreground/20",
    tasks: [
      {
        id: "t1",
        title: "Audit accessibility across all form components",
        label: "Docs",
        assignee: "Morgan Lee",
        initials: "ML",
        avatarSrc: "https://i.pravatar.cc/32?img=47",
        priority: "medium",
      },
      {
        id: "t2",
        title: "Investigate memory leak in background worker",
        label: "Bug",
        assignee: "Sam Rivera",
        initials: "SR",
        avatarSrc: "https://i.pravatar.cc/32?img=12",
        priority: "high",
      },
      {
        id: "t3",
        title: "Add dark-mode support to email templates",
        label: "Design",
        assignee: "Jamie Park",
        initials: "JP",
        avatarSrc: "https://i.pravatar.cc/32?img=33",
        priority: "low",
      },
      {
        id: "t4",
        title: "Define quarterly OKRs with product team",
        label: "Research",
        assignee: "Alex Chen",
        initials: "AC",
        avatarSrc: "https://i.pravatar.cc/32?img=68",
        priority: "medium",
      },
    ],
  },
  {
    id: "in-progress",
    title: "In Progress",
    accent: "bg-primary",
    tasks: [
      {
        id: "t5",
        title: "Migrate CI/CD pipeline to GitHub Actions",
        label: "Infra",
        assignee: "Jordan Kim",
        initials: "JK",
        avatarSrc: "https://i.pravatar.cc/32?img=51",
        priority: "high",
      },
      {
        id: "t6",
        title: "Build multi-step onboarding wizard",
        label: "Feature",
        assignee: "Taylor Obi",
        initials: "TO",
        avatarSrc: "https://i.pravatar.cc/32?img=22",
        priority: "high",
      },
      {
        id: "t7",
        title: "Fix pagination on the invoices list view",
        label: "Bug",
        assignee: "Sam Rivera",
        initials: "SR",
        avatarSrc: "https://i.pravatar.cc/32?img=12",
        priority: "medium",
      },
    ],
  },
  {
    id: "done",
    title: "Done",
    accent: "bg-muted-foreground/30",
    tasks: [
      {
        id: "t8",
        title: "Set up error monitoring with Sentry",
        label: "Infra",
        assignee: "Alex Chen",
        initials: "AC",
        avatarSrc: "https://i.pravatar.cc/32?img=68",
        priority: "medium",
      },
      {
        id: "t9",
        title: "Write API reference for webhooks endpoint",
        label: "Docs",
        assignee: "Jamie Park",
        initials: "JP",
        avatarSrc: "https://i.pravatar.cc/32?img=33",
        priority: "low",
      },
      {
        id: "t10",
        title: "Redesign settings page layout",
        label: "Design",
        assignee: "Morgan Lee",
        initials: "ML",
        avatarSrc: "https://i.pravatar.cc/32?img=47",
        priority: "low",
      },
    ],
  },
]

const priorityDot: Record<Task["priority"], string> = {
  high: "bg-destructive",
  medium: "bg-primary",
  low: "bg-muted-foreground/40",
}

const priorityLabel: Record<Task["priority"], string> = {
  high: "High priority",
  medium: "Medium priority",
  low: "Low priority",
}

function TaskCardBody({
  task,
  dimmed,
  dragging,
}: {
  task: Task
  dimmed?: boolean
  dragging?: boolean
}) {
  return (
    <Card
      size="sm"
      className={`transition-shadow duration-150 hover:shadow-md ${
        dimmed ? "opacity-60 transition-opacity hover:opacity-100" : ""
      }${dragging ? "shadow-lg ring-1 ring-foreground/15" : ""}`}
    >
      <CardHeader>
        <div className="flex items-start gap-2">
          <span
            className={`mt-1.5 size-1.5 shrink-0 rounded-full ${priorityDot[task.priority]}`}
            role="img"
            aria-label={priorityLabel[task.priority]}
          />
          <CardTitle className="text-sm leading-snug font-medium">
            {task.title}
          </CardTitle>
        </div>
      </CardHeader>
      <CardContent>
        <div className="flex items-center justify-between gap-2">
          <Badge variant={labelVariant[task.label]}>{task.label}</Badge>
          <div className="flex items-center gap-1.5">
            <span className="truncate text-xs text-muted-foreground">
              {task.assignee}
            </span>
            <Avatar size="sm">
              <AvatarImage
                src={task.avatarSrc}
                alt={task.assignee}
                className="grayscale"
              />
              <AvatarFallback>{task.initials}</AvatarFallback>
            </Avatar>
          </div>
        </div>
      </CardContent>
    </Card>
  )
}

function SortableTaskCard({
  task,
  dimmed,
  onOpen,
}: {
  task: Task
  dimmed?: boolean
  onOpen: (task: Task) => void
}) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: task.id })

  const style: React.CSSProperties = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0 : 1,
  }

  const movedRef = React.useRef(false)

  return (
    <div
      ref={setNodeRef}
      style={style}
      {...attributes}
      {...listeners}
      role="button"
      tabIndex={0}
      aria-label={`Open task: ${task.title}`}
      className="cursor-grab touch-none outline-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing"
      onPointerDownCapture={() => {
        movedRef.current = false
      }}
      onPointerMoveCapture={() => {
        movedRef.current = true
      }}
      onClick={() => {
        if (!movedRef.current) onOpen(task)
      }}
      onKeyDown={(e) => {
        if (e.key === "Enter") {
          e.preventDefault()
          onOpen(task)
        }
      }}
    >
      <TaskCardBody task={task} dimmed={dimmed} />
    </div>
  )
}

function BoardColumn({
  column,
  onOpen,
  reflowKey,
}: {
  column: Column
  onOpen: (task: Task) => void
  reflowKey: number
}) {
  const { setNodeRef, isOver } = useDroppable({ id: column.id })

  return (
    <div className="flex w-[78%] shrink-0 flex-col gap-3 sm:w-auto sm:shrink">
      <div className="flex items-center gap-2.5">
        <span className={`size-2 ${column.accent}`} />
        <span className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">
          {column.title}
        </span>
        <span className="ml-auto flex size-5 items-center justify-center rounded-md border border-border text-[10px] font-semibold text-muted-foreground tabular-nums">
          {column.tasks.length}
        </span>
      </div>

      <div className={`h-px w-full ${column.accent} opacity-60`} />

      <SortableContext
        items={column.tasks.map((t) => t.id)}
        strategy={verticalListSortingStrategy}
      >
        <ScrollArea
          key={reflowKey}
          className={`h-80 transition-colors [&_[data-slot=scroll-area-viewport]]:scroll-fade-y ${isOver ? "bg-muted/40" : ""}`}
        >
          <div
            ref={setNodeRef}
            className="flex min-h-full flex-col gap-2 p-0.5 pr-2.5"
          >
            {column.tasks.map((task) => (
              <SortableTaskCard
                key={task.id}
                task={task}
                dimmed={column.id === "done"}
                onOpen={onOpen}
              />
            ))}
            {column.tasks.length === 0 && (
              <div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-border py-8 text-xs text-muted-foreground">
                Drop here
              </div>
            )}
          </div>
        </ScrollArea>
      </SortableContext>
    </div>
  )
}

export default function KanbanBlock() {
  const [columns, setColumns] = React.useState<Column[]>(initialColumns)
  const [activeTask, setActiveTask] = React.useState<Task | null>(null)
  const [detailTask, setDetailTask] = React.useState<Task | null>(null)
  const [detailOpen, setDetailOpen] = React.useState(false)
  const [reflow, setReflow] = React.useState(0)

  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  )

  const findColumnId = React.useCallback(
    (id: string): string | undefined => {
      if (columns.some((c) => c.id === id)) return id
      return columns.find((c) => c.tasks.some((t) => t.id === id))?.id
    },
    [columns]
  )

  const totalTasks = columns.reduce((n, c) => n + c.tasks.length, 0)
  const doneCount = columns.find((c) => c.id === "done")?.tasks.length ?? 0

  function handleDragStart(event: DragStartEvent) {
    const id = String(event.active.id)
    const task = columns.flatMap((c) => c.tasks).find((t) => t.id === id)
    setActiveTask(task ?? null)
  }

  function handleDragOver(event: DragOverEvent) {
    const { active, over } = event
    if (!over) return
    const activeId = String(active.id)
    const overId = String(over.id)

    const activeCol = findColumnId(activeId)
    const overCol = findColumnId(overId)
    if (!activeCol || !overCol || activeCol === overCol) return

    setColumns((prev) => {
      const from = prev.find((c) => c.id === activeCol)!
      const to = prev.find((c) => c.id === overCol)!
      const moving = from.tasks.find((t) => t.id === activeId)
      if (!moving) return prev

      const overIndex = to.tasks.findIndex((t) => t.id === overId)
      const insertAt = overIndex >= 0 ? overIndex : to.tasks.length

      return prev.map((c) => {
        if (c.id === activeCol) {
          return { ...c, tasks: c.tasks.filter((t) => t.id !== activeId) }
        }
        if (c.id === overCol) {
          const next = [...c.tasks]
          next.splice(insertAt, 0, moving)
          return { ...c, tasks: next }
        }
        return c
      })
    })
  }

  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event
    setActiveTask(null)
    setReflow((n) => n + 1)
    if (!over) return

    const activeId = String(active.id)
    const overId = String(over.id)
    const activeCol = findColumnId(activeId)
    const overCol = findColumnId(overId)
    if (!activeCol || !overCol) return

    if (activeCol === overCol) {
      setColumns((prev) =>
        prev.map((c) => {
          if (c.id !== activeCol) return c
          const oldIndex = c.tasks.findIndex((t) => t.id === activeId)
          const newIndex = c.tasks.findIndex((t) => t.id === overId)
          if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return c
          return { ...c, tasks: arrayMove(c.tasks, oldIndex, newIndex) }
        })
      )
    }
  }

  function openDetail(task: Task) {
    setDetailTask(task)
    setDetailOpen(true)
  }

  return (
    <section className="flex min-h-svh w-full items-start justify-center bg-background px-6 py-12 text-foreground">
      <div className="mx-auto w-full max-w-5xl">
        <div className="mb-8 flex items-end justify-between border-b border-border pb-5">
          <div>
            <p className="mb-1 text-xs font-medium tracking-widest text-muted-foreground uppercase">
              Acme Engineering
            </p>
            <h1 className="font-heading text-2xl font-bold tracking-tight">
              Sprint Board
            </h1>
          </div>
          <Badge variant="outline" className="mb-0.5">
            Q3 Sprint 7
          </Badge>
        </div>

        <DndContext
          id="kanban-1-board"
          sensors={sensors}
          collisionDetection={closestCorners}
          onDragStart={handleDragStart}
          onDragOver={handleDragOver}
          onDragEnd={handleDragEnd}
          onDragCancel={() => {
            setActiveTask(null)
            setReflow((n) => n + 1)
          }}
        >
          <div className="-mx-1 flex gap-5 overflow-x-auto px-1 pb-3 sm:mx-0 sm:grid sm:grid-cols-3 sm:overflow-visible sm:px-0 sm:pb-0">
            {columns.map((col) => (
              <BoardColumn
                key={col.id}
                column={col}
                onOpen={openDetail}
                reflowKey={reflow}
              />
            ))}
          </div>

          <DragOverlay>
            {activeTask ? (
              <div className="cursor-grabbing">
                <TaskCardBody task={activeTask} dragging />
              </div>
            ) : null}
          </DragOverlay>
        </DndContext>

        <div className="mt-8 flex items-center gap-3 border-t border-border pt-4">
          <div className="flex -space-x-2">
            {[
              {
                src: "https://i.pravatar.cc/32?img=47",
                name: "Morgan Lee",
                initials: "ML",
              },
              {
                src: "https://i.pravatar.cc/32?img=12",
                name: "Sam Rivera",
                initials: "SR",
              },
              {
                src: "https://i.pravatar.cc/32?img=33",
                name: "Jamie Park",
                initials: "JP",
              },
              {
                src: "https://i.pravatar.cc/32?img=68",
                name: "Alex Chen",
                initials: "AC",
              },
              {
                src: "https://i.pravatar.cc/32?img=51",
                name: "Jordan Kim",
                initials: "JK",
              },
            ].map((member) => (
              <Avatar
                key={member.name}
                size="sm"
                className="border-2 border-background"
              >
                <AvatarImage
                  src={member.src}
                  alt={member.name}
                  className="grayscale"
                />
                <AvatarFallback>{member.initials}</AvatarFallback>
              </Avatar>
            ))}
          </div>
          <div className="flex items-center gap-x-4 text-xs text-muted-foreground tabular-nums">
            <span>
              <span className="font-medium text-foreground">5</span>{" "}
              Contributors
            </span>
            <span>
              <span className="font-medium text-foreground">{totalTasks}</span>{" "}
              Tasks
            </span>
            <span>
              <span className="font-medium text-foreground">{doneCount}</span>{" "}
              Completed
            </span>
          </div>
        </div>
      </div>

      <Dialog open={detailOpen} onOpenChange={setDetailOpen}>
        <DialogContent>
          {detailTask && (
            <>
              <DialogHeader>
                <div className="mb-1 flex items-center gap-2">
                  <span
                    className={`size-1.5 shrink-0 rounded-full ${priorityDot[detailTask.priority]}`}
                  />
                  <span className="text-[10px] font-semibold tracking-wider text-muted-foreground uppercase">
                    {priorityLabel[detailTask.priority]}
                  </span>
                </div>
                <DialogTitle>{detailTask.title}</DialogTitle>
                <DialogDescription>
                  Task in the{" "}
                  {columns.find((c) =>
                    c.tasks.some((t) => t.id === detailTask.id)
                  )?.title ?? "board"}{" "}
                  column. Drag the card to move it across the board.
                </DialogDescription>
              </DialogHeader>

              <div className="flex items-center justify-between gap-2 border-t border-border pt-3">
                <Badge variant={labelVariant[detailTask.label]}>
                  {detailTask.label}
                </Badge>
                <div className="flex items-center gap-2">
                  <span className="text-xs text-muted-foreground">
                    {detailTask.assignee}
                  </span>
                  <Avatar size="sm">
                    <AvatarImage
                      src={detailTask.avatarSrc}
                      alt={detailTask.assignee}
                      className="grayscale"
                    />
                    <AvatarFallback>{detailTask.initials}</AvatarFallback>
                  </Avatar>
                </div>
              </div>

              <DialogFooter showCloseButton />
            </>
          )}
        </DialogContent>
      </Dialog>
    </section>
  )
}
```
