Working kanban boards with drag-and-drop powered by @dnd-kit. Adjust the columns, cards, and tags around how your team works.
Sprint board
Three quiet columns with straightforward task cards.
Sprint board
Drag cards — others shift to make room.
To do
2Map pricing tiers
Marketing
Collect launch assets
Design
In progress
2Ship hero-05 polish
Product
Write FAQ answers
Content
Done
1Navbar variants
Engineering
"use client";import { useMemo, useState, type ReactNode } from "react";import { DndContext, DragOverlay, PointerSensor, closestCorners, useDroppable, useSensor, useSensors, type DragEndEvent, type DragOverEvent, type DragStartEvent,} from "@dnd-kit/core";import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy,} from "@dnd-kit/sortable";import { CSS } from "@dnd-kit/utilities";import { cn } from "@/lib/utils";type Kanban01Props = { className?: string;};type Card = { id: string; title: string; meta: string;};type Column = { id: string; title: string; cards: Card[];};const INITIAL: Column[] = [ { id: "todo", title: "To do", cards: [ { id: "t1", title: "Map pricing tiers", meta: "Marketing" }, { id: "t2", title: "Collect launch assets", meta: "Design" }, ], }, { id: "doing", title: "In progress", cards: [ { id: "t3", title: "Ship hero-05 polish", meta: "Product" }, { id: "t4", title: "Write FAQ answers", meta: "Content" }, ], }, { id: "done", title: "Done", cards: [{ id: "t5", title: "Navbar variants", meta: "Engineering" }], },];function findColumnId(columns: Column[], id: string) { if (columns.some((column) => column.id === id)) return id; return columns.find((column) => column.cards.some((card) => card.id === id)) ?.id;}function CardBody({ card }: { card: Card }) { return ( <> <p className="text-sm font-medium tracking-wide text-foreground"> {card.title} </p> <p className="mt-1 text-xs tracking-wide text-muted-foreground"> {card.meta} </p> </> );}function SortableCard({ card, className,}: { card: Card; className?: string;}) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: card.id }); return ( <li ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, }} className={cn( "cursor-grab rounded-lg bg-background px-3 py-3 active:cursor-grabbing", isDragging && "opacity-0", className, )} {...attributes} {...listeners} > <CardBody card={card} /> </li> );}function ColumnDrop({ id, children, empty,}: { id: string; children: ReactNode; empty: boolean;}) { const { setNodeRef } = useDroppable({ id }); return ( <ul ref={setNodeRef} className="flex flex-col gap-2 rounded-xl bg-muted p-2" > {empty ? ( <li className="rounded-lg border border-dashed border-border px-3 py-3 text-center text-xs tracking-wide text-muted-foreground"> Empty </li> ) : ( children )} </ul> );}export function Kanban01({ className }: Kanban01Props) { const [columns, setColumns] = useState(INITIAL); const [activeId, setActiveId] = useState<string | null>(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), ); const activeCard = useMemo(() => { if (!activeId) return null; for (const column of columns) { const card = column.cards.find((item) => item.id === activeId); if (card) return card; } return null; }, [activeId, columns]); function handleDragStart(event: DragStartEvent) { setActiveId(String(event.active.id)); } function handleDragOver(event: DragOverEvent) { const { active, over } = event; if (!over) return; const activeCardId = String(active.id); const overId = String(over.id); setColumns((current) => { const fromId = findColumnId(current, activeCardId); const toId = findColumnId(current, overId); if (!fromId || !toId || fromId === toId) return current; const fromColumn = current.find((column) => column.id === fromId); const toColumn = current.find((column) => column.id === toId); if (!fromColumn || !toColumn) return current; const moving = fromColumn.cards.find((card) => card.id === activeCardId); if (!moving) return current; const overIndex = toColumn.cards.findIndex((card) => card.id === overId); const insertIndex = overIndex >= 0 ? overIndex : toColumn.cards.length; return current.map((column) => { if (column.id === fromId) { return { ...column, cards: column.cards.filter((card) => card.id !== activeCardId), }; } if (column.id === toId) { const next = column.cards.filter((card) => card.id !== activeCardId); next.splice(insertIndex, 0, moving); return { ...column, cards: next }; } return column; }); }); } function handleDragEnd(event: DragEndEvent) { const { active, over } = event; setActiveId(null); if (!over) return; const activeCardId = String(active.id); const overId = String(over.id); setColumns((current) => { const columnId = findColumnId(current, activeCardId); if (!columnId) return current; if (findColumnId(current, overId) !== columnId) return current; const column = current.find((item) => item.id === columnId); if (!column) return current; const oldIndex = column.cards.findIndex((card) => card.id === activeCardId); const newIndex = overId === columnId ? column.cards.length - 1 : column.cards.findIndex((card) => card.id === overId); if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return current; return current.map((item) => item.id === columnId ? { ...item, cards: arrayMove(item.cards, oldIndex, newIndex) } : item, ); }); } return ( <section className={cn("@container w-full bg-background", className)}> <div className="mx-auto w-full max-w-5xl px-4 py-10 @[32rem]:px-6 @[32rem]:py-14"> <div> <h2 className="text-xl font-medium tracking-wide text-foreground @[32rem]:text-2xl"> Sprint board </h2> <p className="mt-2 text-sm tracking-wide text-muted-foreground"> Drag cards — others shift to make room. </p> </div> <DndContext id="kanban-01" sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd} > <div className="mt-8 flex flex-col gap-6 @[40rem]:grid @[40rem]:grid-cols-3 @[40rem]:gap-4"> {columns.map((column) => ( <div key={column.id} className="flex w-full flex-col gap-3"> <div className="flex items-center justify-between gap-2 px-1"> <h3 className="text-sm font-medium tracking-wide text-foreground"> {column.title} </h3> <span className="text-xs tracking-wide text-muted-foreground tabular-nums"> {column.cards.length} </span> </div> <SortableContext id={`kanban-01-${column.id}`} items={column.cards.map((card) => card.id)} strategy={verticalListSortingStrategy} > <ColumnDrop id={column.id} empty={column.cards.length === 0} > {column.cards.map((card) => ( <SortableCard key={card.id} card={card} /> ))} </ColumnDrop> </SortableContext> </div> ))} </div> <DragOverlay> {activeCard ? ( <div className="cursor-grabbing rounded-lg bg-background px-3 py-3 ring-1 ring-border"> <CardBody card={activeCard} /> </div> ) : null} </DragOverlay> </DndContext> </div> </section> );}Installation
npx @arctis-sh/@arctis-sh/ui@latest add kanban-01Usage
import { Kanban01 } from "@/components/blocks/kanban/kanban-01"<Kanban01 />Team board
A team board with priority badges, assignees, and add-task actions.
Team board
Drag cards between columns — they make space as you move.
Backlog
Audit unused tokens
MCMCDraft onboarding email
JHJH
Active
Checkout promo field
RKRK
Review
Gallery marquee pass
MCMC
"use client";import { useMemo, useState, type ReactNode } from "react";import { DndContext, DragOverlay, PointerSensor, closestCorners, useDroppable, useSensor, useSensors, type DragEndEvent, type DragOverEvent, type DragStartEvent,} from "@dnd-kit/core";import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy,} from "@dnd-kit/sortable";import { CSS } from "@dnd-kit/utilities";import { Avatar, AvatarFallback } from "@/components/ui/avatar";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";type Kanban02Props = { className?: string;};type Card = { id: string; title: string; priority: "Low" | "Med" | "High"; assignee: string;};type Column = { id: string; title: string; cards: Card[];};const INITIAL: Column[] = [ { id: "backlog", title: "Backlog", cards: [ { id: "c1", title: "Audit unused tokens", priority: "Low", assignee: "MC", }, { id: "c2", title: "Draft onboarding email", priority: "Med", assignee: "JH", }, ], }, { id: "active", title: "Active", cards: [ { id: "c3", title: "Checkout promo field", priority: "High", assignee: "RK", }, ], }, { id: "review", title: "Review", cards: [ { id: "c4", title: "Gallery marquee pass", priority: "Med", assignee: "MC", }, ], },];function findColumnId(columns: Column[], id: string) { if (columns.some((column) => column.id === id)) return id; return columns.find((column) => column.cards.some((card) => card.id === id)) ?.id;}function CardBody({ card }: { card: Card }) { return ( <> <div className="flex items-start justify-between gap-2"> <p className="text-sm font-medium tracking-wide text-foreground"> {card.title} </p> <Badge variant={card.priority === "High" ? "default" : "secondary"}> {card.priority} </Badge> </div> <div className="mt-3 flex items-center gap-2"> <Avatar size="xs"> <AvatarFallback>{card.assignee}</AvatarFallback> </Avatar> <span className="text-xs tracking-wide text-muted-foreground"> {card.assignee} </span> </div> </> );}function SortableCard({ card }: { card: Card }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: card.id }); return ( <li ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, }} className={cn( "cursor-grab rounded-xl border border-border bg-background px-3 py-3 active:cursor-grabbing", isDragging && "opacity-0", )} {...attributes} {...listeners} > <CardBody card={card} /> </li> );}function PlusIcon({ className }: { className?: string }) { return ( <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={className} > <path d="M12 5v14" /> <path d="M5 12h14" /> </svg> );}function ColumnDrop({ id, children, empty,}: { id: string; children: ReactNode; empty: boolean;}) { const { setNodeRef } = useDroppable({ id }); return ( <ul ref={setNodeRef} className="flex flex-col gap-2"> {empty ? ( <li className="rounded-xl border border-dashed border-border px-3 py-3 text-center text-xs tracking-wide text-muted-foreground"> Empty </li> ) : ( children )} </ul> );}export function Kanban02({ className }: Kanban02Props) { const [columns, setColumns] = useState(INITIAL); const [activeId, setActiveId] = useState<string | null>(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), ); const activeCard = useMemo(() => { if (!activeId) return null; for (const column of columns) { const card = column.cards.find((item) => item.id === activeId); if (card) return card; } return null; }, [activeId, columns]); function addCard(columnId: string) { setColumns((current) => current.map((column) => column.id === columnId ? { ...column, cards: [ ...column.cards, { id: `${columnId}-${Date.now()}`, title: "New task", priority: "Low", assignee: "You", }, ], } : column, ), ); } function handleDragStart(event: DragStartEvent) { setActiveId(String(event.active.id)); } function handleDragOver(event: DragOverEvent) { const { active, over } = event; if (!over) return; const activeCardId = String(active.id); const overId = String(over.id); setColumns((current) => { const fromId = findColumnId(current, activeCardId); const toId = findColumnId(current, overId); if (!fromId || !toId || fromId === toId) return current; const fromColumn = current.find((column) => column.id === fromId); const toColumn = current.find((column) => column.id === toId); if (!fromColumn || !toColumn) return current; const moving = fromColumn.cards.find((card) => card.id === activeCardId); if (!moving) return current; const overIndex = toColumn.cards.findIndex((card) => card.id === overId); const insertIndex = overIndex >= 0 ? overIndex : toColumn.cards.length; return current.map((column) => { if (column.id === fromId) { return { ...column, cards: column.cards.filter((card) => card.id !== activeCardId), }; } if (column.id === toId) { const next = column.cards.filter((card) => card.id !== activeCardId); next.splice(insertIndex, 0, moving); return { ...column, cards: next }; } return column; }); }); } function handleDragEnd(event: DragEndEvent) { const { active, over } = event; setActiveId(null); if (!over) return; const activeCardId = String(active.id); const overId = String(over.id); setColumns((current) => { const columnId = findColumnId(current, activeCardId); if (!columnId) return current; if (findColumnId(current, overId) !== columnId) return current; const column = current.find((item) => item.id === columnId); if (!column) return current; const oldIndex = column.cards.findIndex((card) => card.id === activeCardId); const newIndex = overId === columnId ? column.cards.length - 1 : column.cards.findIndex((card) => card.id === overId); if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return current; return current.map((item) => item.id === columnId ? { ...item, cards: arrayMove(item.cards, oldIndex, newIndex) } : item, ); }); } return ( <section className={cn("@container w-full bg-background", className)}> <div className="mx-auto w-full max-w-5xl px-4 py-10 @[32rem]:px-6 @[32rem]:py-14"> <div> <h2 className="text-xl font-medium tracking-wide text-foreground @[32rem]:text-2xl"> Team board </h2> <p className="mt-2 text-sm tracking-wide text-muted-foreground"> Drag cards between columns — they make space as you move. </p> </div> <DndContext id="kanban-02" sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd} > <div className="mt-8 flex flex-col gap-6 @[40rem]:grid @[40rem]:grid-cols-3 @[40rem]:gap-4"> {columns.map((column) => ( <div key={column.id} className="flex w-full flex-col gap-3"> <div className="flex items-center justify-between gap-2 px-1"> <h3 className="text-sm font-medium tracking-wide text-foreground"> {column.title} </h3> <div className="flex items-center gap-1"> <span className="text-xs tracking-wide text-muted-foreground tabular-nums"> {column.cards.length} </span> <Button type="button" size="icon-sm" variant="ghost" aria-label={`Add task to ${column.title}`} onClick={() => addCard(column.id)} > <PlusIcon /> </Button> </div> </div> <SortableContext id={`kanban-02-${column.id}`} items={column.cards.map((card) => card.id)} strategy={verticalListSortingStrategy} > <ColumnDrop id={column.id} empty={column.cards.length === 0} > {column.cards.map((card) => ( <SortableCard key={card.id} card={card} /> ))} </ColumnDrop> </SortableContext> </div> ))} </div> <DragOverlay> {activeCard ? ( <div className="cursor-grabbing rounded-xl border border-border bg-background px-3 py-3 ring-1 ring-border"> <CardBody card={activeCard} /> </div> ) : null} </DragOverlay> </DndContext> </div> </section> );}Installation
npx @arctis-sh/@arctis-sh/ui@latest add kanban-02Usage
import { Kanban02 } from "@/components/blocks/kanban/kanban-02"<Kanban02 />Filtered board
A column board with quick filters for each team.
Filtered board
Filter by team, then drag cards into place.
Ready
3Icon pass for empty states
design
Footer link audit
content
Kanban drag affordance
eng
Building
2Settings form layout
eng
Team portrait hover
design
Shipped
2Login / signup pair
eng
Pricing ticker
design
"use client";import { useMemo, useState, type ReactNode } from "react";import { DndContext, DragOverlay, PointerSensor, closestCorners, useDroppable, useSensor, useSensors, type DragEndEvent, type DragOverEvent, type DragStartEvent,} from "@dnd-kit/core";import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy,} from "@dnd-kit/sortable";import { CSS } from "@dnd-kit/utilities";import { cn } from "@/lib/utils";type Kanban03Props = { className?: string;};type Filter = "all" | "design" | "eng" | "content";type Tag = Exclude<Filter, "all">;type Card = { id: string; title: string; tag: Tag;};type Column = { id: string; title: string; cards: Card[];};const FILTERS: { id: Filter; label: string }[] = [ { id: "all", label: "All" }, { id: "design", label: "Design" }, { id: "eng", label: "Eng" }, { id: "content", label: "Content" },];const INITIAL: Column[] = [ { id: "todo", title: "Ready", cards: [ { id: "a1", title: "Icon pass for empty states", tag: "design" }, { id: "a2", title: "Footer link audit", tag: "content" }, { id: "a3", title: "Kanban drag affordance", tag: "eng" }, ], }, { id: "doing", title: "Building", cards: [ { id: "a4", title: "Settings form layout", tag: "eng" }, { id: "a5", title: "Team portrait hover", tag: "design" }, ], }, { id: "done", title: "Shipped", cards: [ { id: "a6", title: "Login / signup pair", tag: "eng" }, { id: "a7", title: "Pricing ticker", tag: "design" }, ], },];function findColumnId(columns: Column[], id: string) { if (columns.some((column) => column.id === id)) return id; return columns.find((column) => column.cards.some((card) => card.id === id)) ?.id;}function CardBody({ card }: { card: Card }) { return ( <> <p className="text-sm tracking-wide text-foreground">{card.title}</p> <p className="mt-1 text-[11px] tracking-wide text-muted-foreground capitalize"> {card.tag} </p> </> );}function SortableCard({ card }: { card: Card }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: card.id }); return ( <li ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, }} className={cn( "cursor-grab rounded-lg bg-muted px-3 py-2.5 active:cursor-grabbing", isDragging && "opacity-0", )} {...attributes} {...listeners} > <CardBody card={card} /> </li> );}function ColumnDrop({ id, children, empty,}: { id: string; children: ReactNode; empty: boolean;}) { const { setNodeRef } = useDroppable({ id }); return ( <ul ref={setNodeRef} className="flex flex-col gap-2"> {empty ? ( <li className="rounded-lg border border-dashed border-border px-3 py-2.5 text-center text-xs tracking-wide text-muted-foreground"> Empty </li> ) : ( children )} </ul> );}export function Kanban03({ className }: Kanban03Props) { const [filter, setFilter] = useState<Filter>("all"); const [columns, setColumns] = useState(INITIAL); const [activeId, setActiveId] = useState<string | null>(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), ); const visibleColumns = useMemo( () => columns.map((column) => ({ ...column, cards: filter === "all" ? column.cards : column.cards.filter((card) => card.tag === filter), })), [columns, filter], ); const activeCard = useMemo(() => { if (!activeId) return null; for (const column of columns) { const card = column.cards.find((item) => item.id === activeId); if (card) return card; } return null; }, [activeId, columns]); function handleDragStart(event: DragStartEvent) { setActiveId(String(event.active.id)); } function handleDragOver(event: DragOverEvent) { const { active, over } = event; if (!over) return; const activeCardId = String(active.id); const overId = String(over.id); setColumns((current) => { const fromId = findColumnId(current, activeCardId); const toId = findColumnId(current, overId); if (!fromId || !toId || fromId === toId) return current; const fromColumn = current.find((column) => column.id === fromId); const toColumn = current.find((column) => column.id === toId); if (!fromColumn || !toColumn) return current; const moving = fromColumn.cards.find((card) => card.id === activeCardId); if (!moving) return current; const overIndex = toColumn.cards.findIndex((card) => card.id === overId); const insertIndex = overIndex >= 0 ? overIndex : toColumn.cards.length; return current.map((column) => { if (column.id === fromId) { return { ...column, cards: column.cards.filter((card) => card.id !== activeCardId), }; } if (column.id === toId) { const next = column.cards.filter((card) => card.id !== activeCardId); next.splice(insertIndex, 0, moving); return { ...column, cards: next }; } return column; }); }); } function handleDragEnd(event: DragEndEvent) { const { active, over } = event; setActiveId(null); if (!over) return; const activeCardId = String(active.id); const overId = String(over.id); setColumns((current) => { const columnId = findColumnId(current, activeCardId); if (!columnId) return current; if (findColumnId(current, overId) !== columnId) return current; const column = current.find((item) => item.id === columnId); if (!column) return current; const oldIndex = column.cards.findIndex((card) => card.id === activeCardId); const newIndex = overId === columnId ? column.cards.length - 1 : column.cards.findIndex((card) => card.id === overId); if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return current; return current.map((item) => item.id === columnId ? { ...item, cards: arrayMove(item.cards, oldIndex, newIndex) } : item, ); }); } return ( <section className={cn("@container w-full bg-background", className)}> <div className="mx-auto w-full max-w-5xl px-4 py-10 @[32rem]:px-6 @[32rem]:py-14"> <div className="flex flex-col gap-4 @[40rem]:flex-row @[40rem]:items-end @[40rem]:justify-between"> <div> <h2 className="text-xl font-medium tracking-wide text-foreground @[32rem]:text-2xl"> Filtered board </h2> <p className="mt-2 text-sm tracking-wide text-muted-foreground"> Filter by team, then drag cards into place. </p> </div> <div className="flex flex-wrap gap-1.5"> {FILTERS.map((item) => ( <button key={item.id} type="button" onClick={() => setFilter(item.id)} className={cn( "rounded-md px-2.5 py-1 text-xs tracking-wide transition-colors duration-200 ease-out", filter === item.id ? "bg-foreground text-background" : "bg-muted text-muted-foreground hover:text-foreground", )} > {item.label} </button> ))} </div> </div> <DndContext id="kanban-03" sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd} > <div className="mt-8 flex flex-col gap-6 @[40rem]:grid @[40rem]:grid-cols-3 @[40rem]:gap-4"> {visibleColumns.map((column) => ( <div key={column.id} className="flex w-full flex-col gap-2"> <div className="flex items-center justify-between gap-2 border-b border-border pb-2"> <h3 className="text-sm font-medium tracking-wide text-foreground"> {column.title} </h3> <span className="text-xs tracking-wide text-muted-foreground tabular-nums"> {column.cards.length} </span> </div> <SortableContext id={`kanban-03-${column.id}`} items={column.cards.map((card) => card.id)} strategy={verticalListSortingStrategy} > <ColumnDrop id={column.id} empty={column.cards.length === 0}> {column.cards.map((card) => ( <SortableCard key={card.id} card={card} /> ))} </ColumnDrop> </SortableContext> </div> ))} </div> <DragOverlay> {activeCard ? ( <div className="cursor-grabbing rounded-lg bg-muted px-3 py-2.5 ring-1 ring-border"> <CardBody card={activeCard} /> </div> ) : null} </DragOverlay> </DndContext> </div> </section> );}Installation
npx @arctis-sh/@arctis-sh/ui@latest add kanban-03Usage
import { Kanban03 } from "@/components/blocks/kanban/kanban-03"<Kanban03 />