"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>
);
}