Overview
MCP Server
Custom Shadcn Kanban for React and Tailwind CSS. A drag-and-drop kanban component designed for seamless item organization across customizable columns.
import {
Kanban,
KanbanBoard,
KanbanColumn,
KanbanColumnContent,
KanbanColumnHandle,
KanbanItem,
KanbanItemHandle,
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.
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.
<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>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.
The root component that provides the kanban context and manages the items state.
KanbanCommitMeta<T> is { kind: "item" | "column"; event: DragEndEvent; activeContainer: string; activeIndex: number; overContainer: string; overIndex: number; previousValue: Record<string, T[]> }.
The horizontal container for kanban columns.
An individual column within the kanban board.
The drag handle for a column (if columns are sortable).
The scrollable area within a column that holds the items.
An individual draggable item within a column.
The drag handle for an individual item.
The ghost element displayed during a drag operation.
"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>
)
}