# Single In Progress Column

> Single In Progress kanban column of sortable rich task cards showing priority, labels, a due date, and a stacked assignee avatar group.

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

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

## Install

```bash
npx shadcn@latest add https://7ovr.com/r/kanban-2.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`, `separator`

## Files

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

```tsx
"use client"

import * as React from "react"
import {
  DndContext,
  DragOverlay,
  KeyboardSensor,
  PointerSensor,
  closestCenter,
  useSensor,
  useSensors,
  type DragEndEvent,
  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,
  AvatarGroup,
  AvatarGroupCount,
  AvatarImage,
} from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { IconPlaceholder } from "@/components/icons/icon-placeholder"

type IconProps = { className?: string; size?: number | string }

type Priority = "critical" | "high" | "medium" | "low"
type LabelTag = "Frontend" | "Backend" | "Design" | "API" | "DevOps" | "QA"

type Assignee = {
  name: string
  initials: string
}

type Task = {
  id: string
  title: string
  description: string
  priority: Priority
  labels: LabelTag[]
  dueDate: string
  assignees: Assignee[]
  commentCount: number
}

const priorityConfig: Record<
  Priority,
  {
    label: string
    icon: React.ElementType
    variant: "destructive" | "default" | "secondary" | "outline"
    dot: string
  }
> = {
  critical: {
    label: "Critical",
    icon: (p: IconProps) => (
      <IconPlaceholder
        lucide="CircleAlert"
        tabler="IconAlertCircle"
        hugeicons="AlertCircleIcon"
        phosphor="WarningCircle"
        remixicon="RiErrorWarningLine"
        {...p}
      />
    ),
    variant: "destructive",
    dot: "bg-destructive",
  },
  high: {
    label: "High",
    icon: (p: IconProps) => (
      <IconPlaceholder
        lucide="Flashlight"
        tabler="IconBulb"
        hugeicons="FlashlightIcon"
        phosphor="Flashlight"
        remixicon="RiFlashlightLine"
        {...p}
      />
    ),
    variant: "default",
    dot: "bg-primary",
  },
  medium: {
    label: "Medium",
    icon: (p: IconProps) => (
      <IconPlaceholder
        lucide="Clock"
        tabler="IconClock"
        hugeicons="TimeIcon"
        phosphor="Clock"
        remixicon="RiTimeLine"
        {...p}
      />
    ),
    variant: "secondary",
    dot: "bg-muted-foreground",
  },
  low: {
    label: "Low",
    icon: (p: IconProps) => (
      <IconPlaceholder
        lucide="CircleCheck"
        tabler="IconCircleCheck"
        hugeicons="CheckmarkCircle02Icon"
        phosphor="CheckCircle"
        remixicon="RiCheckboxCircleLine"
        {...p}
      />
    ),
    variant: "outline",
    dot: "bg-border",
  },
}

const labelVariant: Record<LabelTag, "outline" | "secondary"> = {
  Frontend: "outline",
  Backend: "secondary",
  Design: "outline",
  API: "secondary",
  DevOps: "secondary",
  QA: "outline",
}

const assigneeAvatars: Record<string, string> = {
  "Sam Rivera": "https://i.pravatar.cc/150?img=15",
  "Jordan Kim": "https://i.pravatar.cc/150?img=8",
  "Morgan Lee": "https://i.pravatar.cc/150?img=26",
  "Taylor Obi": "https://i.pravatar.cc/150?img=59",
  "Jamie Park": "https://i.pravatar.cc/150?img=12",
  "Alex Chen": "https://i.pravatar.cc/150?img=33",
}

const initialTasks: Task[] = [
  {
    id: "ACM-412",
    title: "Resolve CORS error on the upload endpoint",
    description:
      "POST /api/v2/files returns 403 for cross-origin requests; preflight OPTIONS never reaches the handler.",
    priority: "critical",
    labels: ["Backend", "API"],
    dueDate: "Jun 18",
    assignees: [
      { name: "Sam Rivera", initials: "SR" },
      { name: "Jordan Kim", initials: "JK" },
    ],
    commentCount: 7,
  },
  {
    id: "ACM-398",
    title: "Redesign the account settings page",
    description:
      "Consolidate billing, profile, and notification preferences into a single tabbed layout using the new design tokens.",
    priority: "high",
    labels: ["Frontend", "Design"],
    dueDate: "Jun 23",
    assignees: [
      { name: "Morgan Lee", initials: "ML" },
      { name: "Taylor Obi", initials: "TO" },
      { name: "Jamie Park", initials: "JP" },
    ],
    commentCount: 4,
  },
  {
    id: "ACM-385",
    title: "Add rate limiting to the public REST API",
    description:
      "Implement a sliding-window limiter (100 req/min per key) with proper Retry-After headers on 429 responses.",
    priority: "high",
    labels: ["Backend", "API", "DevOps"],
    dueDate: "Jun 26",
    assignees: [{ name: "Alex Chen", initials: "AC" }],
    commentCount: 2,
  },
  {
    id: "ACM-371",
    title: "Write end-to-end tests for the checkout flow",
    description:
      "Cover happy path, declined card, and coupon-code scenarios using Playwright against the staging environment.",
    priority: "medium",
    labels: ["QA", "Frontend"],
    dueDate: "Jul 2",
    assignees: [
      { name: "Sam Rivera", initials: "SR" },
      { name: "Morgan Lee", initials: "ML" },
    ],
    commentCount: 0,
  },
  {
    id: "ACM-360",
    title: "Document the webhook payload schema",
    description:
      "Add OpenAPI 3.1 schemas for all event types and publish the spec to the developer portal.",
    priority: "low",
    labels: ["Backend", "API"],
    dueDate: "Jul 8",
    assignees: [{ name: "Jamie Park", initials: "JP" }],
    commentCount: 1,
  },
]

function TaskCardBody({
  task,
  dragHandle,
  dragging,
}: {
  task: Task
  dragHandle?: React.ReactNode
  dragging?: boolean
}) {
  const pCfg = priorityConfig[task.priority]
  const PriorityIcon = pCfg.icon
  const visibleAssignees = task.assignees.slice(0, 3)
  const overflow = task.assignees.length - visibleAssignees.length

  return (
    <Card
      className={`group border-border/80 transition-shadow duration-150 hover:shadow-md ${
        dragging ? "shadow-lg ring-1 ring-foreground/15" : ""
      }`}
    >
      <CardHeader className="pb-2">
        <div className="flex items-center gap-2 pb-1">
          <span className={`size-1.5 shrink-0 ${pCfg.dot}`} />
          <span className="font-mono text-[10px] font-medium tracking-wide text-muted-foreground/70 uppercase">
            {task.id}
          </span>
          {dragHandle ?? (
            <IconPlaceholder
              lucide="GripVertical"
              tabler="IconGripVertical"
              hugeicons="Drag01Icon"
              phosphor="DotsSixVertical"
              remixicon="RiDraggable"
              className="ml-auto size-3.5 text-muted-foreground/40"
            />
          )}
        </div>
        <CardTitle className="text-sm leading-snug font-medium">
          {task.title}
        </CardTitle>
        <CardDescription className="line-clamp-2 text-xs leading-relaxed">
          {task.description}
        </CardDescription>
      </CardHeader>

      <CardContent className="pt-0">
        <div className="flex flex-wrap gap-1.5 pb-3.5">
          <Badge variant={pCfg.variant} className="gap-1">
            <PriorityIcon data-icon="inline-start" />
            {pCfg.label}
          </Badge>
          {task.labels.map((lbl) => (
            <Badge
              key={lbl}
              variant={labelVariant[lbl]}
              className="font-normal"
            >
              {lbl}
            </Badge>
          ))}
        </div>

        <Separator className="mb-3.5 opacity-50" />

        <div className="flex items-center justify-between gap-3">
          <div className="flex items-center gap-3">
            <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
              <IconPlaceholder
                lucide="Calendar"
                tabler="IconCalendar"
                hugeicons="CalendarIcon"
                phosphor="Calendar"
                remixicon="RiCalendarLine"
                className="size-3.5 shrink-0"
              />
              <span className="tabular-nums">{task.dueDate}</span>
            </div>
            {task.commentCount > 0 && (
              <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
                <IconPlaceholder
                  lucide="MessageCircle"
                  tabler="IconMessageCircle"
                  hugeicons="Message01Icon"
                  phosphor="ChatCircle"
                  remixicon="RiChat3Line"
                  className="size-3.5 shrink-0"
                />
                <span className="tabular-nums">{task.commentCount}</span>
              </div>
            )}
          </div>

          <AvatarGroup>
            {visibleAssignees.map((a) => (
              <Avatar key={a.name} size="sm">
                <AvatarImage
                  src={assigneeAvatars[a.name]}
                  alt={a.name}
                  className="grayscale"
                />
                <AvatarFallback>{a.initials}</AvatarFallback>
              </Avatar>
            ))}
            {overflow > 0 && <AvatarGroupCount>+{overflow}</AvatarGroupCount>}
          </AvatarGroup>
        </div>
      </CardContent>
    </Card>
  )
}

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

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

  return (
    <div ref={setNodeRef} style={style} className="relative">
      <button
        type="button"
        className="block w-full cursor-pointer text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
        aria-label={`Open task ${task.id}: ${task.title}`}
        onClick={() => onOpen(task)}
      >
        <TaskCardBody
          task={task}
          dragHandle={
            <span
              ref={setActivatorNodeRef}
              {...attributes}
              {...listeners}
              role="button"
              tabIndex={0}
              aria-label={`Reorder task ${task.id}`}
              className="-my-1 ml-auto flex cursor-grab touch-none items-center justify-center p-1 text-muted-foreground/50 outline-none hover:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing"
              onClick={(e) => e.stopPropagation()}
            >
              <IconPlaceholder
                lucide="GripVertical"
                tabler="IconGripVertical"
                hugeicons="Drag01Icon"
                phosphor="DotsSixVertical"
                remixicon="RiDraggable"
                className="size-3.5"
              />
            </span>
          }
        />
      </button>
    </div>
  )
}

export default function KanbanBlock() {
  const [tasks, setTasks] = React.useState<Task[]>(initialTasks)
  const [activeId, setActiveId] = React.useState<string | null>(null)
  const [detailTask, setDetailTask] = React.useState<Task | null>(null)
  const [detailOpen, setDetailOpen] = React.useState(false)

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

  const activeTask = activeId
    ? (tasks.find((t) => t.id === activeId) ?? null)
    : null

  function handleDragStart(event: DragStartEvent) {
    setActiveId(String(event.active.id))
  }

  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event
    setActiveId(null)
    if (!over || active.id === over.id) return
    setTasks((prev) => {
      const oldIndex = prev.findIndex((t) => t.id === active.id)
      const newIndex = prev.findIndex((t) => t.id === over.id)
      if (oldIndex < 0 || newIndex < 0) return prev
      return arrayMove(prev, oldIndex, newIndex)
    })
  }

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

  return (
    <section className="flex w-full items-center justify-center bg-background px-6 py-12 text-foreground">
      <div className="w-full max-w-xl">
        <div className="flex items-center gap-3 px-1 pb-5">
          <div className="flex shrink-0 items-center gap-2.5">
            <span className="text-sm font-semibold tracking-tight">
              In Progress
            </span>
            <Badge
              variant="secondary"
              className="px-1.5 font-medium tabular-nums"
            >
              {tasks.length}
            </Badge>
          </div>
          <Separator orientation="horizontal" className="flex-1 opacity-60" />
        </div>

        <DndContext
          id="kanban-2-board"
          sensors={sensors}
          collisionDetection={closestCenter}
          onDragStart={handleDragStart}
          onDragEnd={handleDragEnd}
          onDragCancel={() => setActiveId(null)}
        >
          <SortableContext
            items={tasks.map((t) => t.id)}
            strategy={verticalListSortingStrategy}
          >
            <ScrollArea className="h-[34rem] [&_[data-slot=scroll-area-viewport]]:scroll-fade-y">
              <div className="flex flex-col gap-2.5 p-1 pr-2.5">
                {tasks.map((task) => (
                  <SortableTaskCard
                    key={task.id}
                    task={task}
                    onOpen={openDetail}
                  />
                ))}
              </div>
            </ScrollArea>
          </SortableContext>

          <DragOverlay>
            {activeTask ? (
              <div className="cursor-grabbing">
                <TaskCardBody task={activeTask} dragging />
              </div>
            ) : null}
          </DragOverlay>
        </DndContext>
      </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 ${priorityConfig[detailTask.priority].dot}`}
                  />
                  <span className="font-mono text-[10px] font-medium tracking-wide text-muted-foreground/70 uppercase">
                    {detailTask.id}
                  </span>
                </div>
                <DialogTitle>{detailTask.title}</DialogTitle>
                <DialogDescription>{detailTask.description}</DialogDescription>
              </DialogHeader>

              <div className="flex flex-wrap gap-1.5">
                <Badge
                  variant={priorityConfig[detailTask.priority].variant}
                  className="gap-1"
                >
                  {React.createElement(
                    priorityConfig[detailTask.priority].icon,
                    {
                      "data-icon": "inline-start",
                    }
                  )}
                  {priorityConfig[detailTask.priority].label}
                </Badge>
                {detailTask.labels.map((lbl) => (
                  <Badge
                    key={lbl}
                    variant={labelVariant[lbl]}
                    className="font-normal"
                  >
                    {lbl}
                  </Badge>
                ))}
              </div>

              <Separator className="opacity-50" />

              <div className="flex items-center justify-between gap-3">
                <div className="flex items-center gap-3">
                  <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
                    <IconPlaceholder
                      lucide="Calendar"
                      tabler="IconCalendar"
                      hugeicons="CalendarIcon"
                      phosphor="Calendar"
                      remixicon="RiCalendarLine"
                      className="size-3.5 shrink-0"
                    />
                    <span className="tabular-nums">{detailTask.dueDate}</span>
                  </div>
                  {detailTask.commentCount > 0 && (
                    <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
                      <IconPlaceholder
                        lucide="MessageCircle"
                        tabler="IconMessageCircle"
                        hugeicons="Message01Icon"
                        phosphor="ChatCircle"
                        remixicon="RiChat3Line"
                        className="size-3.5 shrink-0"
                      />
                      <span className="tabular-nums">
                        {detailTask.commentCount}
                      </span>
                    </div>
                  )}
                </div>

                <AvatarGroup>
                  {detailTask.assignees.slice(0, 3).map((a) => (
                    <Avatar key={a.name} size="sm">
                      <AvatarImage
                        src={assigneeAvatars[a.name]}
                        alt={a.name}
                        className="grayscale"
                      />
                      <AvatarFallback>{a.initials}</AvatarFallback>
                    </Avatar>
                  ))}
                  {detailTask.assignees.length > 3 && (
                    <AvatarGroupCount>
                      +{detailTask.assignees.length - 3}
                    </AvatarGroupCount>
                  )}
                </AvatarGroup>
              </div>

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