A three column task board: To do, In progress and Done. Each card has coloured tags, a title, an owner and a due date. Drag a card onto another column and it travels there with a spring while the other cards close the gap, the column count pops, and the progress bar in the header grows. Arrow buttons on every card do the same move for touch screens and keyboards. Two cards move by themselves on load so the preview shows the motion. On phones the columns stack one above the other.
// Adapted from Cult UI (https://www.cult-ui.com/docs/components/kanban-board)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "framer-motion";
import { ArrowLeft, ArrowRight, CheckCircle2, Circle, Clock3, Plus } from "lucide-react";
type Status = "todo" | "doing" | "done";
type Card = { id: string; title: string; tags: { label: string; tone: keyof typeof tones }[]; who: string; due: string };
const tones = {
feature: "bg-blue-50 text-blue-700",
bug: "bg-red-50 text-red-700",
design: "bg-purple-50 text-purple-700",
copy: "bg-cyan-50 text-cyan-800",
polish: "bg-amber-50 text-amber-800",
};
const columns: { id: Status; title: string }[] = [
{ id: "todo", title: "To do" },
{ id: "doing", title: "In progress" },
{ id: "done", title: "Done" },
];
const starting: Record<Status, Card[]> = {
todo: [
{ id: "k1", title: "Gift card balance check at checkout", tags: [{ label: "Feature", tone: "feature" }], who: "Imani Reyes", due: "Jun 18" },
{ id: "k2", title: "Size guide opens behind the cart drawer", tags: [{ label: "Bug", tone: "bug" }], who: "Tomas Lindqvist", due: "Jun 14" },
{ id: "k3", title: "Rewrite the returns page in plain words", tags: [{ label: "Copy", tone: "copy" }], who: "Priya Nair", due: "Jun 21" },
],
doing: [
{ id: "k4", title: "New product page gallery with zoom", tags: [{ label: "Design", tone: "design" }, { label: "Feature", tone: "feature" }], who: "Tomas Lindqvist", due: "Jun 12" },
{ id: "k5", title: "Faster search results on slow phones", tags: [{ label: "Polish", tone: "polish" }], who: "Imani Reyes", due: "Jun 13" },
],
done: [
{ id: "k6", title: "Free shipping bar in the cart", tags: [{ label: "Feature", tone: "feature" }], who: "Priya Nair", due: "Jun 6" },
{ id: "k7", title: "Summer sale banner artwork", tags: [{ label: "Design", tone: "design" }], who: "Imani Reyes", due: "Jun 4" },
],
};
const icons = {
todo: <Circle className="h-4 w-4 text-slate-400" aria-hidden="true" />,
doing: <Clock3 className="h-4 w-4 text-amber-500" aria-hidden="true" />,
done: <CheckCircle2 className="h-4 w-4 text-emerald-500" aria-hidden="true" />,
};
const avatarTint: Record<string, string> = { I: "bg-rose-200 text-rose-900", T: "bg-sky-200 text-sky-900", P: "bg-emerald-200 text-emerald-900" };
const initials = (name: string) => name.split(" ").map((w) => w[0]).join("").slice(0, 2);
const springy = { type: "spring" as const, stiffness: 500, damping: 34 };
export default function KanbanBoardWithDragAndDropCardsAndLiveCounts() {
const reduce = useReducedMotion();
const [board, setBoard] = useState(starting);
const [dragging, setDragging] = useState<string | null>(null);
const [over, setOver] = useState<Status | null>(null);
const [said, setSaid] = useState("");
const touched = useRef(false);
const moveCard = (id: string, to: Status) =>
setBoard((b) => {
const from = columns.find((c) => b[c.id].some((k) => k.id === id))?.id;
if (!from || from === to) return b;
const card = b[from].find((k) => k.id === id)!;
setSaid(`${card.title} moved to ${columns.find((c) => c.id === to)!.title}`);
return { ...b, [from]: b[from].filter((k) => k.id !== id), [to]: [card, ...b[to]] };
});
// Two moves on load so the preview shows cards travelling. They stop the moment someone interacts.
useEffect(() => {
if (reduce) return;
const a = window.setTimeout(() => !touched.current && moveCard("k2", "doing"), 1100);
const b = window.setTimeout(() => !touched.current && moveCard("k5", "done"), 2500);
return () => {
window.clearTimeout(a);
window.clearTimeout(b);
};
}, [reduce]);
const total = columns.reduce((n, c) => n + board[c.id].length, 0);
const progress = Math.round((board.done.length / total) * 100);
return (
<section className="bg-slate-100 px-4 py-12 sm:px-6 sm:py-16">
<div className="mx-auto max-w-5xl rounded-3xl border border-slate-200 bg-white p-4 shadow-xl shadow-slate-300/40 sm:p-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-slate-600">Fernhill Outfitters ยท Storefront team</p>
<h2 className="mt-0.5 text-2xl font-semibold tracking-tight text-slate-900">June sprint</h2>
</div>
<div className="flex items-center gap-3">
<div className="h-2 w-28 overflow-hidden rounded-full bg-slate-200" aria-hidden="true">
<motion.div className="h-full rounded-full bg-emerald-500" initial={false} animate={{ width: `${progress}%` }} transition={reduce ? { duration: 0 } : springy} />
</div>
<p className="text-sm tabular-nums text-slate-700">
<span className="font-semibold text-slate-900">{board.done.length}</span> of {total} done
</p>
</div>
</div>
<p role="status" className="sr-only">
{said}
</p>
<LayoutGroup>
<div className="mt-6 grid gap-4 md:grid-cols-3">
{columns.map((col, ci) => {
const cards = board[col.id];
const active = over === col.id && dragging !== null;
return (
<motion.div
key={col.id}
onDragOver={(e) => {
e.preventDefault();
setOver(col.id);
}}
onDragLeave={() => setOver((o) => (o === col.id ? null : o))}
onDrop={(e) => {
e.preventDefault();
if (dragging) moveCard(dragging, col.id);
setOver(null);
setDragging(null);
}}
animate={{ backgroundColor: active ? "rgba(59,130,246,0.08)" : "rgba(241,245,249,0.7)", scale: active ? 1.012 : 1 }}
transition={springy}
className="flex min-h-[220px] flex-col rounded-2xl p-3 md:min-h-[430px]"
>
<div className="mb-3 flex items-center gap-2 px-1">
{icons[col.id]}
<h3 className="text-sm font-semibold text-slate-900">{col.title}</h3>
<motion.span key={cards.length} initial={reduce ? false : { scale: 0.5, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={springy} className="ml-auto rounded-full bg-white px-2 py-0.5 text-xs font-semibold tabular-nums text-slate-700 ring-1 ring-slate-200">
{cards.length}
</motion.span>
</div>
<motion.div aria-hidden="true" initial={false} animate={{ opacity: active ? 1 : 0, scaleX: active ? 1 : 0.3 }} transition={springy} className="mx-auto mb-2 h-[3px] w-3/5 rounded-full bg-gradient-to-r from-transparent via-blue-400 to-transparent" />
<ul className="flex flex-1 flex-col gap-2">
<AnimatePresence initial={false} mode="popLayout">
{cards.map((card, i) => (
<motion.li
key={card.id}
layout={reduce ? false : true}
layoutId={reduce ? undefined : card.id}
initial={reduce ? false : { opacity: 0, y: 8, scale: 0.97 }}
animate={{ opacity: col.id === "done" ? 0.72 : 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.12 } }}
transition={{ type: "spring", stiffness: 300, damping: 28, delay: reduce ? 0 : i * 0.03, layout: springy }}
className="relative"
style={{ zIndex: dragging === card.id ? 30 : 1 }}
>
<div
draggable
onDragStart={(e) => {
touched.current = true;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", card.id);
setDragging(card.id);
}}
onDragEnd={() => {
setDragging(null);
setOver(null);
}}
className={`cursor-grab select-none rounded-xl bg-white p-3 ring-1 ring-slate-200 transition-shadow duration-200 hover:shadow-md active:cursor-grabbing ${dragging === card.id ? "shadow-xl ring-blue-300" : "shadow-sm"}`}
>
<div className="flex flex-wrap gap-1">
{card.tags.map((t) => (
<span key={t.label} className={`rounded-md px-1.5 py-0.5 text-[11px] font-semibold ${tones[t.tone]}`}>
{t.label}
</span>
))}
</div>
<p className={`mt-2 text-sm font-medium leading-snug text-slate-900 ${col.id === "done" ? "line-through decoration-slate-400" : ""}`}>{card.title}</p>
<div className="mt-2 flex items-center gap-2">
<span title={card.who} className={`grid h-6 w-6 place-items-center rounded-full text-[10px] font-bold ${avatarTint[card.who[0]] ?? "bg-slate-200 text-slate-800"}`}>
{initials(card.who)}
</span>
<span className="text-xs tabular-nums text-slate-600">{card.due}</span>
<span className="ml-auto flex">
<button
type="button"
disabled={ci === 0}
aria-label={`Move "${card.title}" back to ${columns[ci - 1]?.title ?? ""}`}
onClick={() => {
touched.current = true;
moveCard(card.id, columns[ci - 1].id);
}}
className="grid h-10 w-9 place-items-center rounded-lg text-slate-500 outline-none hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-25"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
disabled={ci === columns.length - 1}
aria-label={`Move "${card.title}" on to ${columns[ci + 1]?.title ?? ""}`}
onClick={() => {
touched.current = true;
moveCard(card.id, columns[ci + 1].id);
}}
className="grid h-10 w-9 place-items-center rounded-lg text-slate-500 outline-none hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-25"
>
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</button>
</span>
</div>
</div>
</motion.li>
))}
</AnimatePresence>
</ul>
{col.id === "todo" && (
<a href="#" className="mt-2 inline-flex h-10 items-center gap-1.5 rounded-xl px-2 text-sm font-medium text-slate-600 outline-none hover:bg-white hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-blue-500">
<Plus className="h-4 w-4" aria-hidden="true" /> Add a card
</a>
)}
</motion.div>
);
})}
</div>
</LayoutGroup>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/kanban-board-with-drag-and-drop-cards-and-live-counts.json
Each card is a `motion.li` with a `layoutId`. When a card leaves one list and appears in another inside the same `LayoutGroup`, Framer Motion animates it from the old position to the new one. The remaining cards have `layout` set, so they slide to fill the space.
The browser's `draggable` attribute with `onDragOver` and `onDrop` on the columns is enough for moving cards between lists on desktop and adds no dependency. The arrow buttons cover touch devices, where native drag events do not fire.
Not in this version: a moved card goes to the top of its new column. For ordering within a list, wrap each column in Framer Motion's `Reorder.Group`.
All moves go through `moveCard(id, to)`. Call your API there with the card id and the new status.
Add it to `columns`, `starting` and `icons`, extend the `Status` type, and change `md:grid-cols-3` to `md:grid-cols-4`.
Adapted from Kanban Board in Cult UI, used under its open-source licence. Licences and credits
Related: Feature Request Voting Board with Live Reordering, App Shell with Sidebar and Top Bar, Drag to Reorder Checklist with Slide Out Delete.
More dashboard or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.