Kanban Board with Drag and Drop Cards and Live Counts

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.

Code

// 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

When to use it

How to customize it

Accessibility

Questions

How do the cards animate from one column to another?

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.

Why native drag and drop instead of a library?

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.

Can I reorder cards within a column?

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`.

How do I save changes?

All moves go through `moveCard(id, to)`. Call your API there with the card id and the new status.

How do I add a fourth column?

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.

More in Dashboard

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.

Product

Who It's For

Features

Resources

Company

View Sitemap