Kanban Board with Spring Physics Drag and Self Playing Demo

A four column task board on warm paper tones, written without a drag and drop library. Pick up a card and it lifts, grows a shadow and leans in the direction you throw it. Let go and it springs into the nearest slot while its neighbours slide out of the way, a dashed outline marks where it will land, and the column counts pop. Until someone touches it, a small cursor picks cards up and moves them through the stages on a loop. A progress bar counts what has shipped. On phones the board folds into a two by two grid and cards are dragged by their grip.

Code

import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent, PointerEvent } from "react";
import { AnimatePresence, animate, motion, useInView, useMotionValue, useReducedMotion, useSpring, useTransform, useVelocity } from "framer-motion";

type Tag = "Design" | "Build" | "Copy" | "QA";
type Card = { id: string; title: string; tag: Tag; who: string; due: string; notes: number };

const columns = [
  { name: "Backlog", dot: "bg-stone-400" },
  { name: "In progress", dot: "bg-orange-500" },
  { name: "Review", dot: "bg-violet-500" },
  { name: "Shipped", dot: "bg-emerald-500" },
];

const cards: Card[] = [
  { id: "a", title: "Empty state for the trips list", tag: "Design", who: "MO", due: "Thu", notes: 3 },
  { id: "b", title: "Rewrite the paywall headline", tag: "Copy", who: "JS", due: "Fri", notes: 1 },
  { id: "c", title: "Offline map tiles cache", tag: "Build", who: "TK", due: "Mon", notes: 6 },
  { id: "d", title: "Push alerts for gate changes", tag: "Build", who: "AR", due: "Wed", notes: 4 },
  { id: "e", title: "Boarding pass widget, dark mode", tag: "Design", who: "MO", due: "Wed", notes: 2 },
  { id: "f", title: "Seat picker on small phones", tag: "QA", who: "LN", due: "Today", notes: 8 },
  { id: "g", title: "Currency switcher in checkout", tag: "Build", who: "TK", due: "Done", notes: 5 },
  { id: "h", title: "Welcome email, three variants", tag: "Copy", who: "JS", due: "Done", notes: 2 },
];

const START: string[][] = [["a", "b", "c"], ["d", "e"], ["f"], ["g", "h"]];
/** The demo script: [card id, target column, position in that column]. */
const MOVES: [string, number, number][] = [
  ["c", 1, 0],
  ["f", 3, 0],
  ["d", 2, 0],
  ["a", 1, 1],
  ["e", 2, 1],
];

const tagStyle: Record<Tag, string> = {
  Design: "bg-violet-100 text-violet-800",
  Build: "bg-sky-100 text-sky-800",
  Copy: "bg-amber-100 text-amber-900",
  QA: "bg-emerald-100 text-emerald-800",
};
const SPRING = { type: "spring" as const, stiffness: 260, damping: 22, mass: 0.9 };

function place(board: string[][], id: string, col: number, index: number): string[][] {
  const next = board.map((c) => c.filter((x) => x !== id));
  const at = Math.min(Math.max(index, 0), next[col].length);
  next[col] = [...next[col].slice(0, at), id, ...next[col].slice(at)];
  return next;
}

/** Every column and card position is worked out from the board width, so cards can be moved with transforms. */
function layoutOf(width: number, board: string[][]) {
  const wide = width >= 700;
  const perRow = wide ? 4 : 2;
  const gap = wide ? 14 : 10;
  const colW = (width - gap * (perRow - 1)) / perRow;
  const head = 46;
  const pad = 8;
  const cardH = 128;
  const step = cardH + 8;
  const rows = wide ? [[0, 1, 2, 3]] : [[0, 1], [2, 3]];
  const cols: { x: number; y: number; w: number; h: number }[] = [];
  let y = 0;
  for (const row of rows) {
    const slots = Math.max(wide ? 3 : 2, ...row.map((c) => board[c].length));
    const h = head + slots * step + pad;
    row.forEach((c, i) => (cols[c] = { x: i * (colW + gap), y, w: colW, h }));
    y += h + gap;
  }
  const slot = (col: number, index: number) => ({ x: cols[col].x + pad, y: cols[col].y + head + index * step });
  return { wide, cols, slot, cardW: colW - pad * 2, cardH, step, head, height: y - gap };
}

type CardProps = {
  card: Card;
  x: number;
  y: number;
  w: number;
  h: number;
  col: number;
  order: number;
  lifted: boolean;
  ghost: boolean;
  dragging: boolean;
  top: boolean;
  reduce: boolean;
  onGrab: (id: string) => void;
  onMove: (id: string, cx: number, cy: number) => void;
  onDrop: () => void;
  onKey: (id: string, e: KeyboardEvent<HTMLDivElement>) => void;
};

function BoardCard({ card, x, y, w, h, col, order, lifted, ghost, dragging, top, reduce, onGrab, onMove, onDrop, onKey }: CardProps) {
  const mx = useMotionValue(x);
  const my = useMotionValue(y);
  // The card leans into its own speed, which is what makes the throw feel physical.
  const lean = useSpring(useTransform(useVelocity(mx), [-1600, 0, 1600], [-10, 0, 10], { clamp: true }), { stiffness: 260, damping: 18 });
  const grab = useRef<{ px: number; py: number; sx: number; sy: number } | null>(null);

  useEffect(() => {
    if (dragging) return;
    if (reduce) {
      mx.set(x);
      my.set(y);
      return;
    }
    const ax = animate(mx, x, SPRING);
    const ay = animate(my, y, SPRING);
    return () => {
      ax.stop();
      ay.stop();
    };
  }, [x, y, dragging, reduce, mx, my]);

  const down = (e: PointerEvent<HTMLDivElement>) => {
    if (e.button !== 0) return;
    // With a finger only the grip starts a drag, so the page can still be scrolled over the cards.
    if (e.pointerType !== "mouse" && !(e.target as HTMLElement).closest("[data-grip]")) return;
    e.currentTarget.setPointerCapture(e.pointerId);
    grab.current = { px: e.clientX, py: e.clientY, sx: mx.get(), sy: my.get() };
    onGrab(card.id);
  };
  const move = (e: PointerEvent<HTMLDivElement>) => {
    const g = grab.current;
    if (!g) return;
    mx.set(g.sx + e.clientX - g.px);
    my.set(g.sy + e.clientY - g.py);
    onMove(card.id, mx.get() + w / 2, my.get() + h / 2);
  };
  const up = () => {
    if (!grab.current) return;
    grab.current = null;
    onDrop();
  };

  const raised = lifted || dragging;
  return (
    <motion.div
      role="listitem"
      tabIndex={0}
      aria-roledescription="movable card"
      aria-label={`${card.title}. ${card.tag}. In ${columns[col].name}. Use the arrow keys to move it.`}
      onKeyDown={(e) => onKey(card.id, e)}
      onPointerDown={down}
      onPointerMove={move}
      onPointerUp={up}
      onPointerCancel={up}
      style={{ x: mx, y: my, width: w, height: h, zIndex: raised ? 30 : top ? 20 : 1 }}
      className="group absolute left-0 top-0 cursor-grab rounded-2xl focus:outline-none active:cursor-grabbing"
    >
      <motion.div
        style={{ rotate: reduce ? 0 : lean }}
        initial={reduce ? false : { opacity: 0, y: 18 }}
        animate={{ opacity: 1, y: 0, scale: raised ? 1.05 : 1 }}
        transition={{ ...SPRING, delay: raised || top ? 0 : undefined, opacity: { duration: 0.35, delay: reduce ? 0 : 0.08 + order * 0.05 }, y: { ...SPRING, delay: reduce ? 0 : 0.08 + order * 0.05 } }}
        className={`flex h-full select-none flex-col rounded-2xl border bg-white p-3 transition-shadow duration-200 group-focus-visible:ring-2 group-focus-visible:ring-orange-500 group-focus-visible:ring-offset-2 ${
          raised ? "border-orange-300 shadow-2xl shadow-orange-900/25" : "border-stone-200 shadow-sm shadow-stone-900/5"
        }`}
      >
        <div className="flex items-center justify-between gap-2">
          <span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${tagStyle[card.tag]}`}>{card.tag}</span>
          <span data-grip="" aria-hidden="true" className="-m-2 flex h-10 w-10 touch-none items-center justify-center text-stone-400">
            <svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor">
              <circle cx="7" cy="5" r="1.5" />
              <circle cx="13" cy="5" r="1.5" />
              <circle cx="7" cy="10" r="1.5" />
              <circle cx="13" cy="10" r="1.5" />
              <circle cx="7" cy="15" r="1.5" />
              <circle cx="13" cy="15" r="1.5" />
            </svg>
          </span>
        </div>
        <p className="mt-1.5 line-clamp-2 text-sm font-semibold leading-snug text-stone-900">{card.title}</p>
        <div className="mt-auto flex items-center justify-between text-xs font-medium text-stone-600">
          <span className="flex items-center gap-1.5">
            <span className="flex h-6 w-6 items-center justify-center rounded-full bg-stone-900 text-[10px] font-semibold text-white">{card.who}</span>
            {col === 3 ? (
              <span className="flex items-center gap-1 text-emerald-700">
                <svg viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <path d="M4 10.5l4 4 8-9" />
                </svg>
                Shipped
              </span>
            ) : (
              <span>Due {card.due}</span>
            )}
          </span>
          <span className="flex items-center gap-1 tabular-nums">
            <svg viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" aria-hidden="true">
              <path d="M3 4.5h14v9H9l-4 3v-3H3z" />
            </svg>
            {card.notes}
          </span>
        </div>
      </motion.div>
      <AnimatePresence>
        {ghost && (
          <motion.svg
            aria-hidden="true"
            viewBox="0 0 24 24"
            className="pointer-events-none absolute left-[58%] top-[52%] h-7 w-7 drop-shadow-md"
            initial={{ opacity: 0, scale: 0.4, x: 26, y: 26 }}
            animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
            exit={{ opacity: 0, scale: 0.6, x: 18, y: 18 }}
            transition={{ type: "spring", stiffness: 300, damping: 22 }}
          >
            <path d="M5 3l14 7.5-6.2 1.6L10.5 19z" fill="#1c1917" stroke="#fff" strokeWidth="1.6" strokeLinejoin="round" />
          </motion.svg>
        )}
      </AnimatePresence>
    </motion.div>
  );
}

export default function KanbanBoardWithSpringPhysicsDragAndSelfPlayingDemo() {
  const reduce = !!useReducedMotion();
  const [board, setBoard] = useState(START);
  const [preview, setPreview] = useState<{ id: string; col: number; index: number } | null>(null);
  const [lifted, setLifted] = useState<string | null>(null);
  const [last, setLast] = useState<string | null>(null);
  const [auto, setAuto] = useState(true);
  const [step, setStep] = useState(0);
  const [width, setWidth] = useState(1040);
  const [note, setNote] = useState("");
  const frame = useRef<HTMLDivElement>(null);
  const idle = useRef<number | undefined>(undefined);
  const visible = useInView(frame, { amount: 0.25 });

  useEffect(() => {
    const el = frame.current;
    if (!el) return;
    const measure = () => setWidth(el.clientWidth);
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // Self demo: lift a card, throw it to its new column, let it settle, repeat.
  useEffect(() => {
    if (!auto || reduce || !visible) return;
    const timers: number[] = [];
    const later = (fn: () => void, ms: number) => timers.push(window.setTimeout(fn, ms));
    if (step >= MOVES.length) {
      later(() => {
        setBoard(START);
        setLast(null);
        setStep(0);
      }, 2200);
    } else {
      const [id, col, index] = MOVES[step];
      const wait = step === 0 ? 1000 : 650;
      later(() => setLifted(id), wait);
      later(() => {
        setBoard((b) => place(b, id, col, index));
        setLast(id);
      }, wait + 430);
      later(() => {
        setLifted(null);
        setStep((s) => s + 1);
      }, wait + 1080);
    }
    return () => timers.forEach((t) => window.clearTimeout(t));
  }, [auto, reduce, visible, step]);

  useEffect(() => () => window.clearTimeout(idle.current), []);

  const takeOver = () => {
    setAuto(false);
    setLifted(null);
    window.clearTimeout(idle.current);
    idle.current = window.setTimeout(() => {
      setStep(MOVES.length);
      setAuto(true);
    }, 7000);
  };

  const shown = preview ? place(board, preview.id, preview.col, preview.index) : board;
  const g = layoutOf(width, shown);
  const where = (id: string) => {
    const col = shown.findIndex((c) => c.includes(id));
    return { col, index: shown[col].indexOf(id) };
  };

  const onGrab = (id: string) => {
    takeOver();
    setLast(id);
    setPreview({ id, ...where(id) });
  };
  const onMove = (id: string, cx: number, cy: number) => {
    let col = 0;
    let best = Infinity;
    g.cols.forEach((c, i) => {
      const dx = Math.max(c.x - cx, 0, cx - (c.x + c.w));
      const dy = Math.max(c.y - cy, 0, cy - (c.y + c.h));
      if (dx + dy < best) {
        best = dx + dy;
        col = i;
      }
    });
    const others = board[col].filter((x) => x !== id).length;
    const index = Math.min(others, Math.max(0, Math.round((cy - g.cols[col].y - g.head - g.cardH / 2) / g.step)));
    setPreview((p) => (p && p.col === col && p.index === index ? p : { id, col, index }));
  };
  const onDrop = () => {
    if (!preview) return;
    setBoard(place(board, preview.id, preview.col, preview.index));
    setNote(`${cards.find((c) => c.id === preview.id)?.title} moved to ${columns[preview.col].name}.`);
    setPreview(null);
  };
  const onKey = (id: string, e: KeyboardEvent<HTMLDivElement>) => {
    const { col, index } = where(id);
    const to = { ArrowLeft: [col - 1, index], ArrowRight: [col + 1, index], ArrowUp: [col, index - 1], ArrowDown: [col, index + 1] }[e.key];
    if (!to || to[0] < 0 || to[0] > 3) return;
    e.preventDefault();
    takeOver();
    setLast(id);
    setBoard(place(board, id, to[0], to[1]));
    setNote(`${cards.find((c) => c.id === id)?.title} moved to ${columns[to[0]].name}.`);
  };

  const target = preview ? preview.col : lifted && step < MOVES.length ? MOVES[step][1] : -1;
  const hole = preview ? g.slot(preview.col, preview.index) : null;
  const shipped = shown[3].length;

  return (
    <section className="bg-stone-100 px-4 py-14 sm:px-6 sm:py-16">
      <div className="mx-auto max-w-5xl">
        <div className="flex flex-wrap items-end justify-between gap-4">
          <div>
            <p className="text-sm font-semibold text-orange-700">Sprint 14 ยท Fernhill travel app</p>
            <h2 className="mt-1 text-3xl font-semibold tracking-tight text-stone-900 sm:text-4xl">Launch board</h2>
            <p className="mt-2 max-w-md text-base text-stone-600">Drag a card to another column, or focus one and use the arrow keys. It plays by itself until you do.</p>
          </div>
          <div className="w-full sm:w-56">
            <p className="flex items-center justify-between text-sm font-medium text-stone-700">
              <span>Shipped</span>
              <span className="tabular-nums">
                {shipped} of {cards.length}
              </span>
            </p>
            <div className="mt-2 h-2 overflow-hidden rounded-full bg-stone-300">
              <motion.div
                className="h-full w-full origin-left rounded-full bg-emerald-500"
                initial={false}
                animate={{ scaleX: shipped / cards.length }}
                transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 160, damping: 18 }}
              />
            </div>
          </div>
        </div>

        <div ref={frame} role="list" aria-label="Launch board cards" className="relative mt-8 transition-[height] duration-300 motion-reduce:transition-none" style={{ height: g.height }}>
          {columns.map((c, i) => (
            <div
              key={c.name}
              className={`absolute left-0 top-0 rounded-3xl transition-[height,background-color,box-shadow] duration-300 motion-reduce:transition-none ${
                i === target ? "bg-orange-100 shadow-[inset_0_0_0_2px_#fb923c]" : "bg-stone-200/70"
              }`}
              style={{ transform: `translate(${g.cols[i].x}px, ${g.cols[i].y}px)`, width: g.cols[i].w, height: g.cols[i].h }}
            >
              <p className="flex h-[46px] items-center gap-2 px-4 text-sm font-semibold text-stone-800">
                <span className={`h-2 w-2 rounded-full ${c.dot}`} />
                {c.name}
                <motion.span
                  key={shown[i].length}
                  initial={reduce ? false : { scale: 1.5 }}
                  animate={{ scale: 1 }}
                  transition={{ type: "spring", stiffness: 400, damping: 14 }}
                  className="ml-auto flex h-6 min-w-[24px] items-center justify-center rounded-full bg-white px-1.5 text-xs tabular-nums text-stone-700"
                >
                  {shown[i].length}
                </motion.span>
              </p>
            </div>
          ))}
          {hole && (
            <div
              aria-hidden="true"
              className="absolute left-0 top-0 rounded-2xl border-2 border-dashed border-orange-400 bg-orange-50 transition-transform duration-200 motion-reduce:transition-none"
              style={{ transform: `translate(${hole.x}px, ${hole.y}px)`, width: g.cardW, height: g.cardH }}
            />
          )}
          {cards.map((card, order) => {
            const { col, index } = where(card.id);
            const pos = g.slot(col, index);
            return (
              <BoardCard
                key={card.id}
                card={card}
                x={pos.x}
                y={pos.y}
                w={g.cardW}
                h={g.cardH}
                col={col}
                order={order}
                lifted={lifted === card.id}
                ghost={lifted === card.id && auto}
                dragging={preview?.id === card.id}
                top={last === card.id}
                reduce={reduce}
                onGrab={onGrab}
                onMove={onMove}
                onDrop={onDrop}
                onKey={onKey}
              />
            );
          })}
        </div>
        <p className="sr-only" aria-live="polite">
          {note}
        </p>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/kanban-board-with-spring-physics-drag-and-self-playing-demo.json

When to use it

How to customize it

Accessibility

Questions

How does drag and drop work without a library?

The board measures its own width and works out a pixel position for every slot. Each card is absolutely positioned and moved with a transform. Pointer events move the card while it is held; on release the board state changes and the card animates from where it was dropped to its slot.

What makes the cards lean when they move?

Each card reads the velocity of its own x position with `useVelocity`, maps it to a few degrees of rotation and smooths that with a spring. A fast throw tilts the card and it swings upright as it settles.

How do the other cards make room?

While a card is held, the board keeps a `preview` of the column and index it is over. The layout is computed from that preview, so the neighbours already spring to their new slots before the card is dropped.

How do I save the board?

The whole state is one array of four id arrays. Send it to your server inside `onDrop` and the keyboard handler, right after `setBoard`.

Does it work on touch screens?

Yes. With a finger a drag starts only from the six dot grip, which has `touch-none`, so the page can still be scrolled by swiping over the rest of the card. With a mouse the whole card is the handle.

Related: Draggable Sticky Notes Board with Momentum, Feature Request Voting Board with Live Reordering, App Shell with Sidebar and Top Bar.

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