Web InnoventixFreeCode

Kanban Board

Original · free

kanban board with 3 columns and cards

byWeb InnoventixReact + Tailwind
kanbanboard
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/kanban-board.json
kanban-board.tsx
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
  KeyboardEvent as ReactKeyboardEvent,
  PointerEvent as ReactPointerEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";

type ColumnId = "backlog" | "progress" | "shipped";
type Priority = "high" | "medium" | "low";
type Tag = "Checkout" | "Payments" | "Tech debt" | "Growth" | "Performance";
type Board = Record<ColumnId, string[]>;
type Dir = "up" | "down" | "left" | "right";

type Person = { initials: string; name: string; ring: string };

type Card = {
  id: string;
  title: string;
  tag: Tag;
  priority: Priority;
  owner: Person;
  done: number;
  total: number;
  comments: number;
};

type DragState = {
  id: string;
  from: ColumnId;
  x: number;
  y: number;
  offX: number;
  offY: number;
  w: number;
};

type DropState = { col: ColumnId; index: number; y: number };

const ORDER: ColumnId[] = ["backlog", "progress", "shipped"];

const PEOPLE: Record<string, Person> = {
  ar: { initials: "AR", name: "Amara Reyes", ring: "bg-indigo-500" },
  km: { initials: "KM", name: "Kofi Mensah", ring: "bg-emerald-500" },
  ts: { initials: "TS", name: "Tomas Sandberg", ring: "bg-amber-500" },
  pn: { initials: "PN", name: "Priya Nair", ring: "bg-rose-500" },
};

const CARDS: Record<string, Card> = {
  "c-guest": {
    id: "c-guest",
    title: "Guest checkout without forcing account creation",
    tag: "Checkout",
    priority: "high",
    owner: PEOPLE.ar,
    done: 2,
    total: 5,
    comments: 4,
  },
  "c-autocomplete": {
    id: "c-autocomplete",
    title: "Retire the legacy address autocomplete widget",
    tag: "Tech debt",
    priority: "low",
    owner: PEOPLE.km,
    done: 0,
    total: 3,
    comments: 1,
  },
  "c-applepay": {
    id: "c-applepay",
    title: "Apple Pay button on product detail pages",
    tag: "Payments",
    priority: "medium",
    owner: PEOPLE.ts,
    done: 1,
    total: 4,
    comments: 6,
  },
  "c-webhooks": {
    id: "c-webhooks",
    title: "Audit the 12 Stripe webhooks nothing listens to",
    tag: "Tech debt",
    priority: "low",
    owner: PEOPLE.pn,
    done: 0,
    total: 2,
    comments: 0,
  },
  "c-split": {
    id: "c-split",
    title: "Split the payment step into shipping + billing",
    tag: "Checkout",
    priority: "high",
    owner: PEOPLE.pn,
    done: 3,
    total: 6,
    comments: 9,
  },
  "c-vat": {
    id: "c-vat",
    title: "Fix VAT rounding on EU invoices over €1,000",
    tag: "Payments",
    priority: "high",
    owner: PEOPLE.km,
    done: 4,
    total: 5,
    comments: 12,
  },
  "c-reorder": {
    id: "c-reorder",
    title: "One-tap reorder for returning buyers",
    tag: "Growth",
    priority: "medium",
    owner: PEOPLE.ts,
    done: 2,
    total: 4,
    comments: 3,
  },
  "c-inline": {
    id: "c-inline",
    title: "Inline card validation with real-time field errors",
    tag: "Checkout",
    priority: "medium",
    owner: PEOPLE.ar,
    done: 5,
    total: 5,
    comments: 8,
  },
  "c-bundle": {
    id: "c-bundle",
    title: "Cut the checkout bundle from 412 KB to 189 KB",
    tag: "Performance",
    priority: "high",
    owner: PEOPLE.pn,
    done: 6,
    total: 6,
    comments: 15,
  },
};

const INITIAL_BOARD: Board = {
  backlog: ["c-guest", "c-applepay", "c-autocomplete", "c-webhooks"],
  progress: ["c-vat", "c-split", "c-reorder"],
  shipped: ["c-bundle", "c-inline"],
};

const COLUMN_META: Record<
  ColumnId,
  { name: string; hint: string; limit: number | null; dot: string; pill: string }
> = {
  backlog: {
    name: "Backlog",
    hint: "Groomed, estimated, ready to pull",
    limit: null,
    dot: "bg-slate-400 dark:bg-zinc-500",
    pill: "bg-slate-100 text-slate-600 dark:bg-zinc-800 dark:text-zinc-300",
  },
  progress: {
    name: "In progress",
    hint: "WIP limit 3 — finish before you start",
    limit: 3,
    dot: "bg-indigo-500",
    pill:
      "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
  },
  shipped: {
    name: "Shipped",
    hint: "Behind a flag or fully rolled out",
    limit: null,
    dot: "bg-emerald-500",
    pill:
      "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
  },
};

const TAG_STYLE: Record<Tag, string> = {
  Checkout:
    "bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/30",
  Payments:
    "bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-500/30",
  "Tech debt":
    "bg-slate-100 text-slate-600 ring-slate-200 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-700",
  Growth:
    "bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-500/30",
  Performance:
    "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30",
};

const PRIORITY_STYLE: Record<Priority, { label: string; cls: string }> = {
  high: {
    label: "High",
    cls: "bg-rose-500/90 text-white dark:bg-rose-500/80",
  },
  medium: {
    label: "Med",
    cls: "bg-amber-400/90 text-amber-950 dark:bg-amber-400/80",
  },
  low: {
    label: "Low",
    cls: "bg-slate-300 text-slate-700 dark:bg-zinc-700 dark:text-zinc-200",
  },
};

function columnOf(board: Board, id: string): ColumnId | null {
  for (const col of ORDER) {
    if (board[col].includes(id)) return col;
  }
  return null;
}

function moveCard(
  board: Board,
  id: string,
  dir: Dir,
): { board: Board; col: ColumnId; index: number } | null {
  const from = columnOf(board, id);
  if (!from) return null;
  const fromIdx = board[from].indexOf(id);

  if (dir === "up" || dir === "down") {
    const next = dir === "up" ? fromIdx - 1 : fromIdx + 1;
    if (next < 0 || next >= board[from].length) return null;
    const arr = board[from].slice();
    arr.splice(fromIdx, 1);
    arr.splice(next, 0, id);
    return { board: { ...board, [from]: arr }, col: from, index: next };
  }

  const ci = ORDER.indexOf(from);
  const ti = dir === "left" ? ci - 1 : ci + 1;
  if (ti < 0 || ti >= ORDER.length) return null;
  const to = ORDER[ti];
  const src = board[from].slice();
  src.splice(fromIdx, 1);
  const tgt = board[to].slice();
  const insert = Math.min(fromIdx, tgt.length);
  tgt.splice(insert, 0, id);
  return { board: { ...board, [from]: src, [to]: tgt }, col: to, index: insert };
}

function placeCard(
  board: Board,
  id: string,
  to: ColumnId,
  rawIndex: number,
): { board: Board; col: ColumnId; index: number } {
  const from = columnOf(board, id);
  if (!from) return { board, col: to, index: rawIndex };
  const removeIdx = board[from].indexOf(id);

  if (from === to) {
    const arr = board[from].slice();
    arr.splice(removeIdx, 1);
    const insert = rawIndex > removeIdx ? rawIndex - 1 : rawIndex;
    arr.splice(insert, 0, id);
    return { board: { ...board, [from]: arr }, col: to, index: insert };
  }

  const src = board[from].slice();
  src.splice(removeIdx, 1);
  const tgt = board[to].slice();
  const insert = Math.min(rawIndex, tgt.length);
  tgt.splice(insert, 0, id);
  return {
    board: { ...board, [from]: src, [to]: tgt },
    col: to,
    index: insert,
  };
}

function CardFace({ card }: { card: Card }) {
  const pct = card.total === 0 ? 0 : Math.round((card.done / card.total) * 100);
  const prio = PRIORITY_STYLE[card.priority];
  return (
    <div className="py-3.5 pl-5 pr-3.5">
      <div className="flex items-start justify-between gap-2">
        <span
          className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-semibold ring-1 ring-inset ${TAG_STYLE[card.tag]}`}
        >
          {card.tag}
        </span>
        <span
          className={`inline-flex shrink-0 items-center rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide ${prio.cls}`}
        >
          {prio.label}
        </span>
      </div>

      <p className="mt-2.5 text-sm font-semibold leading-snug text-slate-800 dark:text-zinc-100">
        {card.title}
      </p>

      <div className="mt-3">
        <div className="flex items-center justify-between text-[11px] font-medium tabular-nums text-slate-400 dark:text-zinc-500">
          <span>
            {card.done}/{card.total} subtasks
          </span>
          <span>{pct}%</span>
        </div>
        <div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-zinc-800">
          <div
            className={`h-full rounded-full ${
              pct === 100 ? "bg-emerald-500" : "bg-indigo-500 dark:bg-indigo-400"
            }`}
            style={{ width: `${pct}%` }}
          />
        </div>
      </div>

      <div className="mt-3 flex items-center justify-between">
        <span className="flex items-center gap-2">
          <span
            aria-hidden="true"
            className={`grid h-6 w-6 place-items-center rounded-full text-[10px] font-bold text-white ${card.owner.ring}`}
          >
            {card.owner.initials}
          </span>
          <span className="text-[11px] font-medium text-slate-500 dark:text-zinc-400">
            {card.owner.name}
          </span>
        </span>
        <span className="flex items-center gap-1 text-[11px] font-medium tabular-nums text-slate-400 dark:text-zinc-500">
          <svg
            viewBox="0 0 24 24"
            className="h-3.5 w-3.5"
            fill="none"
            stroke="currentColor"
            strokeWidth={2}
            strokeLinecap="round"
            strokeLinejoin="round"
            aria-hidden="true"
          >
            <path d="M21 11.5a8.4 8.4 0 0 1-9 8.4 9.9 9.9 0 0 1-4-.9L3 21l1.9-4.6A8.4 8.4 0 1 1 21 11.5z" />
          </svg>
          {card.comments}
        </span>
      </div>
    </div>
  );
}

export default function KanbanBoard() {
  const reduce = useReducedMotion();

  const [board, setBoard] = useState<Board>(INITIAL_BOARD);
  const [drag, setDrag] = useState<DragState | null>(null);
  const [drop, setDrop] = useState<DropState | null>(null);
  const [lifted, setLifted] = useState<string | null>(null);
  const [message, setMessage] = useState("");

  const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
  const colRefs = useRef<Map<ColumnId, HTMLDivElement>>(new Map());
  const listRefs = useRef<Map<ColumnId, HTMLDivElement>>(new Map());
  const pendingRef = useRef<{
    id: string;
    col: ColumnId;
    startX: number;
    startY: number;
    offX: number;
    offY: number;
    w: number;
    pointerId: number;
  } | null>(null);
  const snapshotRef = useRef<Board | null>(null);

  const totals = useMemo(
    () => ORDER.reduce((n, c) => n + board[c].length, 0),
    [board],
  );

  const announcePlace = useCallback(
    (id: string, col: ColumnId, index: number, verb: string) => {
      setMessage(
        `${CARDS[id].title} ${verb} ${COLUMN_META[col].name}, position ${index + 1}.`,
      );
    },
    [],
  );

  const computeDrop = useCallback(
    (clientX: number, clientY: number): DropState | null => {
      for (const col of ORDER) {
        const colEl = colRefs.current.get(col);
        const listEl = listRefs.current.get(col);
        if (!colEl || !listEl) continue;
        const cr = colEl.getBoundingClientRect();
        if (clientX < cr.left || clientX > cr.right) continue;

        const ids = board[col];
        const rects = ids.map((id) =>
          cardRefs.current.get(id)?.getBoundingClientRect(),
        );
        let index = ids.length;
        for (let i = 0; i < ids.length; i++) {
          const r = rects[i];
          if (!r) continue;
          if (clientY < r.top + r.height / 2) {
            index = i;
            break;
          }
        }

        const listRect = listEl.getBoundingClientRect();
        let y: number;
        if (ids.length === 0) {
          y = 10;
        } else if (index === 0) {
          const first = rects[0];
          y = first ? first.top - listRect.top - 5 : 10;
        } else {
          const prev = rects[index - 1];
          y = prev ? prev.bottom - listRect.top + 5 : listRect.height - 10;
        }
        return { col, index, y };
      }
      return null;
    },
    [board],
  );

  const startPending = useCallback(
    (e: ReactPointerEvent, id: string, col: ColumnId) => {
      const el = cardRefs.current.get(id);
      if (!el) return;
      const r = el.getBoundingClientRect();
      pendingRef.current = {
        id,
        col,
        startX: e.clientX,
        startY: e.clientY,
        offX: e.clientX - r.left,
        offY: e.clientY - r.top,
        w: r.width,
        pointerId: e.pointerId,
      };
      el.setPointerCapture(e.pointerId);
    },
    [],
  );

  const onCardPointerDown = useCallback(
    (e: ReactPointerEvent<HTMLDivElement>, id: string, col: ColumnId) => {
      if (e.pointerType === "touch") return;
      if (e.button !== 0) return;
      startPending(e, id, col);
    },
    [startPending],
  );

  const onHandlePointerDown = useCallback(
    (e: ReactPointerEvent<HTMLSpanElement>, id: string, col: ColumnId) => {
      e.stopPropagation();
      startPending(e, id, col);
    },
    [startPending],
  );

  const onCardPointerMove = useCallback(
    (e: ReactPointerEvent<HTMLDivElement>) => {
      const p = pendingRef.current;
      if (!p || p.pointerId !== e.pointerId) return;

      if (!drag) {
        const dist = Math.hypot(e.clientX - p.startX, e.clientY - p.startY);
        if (dist < 5) return;
        setLifted(null);
        snapshotRef.current = null;
        setDrag({
          id: p.id,
          from: p.col,
          x: e.clientX,
          y: e.clientY,
          offX: p.offX,
          offY: p.offY,
          w: p.w,
        });
      } else {
        setDrag({ ...drag, x: e.clientX, y: e.clientY });
      }
      setDrop(computeDrop(e.clientX, e.clientY));
    },
    [drag, computeDrop],
  );

  const onCardPointerUp = useCallback(
    (e: ReactPointerEvent<HTMLDivElement>) => {
      const p = pendingRef.current;
      if (p && p.pointerId === e.pointerId) {
        const el = cardRefs.current.get(p.id);
        if (el && el.hasPointerCapture(e.pointerId)) {
          el.releasePointerCapture(e.pointerId);
        }
      }
      if (drag) {
        if (drop) {
          const next = placeCard(board, drag.id, drop.col, drop.index);
          setBoard(next.board);
          announcePlace(drag.id, next.col, next.index, "moved to");
        } else {
          setMessage(`${CARDS[drag.id].title} stayed where it was.`);
        }
        setDrag(null);
        setDrop(null);
      }
      pendingRef.current = null;
    },
    [drag, drop, board, announcePlace],
  );

  const onCardPointerCancel = useCallback(() => {
    pendingRef.current = null;
    setDrag(null);
    setDrop(null);
  }, []);

  const focusNeighbour = useCallback(
    (id: string, dir: Dir) => {
      const from = columnOf(board, id);
      if (!from) return;
      const idx = board[from].indexOf(id);
      let targetId: string | undefined;
      if (dir === "up" || dir === "down") {
        targetId = board[from][dir === "up" ? idx - 1 : idx + 1];
      } else {
        const ti = ORDER.indexOf(from) + (dir === "left" ? -1 : 1);
        if (ti < 0 || ti >= ORDER.length) return;
        const col = board[ORDER[ti]];
        targetId = col[Math.min(idx, col.length - 1)];
      }
      if (targetId) cardRefs.current.get(targetId)?.focus();
    },
    [board],
  );

  const onCardKeyDown = useCallback(
    (e: ReactKeyboardEvent<HTMLDivElement>, id: string) => {
      const isLifted = lifted === id;

      if (e.key === "Enter" || e.key === " ") {
        e.preventDefault();
        if (isLifted) {
          const col = columnOf(board, id);
          const index = col ? board[col].indexOf(id) : 0;
          setLifted(null);
          snapshotRef.current = null;
          if (col) announcePlace(id, col, index, "dropped in");
        } else {
          const col = columnOf(board, id);
          const index = col ? board[col].indexOf(id) : 0;
          snapshotRef.current = board;
          setLifted(id);
          setMessage(
            `${CARDS[id].title} lifted from ${col ? COLUMN_META[col].name : ""}, position ${index + 1}. Use the arrow keys to move it, Enter to drop, Escape to cancel.`,
          );
        }
        return;
      }

      if (e.key === "Escape" && isLifted) {
        e.preventDefault();
        if (snapshotRef.current) setBoard(snapshotRef.current);
        snapshotRef.current = null;
        setLifted(null);
        setMessage(`Move cancelled. ${CARDS[id].title} is back where it was.`);
        return;
      }

      const dirs: Record<string, Dir> = {
        ArrowUp: "up",
        ArrowDown: "down",
        ArrowLeft: "left",
        ArrowRight: "right",
      };
      const dir = dirs[e.key];
      if (!dir) return;
      e.preventDefault();

      if (!isLifted) {
        focusNeighbour(id, dir);
        return;
      }

      const next = moveCard(board, id, dir);
      if (!next) {
        setMessage("Cannot move any further in that direction.");
        return;
      }
      setBoard(next.board);
      announcePlace(id, next.col, next.index, "moved to");
    },
    [board, lifted, announcePlace, focusNeighbour],
  );

  useEffect(() => {
    if (lifted) cardRefs.current.get(lifted)?.focus();
  }, [board, lifted]);

  const resetBoard = useCallback(() => {
    setBoard(INITIAL_BOARD);
    setLifted(null);
    snapshotRef.current = null;
    setMessage("Board reset to the start of the sprint.");
  }, []);

  const dragCard = drag ? CARDS[drag.id] : null;

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 sm:py-24 dark:bg-zinc-950">
      <style>{`
        @keyframes knb-lift-ring {
          0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.5); }
          50% { box-shadow: 0 0 0 7px rgba(99, 102, 241, 0); }
        }
        @keyframes knb-line-glow {
          0%, 100% { opacity: 0.65; }
          50% { opacity: 1; }
        }
        @keyframes knb-sheen {
          0% { transform: translateX(-130%); }
          100% { transform: translateX(330%); }
        }
        .knb-lifted { animation: knb-lift-ring 1.7s ease-out infinite; }
        .knb-line { animation: knb-line-glow 1.1s ease-in-out infinite; }
        .knb-sheen { animation: knb-sheen 2.6s linear infinite; }
        @media (prefers-reduced-motion: reduce) {
          .knb-lifted, .knb-line, .knb-sheen { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-6xl">
        {/* Board header */}
        <div className="flex flex-wrap items-end justify-between gap-4">
          <div>
            <p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-400 dark:text-zinc-500">
              <span className="relative inline-flex h-1.5 w-1.5">
                <span className="absolute inset-0 rounded-full bg-emerald-500" />
              </span>
              Sprint 24 · closes Friday
            </p>
            <h2 className="mt-2 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-zinc-50">
              Checkout revamp
            </h2>
            <p className="mt-1.5 max-w-md text-sm text-slate-500 dark:text-zinc-400">
              {totals} cards across three lanes. Drag with a mouse, or focus a
              card and press Enter to move it with the arrow keys.
            </p>
          </div>

          <div className="flex items-center gap-2">
            <span className="hidden items-center -space-x-2 sm:flex">
              {Object.values(PEOPLE).map((p) => (
                <span
                  key={p.initials}
                  title={p.name}
                  className={`grid h-8 w-8 place-items-center rounded-full text-[11px] font-bold text-white ring-2 ring-slate-50 dark:ring-zinc-950 ${p.ring}`}
                >
                  {p.initials}
                </span>
              ))}
            </span>
            <button
              type="button"
              onClick={resetBoard}
              className="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-2 text-sm font-semibold text-slate-600 ring-1 ring-inset ring-slate-200 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:bg-zinc-900 dark:text-zinc-300 dark:ring-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:focus-visible:ring-offset-zinc-950"
            >
              <svg
                viewBox="0 0 24 24"
                className="h-4 w-4"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
              >
                <path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
                <path d="M3 3v5h5" />
              </svg>
              Reset
            </button>
          </div>
        </div>

        <p
          id="knb-instructions"
          className="mt-6 rounded-lg border border-dashed border-slate-300 bg-white/60 px-3 py-2 text-xs text-slate-500 dark:border-zinc-800 dark:bg-zinc-900/60 dark:text-zinc-400"
        >
          <span className="font-semibold text-slate-700 dark:text-zinc-200">
            Keyboard:
          </span>{" "}
          Tab to a card, arrows to browse, Enter to pick it up, arrows to move it
          between lanes, Enter to drop, Escape to cancel.
        </p>

        {/* Columns */}
        <div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
          {ORDER.map((col) => {
            const meta = COLUMN_META[col];
            const ids = board[col];
            const over = meta.limit !== null && ids.length > meta.limit;
            const isTarget = drop?.col === col && drag !== null;

            return (
              <div
                key={col}
                ref={(el) => {
                  if (el) colRefs.current.set(col, el);
                  else colRefs.current.delete(col);
                }}
                className={`flex flex-col rounded-2xl border bg-white/70 transition-colors duration-150 dark:bg-zinc-900/50 ${
                  isTarget
                    ? "border-indigo-400 bg-indigo-50/60 dark:border-indigo-500/60 dark:bg-indigo-500/5"
                    : "border-slate-200 dark:border-zinc-800"
                } ${col === "shipped" ? "sm:col-span-2 lg:col-span-1" : ""}`}
              >
                <div className="flex items-start justify-between gap-2 px-3.5 pb-2 pt-3.5">
                  <div className="min-w-0">
                    <h3 className="flex items-center gap-2 text-sm font-bold text-slate-800 dark:text-zinc-100">
                      <span
                        aria-hidden="true"
                        className={`h-2 w-2 shrink-0 rounded-full ${meta.dot}`}
                      />
                      {meta.name}
                      <span
                        className={`rounded-full px-1.5 py-0.5 text-[11px] font-bold tabular-nums ${meta.pill}`}
                      >
                        {ids.length}
                      </span>
                    </h3>
                    <p className="mt-1 truncate text-[11px] text-slate-400 dark:text-zinc-500">
                      {meta.hint}
                    </p>
                  </div>
                  {over && (
                    <span className="inline-flex shrink-0 items-center gap-1 rounded-md bg-amber-50 px-1.5 py-1 text-[10px] font-bold uppercase tracking-wide text-amber-700 ring-1 ring-inset ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30">
                      <svg
                        viewBox="0 0 24 24"
                        className="h-3 w-3"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth={2.4}
                        strokeLinecap="round"
                        aria-hidden="true"
                      >
                        <path d="M12 8v5M12 16.5v.01" />
                        <path d="M10.3 3.9 2.4 17.4A1.9 1.9 0 0 0 4 20.3h16a1.9 1.9 0 0 0 1.6-2.9L13.7 3.9a1.9 1.9 0 0 0-3.4 0z" />
                      </svg>
                      Over WIP
                    </span>
                  )}
                </div>

                <div
                  ref={(el) => {
                    if (el) listRefs.current.set(col, el);
                    else listRefs.current.delete(col);
                  }}
                  role="list"
                  aria-label={`${meta.name}, ${ids.length} cards`}
                  className="relative min-h-[7rem] flex-1 space-y-2.5 px-2.5 pb-3"
                >
                  {isTarget && drop && (
                    <span
                      aria-hidden="true"
                      className={`pointer-events-none absolute inset-x-2.5 z-10 h-0.5 rounded-full bg-indigo-500 dark:bg-indigo-400 ${
                        reduce ? "" : "knb-line"
                      }`}
                      style={{ top: drop.y }}
                    />
                  )}

                  {ids.length === 0 && (
                    <p
                      role="listitem"
                      className="grid h-24 place-items-center rounded-xl border border-dashed border-slate-300 text-xs font-medium text-slate-400 dark:border-zinc-700 dark:text-zinc-500"
                    >
                      Nothing here yet
                    </p>
                  )}

                  {ids.map((id) => {
                    const card = CARDS[id];
                    const isDragging = drag?.id === id;
                    const isLifted = lifted === id;
                    const index = ids.indexOf(id);

                    return (
                      <motion.div
                        key={id}
                        layout={!reduce}
                        transition={{
                          type: "spring",
                          stiffness: 520,
                          damping: 42,
                        }}
                        ref={(el: HTMLDivElement | null) => {
                          if (el) cardRefs.current.set(id, el);
                          else cardRefs.current.delete(id);
                        }}
                        role="listitem"
                        tabIndex={0}
                        aria-roledescription="Draggable card"
                        aria-describedby="knb-instructions"
                        aria-label={`${card.title}. ${card.tag}, ${PRIORITY_STYLE[card.priority].label} priority, owned by ${card.owner.name}. ${COLUMN_META[col].name}, position ${index + 1} of ${ids.length}.`}
                        onPointerDown={(e) => onCardPointerDown(e, id, col)}
                        onPointerMove={onCardPointerMove}
                        onPointerUp={onCardPointerUp}
                        onPointerCancel={onCardPointerCancel}
                        onKeyDown={(e) => onCardKeyDown(e, id)}
                        className={`group relative select-none rounded-xl border bg-white text-left shadow-sm outline-none transition-shadow dark:bg-zinc-900 ${
                          isDragging
                            ? "border-slate-200 opacity-40 dark:border-zinc-800"
                            : "border-slate-200 hover:shadow-md dark:border-zinc-800"
                        } ${
                          isLifted
                            ? "border-indigo-400 dark:border-indigo-500 " +
                              (reduce ? "ring-2 ring-indigo-500" : "knb-lifted")
                            : ""
                        } focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-zinc-950 cursor-grab active:cursor-grabbing`}
                      >
                        {isLifted && (
                          <span className="absolute -top-2 left-3 z-10 rounded bg-indigo-600 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-white">
                            Held
                          </span>
                        )}
                        <span
                          onPointerDown={(e) => onHandlePointerDown(e, id, col)}
                          aria-hidden="true"
                          className="absolute inset-y-0 left-0 grid w-4 touch-none place-items-center rounded-l-xl text-slate-300 transition-colors hover:bg-slate-50 hover:text-slate-500 dark:text-zinc-700 dark:hover:bg-zinc-800/60 dark:hover:text-zinc-400"
                        >
                          <svg
                            viewBox="0 0 6 16"
                            className="h-4 w-1.5"
                            fill="currentColor"
                          >
                            <circle cx="1.5" cy="4" r="1" />
                            <circle cx="4.5" cy="4" r="1" />
                            <circle cx="1.5" cy="8" r="1" />
                            <circle cx="4.5" cy="8" r="1" />
                            <circle cx="1.5" cy="12" r="1" />
                            <circle cx="4.5" cy="12" r="1" />
                          </svg>
                        </span>
                        <CardFace card={card} />
                      </motion.div>
                    );
                  })}
                </div>
              </div>
            );
          })}
        </div>

        <span className="sr-only" role="status" aria-live="polite">
          {message}
        </span>
      </div>

      {/* Floating drag preview */}
      {drag && dragCard && (
        <div
          aria-hidden="true"
          className="pointer-events-none fixed z-50 overflow-hidden rounded-xl border border-indigo-300 bg-white shadow-2xl shadow-slate-900/20 dark:border-indigo-500/50 dark:bg-zinc-900 dark:shadow-black/60"
          style={{
            left: drag.x - drag.offX,
            top: drag.y - drag.offY,
            width: drag.w,
            transform: reduce ? undefined : "rotate(2.5deg) scale(1.03)",
          }}
        >
          <span className="pointer-events-none absolute inset-0 overflow-hidden">
            <span
              className={`absolute inset-y-0 -left-1/3 w-1/3 bg-gradient-to-r from-transparent via-indigo-400/15 to-transparent ${
                reduce ? "" : "knb-sheen"
              }`}
            />
          </span>
          <CardFace card={dragCard} />
        </div>
      )}
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →