About
A drag-and-drop kanban board for organizing tasks across columns, with reorderable cards and columns, keyboard support, and an optional drag overlay.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Kanban Board to your project and fits it to your theme.
Code
How Kanban Board is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client";
import {
Kanban,
KanbanBoard,
KanbanColumn,
KanbanColumnContent,
KanbanColumnHandle,
KanbanItem,
KanbanItemHandle,
KanbanOverlay,
} from "@/components/ui/kanban";
import { IconGripVertical } from "@tabler/icons-react";
import { ComponentProps, useState } from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
import { cva, type VariantProps } from "class-variance-authority";
import { Loader2 } from "lucide-react";
function cx(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
function Spinner({
className,
...props
}: React.ComponentProps<typeof Loader2>) {
return (
<Loader2
aria-label="Loading"
className={cx("animate-spin", className)}
role="status"
data-slot="spinner"
{...props}
/>
);
}
const buttonVariants = cva(
"relative inline-flex shrink-0 [corner-shape:superellipse(1.25)] cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-medium text-base outline-none transition-colors before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 data-loading:select-none data-loading:text-transparent sm:text-sm [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:-mx-0.5 [&_svg]:shrink-0",
{
defaultVariants: { size: "default", variant: "default" },
variants: {
size: {
default: "h-9 px-[calc(--spacing(3)-1px)] sm:h-8",
icon: "size-9 sm:size-8",
"icon-lg": "size-10 sm:size-9",
"icon-sm": "size-8 sm:size-7",
"icon-xl":
"size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5",
"icon-xs":
"size-7 rounded-md before:rounded-[calc(var(--radius-md)-1px)] sm:size-6 not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-4 sm:not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-3.5",
lg: "h-10 px-[calc(--spacing(3.5)-1px)] sm:h-9",
sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7",
xl: "h-11 rounded-lg px-[calc(--spacing(4)-1px)] text-lg sm:h-10 sm:text-base [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5",
xs: "h-7 gap-1 rounded-md px-[calc(--spacing(2)-1px)] text-sm before:rounded-[calc(var(--radius-md)-1px)] sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5",
},
variant: {
default:
"not-disabled:inset- border-primary bg-primary text-primary-foreground hover:bg-primary/90 data-pressed:bg-primary/90 *:data-[slot=button-loading-indicator]:text-primary-foreground [:active,[data-pressed]]:inset- [:disabled,:active,[data-pressed]]:shadow-none",
destructive:
"not-disabled:inset- border-destructive bg-destructive text-white hover:bg-destructive/90 data-pressed:bg-destructive/90 *:data-[slot=button-loading-indicator]:text-white [:active,[data-pressed]]:inset- [:disabled,:active,[data-pressed]]:shadow-none",
"destructive-outline":
"border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground hover:border-destructive/32 hover:bg-destructive/4 data-pressed:border-destructive/32 data-pressed:bg-destructive/4 *:data-[slot=button-loading-indicator]:text-foreground dark:bg-input/32 [:disabled,:active,[data-pressed]]:shadow-none",
ghost:
"border-transparent text-foreground hover:bg-accent data-pressed:bg-accent *:data-[slot=button-loading-indicator]:text-foreground",
link: "border-transparent text-foreground underline-offset-4 hover:underline data-pressed:underline *:data-[slot=button-loading-indicator]:text-foreground",
outline:
"border-input bg-popover not-dark:bg-clip-padding text-foreground hover:bg-accent/50 data-pressed:bg-accent/50 *:data-[slot=button-loading-indicator]:text-foreground dark:bg-input/32 dark:data-pressed:bg-input/64 dark:hover:bg-input/64 [:disabled,:active,[data-pressed]]:shadow-none",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/90 data-pressed:bg-secondary/90 *:data-[slot=button-loading-indicator]:text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80",
},
},
},
);
interface ButtonProps extends useRender.ComponentProps<"button"> {
variant?: VariantProps<typeof buttonVariants>["variant"];
size?: VariantProps<typeof buttonVariants>["size"];
loading?: boolean;
}
function Button({
className,
variant,
size,
render,
children,
loading = false,
disabled: disabledProp,
...props
}: ButtonProps) {
const isDisabled = Boolean(loading || disabledProp);
const typeValue = render ? undefined : "button";
const defaultProps = {
children: (
<>
{children}
{loading && (
<Spinner
className="pointer-events-none absolute"
data-slot="button-loading-indicator"
/>
)}
</>
),
className: cx(buttonVariants({ className, size, variant })),
"aria-disabled": loading || undefined,
"data-loading": loading ? "" : undefined,
"data-slot": "button",
disabled: isDisabled,
type: typeValue,
};
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(defaultProps, props),
render,
});
}
const badgeVariants = cva(
"relative inline-flex shrink-0 [corner-shape:superellipse(1.25)] items-center justify-center gap-1 whitespace-nowrap rounded-sm border border-transparent font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-3.5 sm:[&_svg:not([class*='size-'])]:size-3 [&_svg]:pointer-events-none [&_svg]:shrink-0 [button&,a&]:cursor-pointer [button&,a&]:pointer-coarse:after:absolute [button&,a&]:pointer-coarse:after:size-full [button&,a&]:pointer-coarse:after:min-h-11 [button&,a&]:pointer-coarse:after:min-w-11",
{
defaultVariants: { size: "default", variant: "default" },
variants: {
size: {
default:
"h-5.5 min-w-5.5 px-[calc(--spacing(1)-1px)] text-sm sm:h-4.5 sm:min-w-4.5 sm:text-xs",
lg: "h-6.5 min-w-6.5 px-[calc(--spacing(1.5)-1px)] text-base sm:h-5.5 sm:min-w-5.5 sm:text-sm",
sm: "h-5 min-w-5 rounded-[.25rem] px-[calc(--spacing(1)-1px)] text-xs sm:h-4 sm:min-w-4 sm:text-[.625rem]",
},
variant: {
default:
"bg-primary text-primary-foreground [button&,a&]:hover:bg-primary/90",
destructive:
"bg-destructive text-white [button&,a&]:hover:bg-destructive/90",
error: "bg-error text-error-foreground dark:bg-error",
info: "bg-info text-info-foreground dark:bg-info",
outline:
"border-input bg-background text-foreground dark:bg-input/32 [button&,a&]:hover:bg-accent/50 dark:[button&,a&]:hover:bg-input/48",
secondary:
"bg-secondary text-secondary-foreground [button&,a&]:hover:bg-secondary/90",
success: "bg-success text-success-foreground dark:bg-success",
warning: "bg-warning text-warning-foreground dark:bg-warning",
},
},
},
);
interface BadgeProps extends useRender.ComponentProps<"span"> {
variant?: VariantProps<typeof badgeVariants>["variant"];
size?: VariantProps<typeof badgeVariants>["size"];
square?: boolean;
}
function Badge({
className,
variant,
size,
square,
render,
...props
}: BadgeProps) {
const defaultProps = {
className: cx(
square && "aspect-square",
badgeVariants({ className, size, variant }),
),
"data-slot": "badge",
};
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(defaultProps, props),
render,
});
}
function Avatar({ className, ...props }: AvatarPrimitive.Root.Props) {
return (
<AvatarPrimitive.Root
className={cx(
"relative inline-flex size-8 shrink-0 select-none items-center justify-center rounded-full bg-background align-middle font-medium text-xs",
className,
)}
data-slot="avatar"
{...props}
/>
);
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
className={cx("size-full object-cover rounded-[inherit]", className)}
data-slot="avatar-image"
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
className={cx(
"flex size-full items-center justify-center rounded-full bg-muted",
className,
)}
data-slot="avatar-fallback"
{...props}
/>
);
}
function Card({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cx(
"relative flex flex-col gap-2 rounded-xl p-2 bg-card not-dark:bg-clip-padding text-card-foreground before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] ",
className,
),
"data-slot": "card",
};
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(defaultProps, props),
render,
});
}
function CardHeader({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cx(
"grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 p-4 in-[[data-slot=card]:has(>[data-slot=card-panel])]:pb-4 has-data-[slot=card-action]:grid-cols-[1fr_auto]",
className,
),
"data-slot": "card-header",
};
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(defaultProps, props),
render,
});
}
function CardContent({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cx(
"flex-1 p-4 in-[[data-slot=card]:has(>[data-slot=card-header]:not(.border-b))]:pt-0 in-[[data-slot=card]:has(>[data-slot=card-footer]:not(.border-t))]:pb-0",
className,
),
"data-slot": "card-panel",
};
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(defaultProps, props),
render,
});
}
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">
<IconGripVertical />
</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 default function Demo() {
const [columns, setColumns] = useState<Record<string, Task[]>>({
backlog: [
{
id: "1",
title: "Add authentication",
priority: "high",
assignee: "Alex Johnson",
assigneeAvatar:
"https://avatars.spaceui.one/v1?name=apollo&variant=invader",
dueDate: "Jan 10, 2025",
},
{
id: "2",
title: "Create API endpoints",
priority: "medium",
assignee: "Sarah Chen",
assigneeAvatar:
"https://avatars.spaceui.one/v1?name=gemini&variant=kendo",
dueDate: "Jan 15, 2025",
},
{
id: "3",
title: "Write documentation",
priority: "low",
assignee: "Michael Rodriguez",
assigneeAvatar:
"https://avatars.spaceui.one/v1?name=luna&variant=lumina",
dueDate: "Jan 20, 2025",
},
],
inProgress: [
{
id: "4",
title: "Design system updates",
priority: "high",
assignee: "Emma Wilson",
assigneeAvatar:
"https://avatars.spaceui.one/v1?name=mars&variant=shaula",
dueDate: "Aug 25, 2025",
},
{
id: "5",
title: "Implement dark mode",
priority: "medium",
assignee: "David Kim",
assigneeAvatar:
"https://avatars.spaceui.one/v1?name=jupiter&variant=singularity",
dueDate: "Aug 25, 2025",
},
],
done: [
{
id: "7",
title: "Setup project",
priority: "high",
assignee: "Aron Thompson",
assigneeAvatar:
"https://avatars.spaceui.one/v1?name=saturn&variant=triton",
dueDate: "Sep 25, 2025",
},
{
id: "8",
title: "Initial commit",
priority: "low",
assignee: "James Brown",
assigneeAvatar:
"https://avatars.spaceui.one/v1?name=venus&variant=solar-flare",
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>
);
}








