Skip to content

Kanban Drag Columns

A dependency-free kanban board: lift a card and a lifted copy follows your pointer while the columns light up as drop targets and the cards glide out of the way on a spring. Count badges roll, touch scrolls sideways with snap, and a full keyboard path moves cards between columns.

DOM + CSS0 deps1 file · 39 KB TSXreduced-motion aware

Freeminimalglass
Preview

Props

No runtime dependencies
"use client";

/*
 * Kanban drag columns: a small board where cards drag within and across columns, no dependencies.
 *
 * Mouse and pen: press a card and drag. Touch: drag by the grip (the board still scrolls sideways
 * under your finger everywhere else). The card you hold floats above the board as a lifted copy
 * while its slot stays behind as a dashed placeholder; the cards around it glide out of the way on
 * a spring and the column under the pointer lights up as the drop target. Keyboard: focus a grip,
 * press Space to pick the card up, move with the arrow keys (Up and Down reorder, Left and Right
 * change column, Home and End jump to the ends), Space to drop, Escape to cancel. Every step is
 * announced through an aria-live region. Positions are measured in layout pixels and animated
 * with FLIP (Web Animations API), so it stays correct inside scaled previews.
 */

import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";

export type KanbanPriority = "low" | "medium" | "high" | "urgent";

export type KanbanCard = {
  id: string;
  label: string;
  /** Small label above the title, for example "Design". */
  tag?: string;
  /** Shown as a priority glyph at the bottom left of the card. */
  priority?: KanbanPriority;
  /** Initials for the avatar at the bottom right, for example "MK". */
  assignee?: string;
};

export type KanbanColumns = Record<string, KanbanCard[]>;

export type KanbanDragColumnsProps = {
  /** The starting board: column name to cards. Column names must be unique. Read once on mount. */
  initialColumns?: KanbanColumns;
  /** Called with the whole board after every committed move (on drop, or on each keyboard step). */
  onChange?: (columns: KanbanColumns) => void;
  /** Surface of the board: dark glass or white paper. */
  tone?: "dark" | "light";
  /** Classes for the root. The default sets the width; pass your own to replace it. */
  className?: string;
};

const DEFAULT_COLUMNS: KanbanColumns = {
  Todo: [
    { id: "t1", label: "Wireframe hero", tag: "Design", priority: "medium", assignee: "MK" },
    { id: "t2", label: "Write copy", tag: "Content", priority: "low", assignee: "JA" },
    { id: "t3", label: "Audit colour tokens", tag: "Design", priority: "low" },
  ],
  Doing: [
    { id: "d1", label: "Build nav", tag: "Frontend", priority: "high", assignee: "RS" },
    { id: "d3", label: "Fix checkout bug", tag: "Bug", priority: "urgent", assignee: "TL" },
  ],
  Done: [
    { id: "d2", label: "Set up repo", tag: "Infra", priority: "low", assignee: "DN" },
    { id: "d4", label: "Pick type scale", tag: "Design", priority: "medium", assignee: "MK" },
  ],
};

const COLUMN_DOTS = ["150,160,185", "120,160,255", "90,220,160", "240,180,90", "200,140,255"];
const FALLBACK_EASE = "cubic-bezier(0.34, 1.3, 0.64, 1)";
const FLIP_MS = 460;
const LAND_MS = 400;
const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;

/* A damped spring (stiffness 380, damping 26) sampled into a CSS linear() curve, with a cubic-bezier fallback. */
const SPRING =
  "linear(0, 0.046, 0.156, 0.299, 0.452, 0.597, 0.726, 0.832, 0.916, 0.977, 1.018, 1.044, 1.057, 1.06, 1.057, 1.051, 1.042, 1.033, 1.024, 1.016, 1.01, 1.005, 1.001, 0.999, 0.997, 0.997, 0.996, 0.997, 0.997, 0.997, 0.998, 0.999, 0.999, 0.999, 1, 1, 1)";

const CSS = [
  ".kb{--kb-ease:" + FALLBACK_EASE + ";position:relative;box-sizing:border-box;font-size:14px;color:var(--kb-text);text-align:left;-webkit-tap-highlight-color:transparent}",
  "@supports (transition-timing-function:linear(0,1)){.kb{--kb-ease:" + SPRING + "}}",
  ".kb *,.kb *::before,.kb *::after{box-sizing:border-box}",
  ".kb[data-tone=dark]{--kb-col-bg:rgba(255,255,255,.03);--kb-col-line:rgba(255,255,255,.08);--kb-text:rgba(255,255,255,.92);--kb-muted:rgba(255,255,255,.46);--kb-faint:rgba(255,255,255,.3);--kb-card-bg:linear-gradient(180deg,#22242b,#181a20);--kb-card-line:rgba(255,255,255,.1);--kb-card-hi:rgba(255,255,255,.3);--kb-hl:rgba(255,255,255,.07);--kb-pill:rgba(255,255,255,.08);--kb-sh:0 1px 0 rgba(0,0,0,.4),0 8px 16px -10px rgba(0,0,0,.7);--kb-lift:0 30px 50px -14px rgba(0,0,0,.9),0 12px 20px -10px rgba(0,0,0,.7);--kb-accent:132,164,255;--kb-ring:rgba(255,255,255,.9);--kb-av-bg-c:38% 26%;--kb-av-fg-c:80% 86%;--kb-tag-c:75% 70%;--kb-bar:rgba(255,255,255,.2);--kb-bar-on:rgba(255,255,255,.82)}",
  ".kb[data-tone=light]{--kb-col-bg:rgba(15,23,42,.04);--kb-col-line:rgba(15,23,42,.09);--kb-text:#171717;--kb-muted:#737373;--kb-faint:#a3a3a3;--kb-card-bg:linear-gradient(180deg,#ffffff,#fafafa);--kb-card-line:rgba(15,23,42,.11);--kb-card-hi:rgba(15,23,42,.34);--kb-hl:rgba(255,255,255,.9);--kb-pill:rgba(15,23,42,.07);--kb-sh:0 1px 2px rgba(15,23,42,.06),0 6px 14px -8px rgba(15,23,42,.12);--kb-lift:0 28px 44px -16px rgba(15,23,42,.34),0 10px 18px -10px rgba(15,23,42,.2);--kb-accent:59,91,255;--kb-ring:#171717;--kb-av-bg-c:80% 91%;--kb-av-fg-c:55% 28%;--kb-tag-c:55% 38%;--kb-bar:rgba(15,23,42,.18);--kb-bar-on:#262626}",
  ".kb-scroll{display:flex;align-items:flex-start;gap:12px;overflow-x:auto;overflow-y:hidden;padding:6px;margin:-6px;scroll-snap-type:x proximity;scroll-padding:0 6px;overscroll-behavior-x:contain;scrollbar-width:none}",
  ".kb-scroll::-webkit-scrollbar{display:none}",
  "@media (pointer:coarse){.kb-scroll{scroll-snap-type:x mandatory}}",
  ".kb-col{position:relative;flex:1 0 min(248px,84%);min-width:0;scroll-snap-align:start;padding:10px;border-radius:18px;border:1px solid var(--kb-col-line);background:var(--kb-col-bg);transition:border-color .25s ease,background-color .25s ease,box-shadow .35s ease}",
  ".kb-col::before{content:\"\";position:absolute;left:18px;right:18px;top:-1px;height:2px;border-radius:2px;background:rgb(var(--kb-accent));opacity:0;transform:scaleX(.4);transition:opacity .25s ease,transform .4s var(--kb-ease)}",
  ".kb-col[data-over]{border-color:rgba(var(--kb-accent),.5);background:rgba(var(--kb-accent),.065);box-shadow:0 0 0 3px rgba(var(--kb-accent),.12),0 24px 60px -30px rgba(var(--kb-accent),.6)}",
  ".kb-col[data-over]::before{opacity:1;transform:scaleX(1)}",
  ".kb-head{display:flex;align-items:center;gap:8px;padding:3px 4px 11px}",
  ".kb-dot{flex:none;width:8px;height:8px;border-radius:50%;background:rgb(var(--kb-dot));box-shadow:0 0 10px rgba(var(--kb-dot),.55)}",
  ".kb-name{flex:1 1 auto;min-width:0;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600;letter-spacing:-.005em;line-height:1.2;color:var(--kb-text)}",
  ".kb-count{flex:none;display:inline-grid;place-items:center;min-width:24px;height:20px;padding:0 7px;border-radius:999px;background:var(--kb-pill);color:var(--kb-muted);font:500 11px/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-variant-numeric:tabular-nums;overflow:hidden}",
  ".kb-roll{display:inline-grid;overflow:hidden}",
  ".kb-roll>span{grid-area:1/1;display:block;text-align:center}",
  ".kb-roll-in[data-roll=up]{animation:kb-in-up .46s var(--kb-ease) both}",
  ".kb-roll-in[data-roll=down]{animation:kb-in-down .46s var(--kb-ease) both}",
  ".kb-roll-out[data-roll=up]{animation:kb-out-up .3s ease-in both}",
  ".kb-roll-out[data-roll=down]{animation:kb-out-down .3s ease-in both}",
  "@keyframes kb-in-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}",
  "@keyframes kb-in-down{from{transform:translateY(-100%);opacity:0}to{transform:translateY(0);opacity:1}}",
  "@keyframes kb-out-up{from{transform:translateY(0);opacity:1}to{transform:translateY(-100%);opacity:0}}",
  "@keyframes kb-out-down{from{transform:translateY(0);opacity:1}to{transform:translateY(100%);opacity:0}}",
  ".kb-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px;position:relative;min-height:64px}",
  ".kb-list[data-empty]::before{content:\"No cards\";display:grid;place-items:center;height:64px;border-radius:12px;border:1px dashed var(--kb-col-line);color:var(--kb-faint);font-size:12px}",
  ".kb-li{position:relative;margin:0;padding:0}",
  ".kb-li[data-placeholder]::after{content:\"\";position:absolute;inset:0;border-radius:12px;border:1.5px dashed rgba(var(--kb-accent),.6);background:rgba(var(--kb-accent),.08);animation:kb-ph .3s ease-out}",
  "@keyframes kb-ph{from{opacity:0}to{opacity:1}}",
  ".kb-li[data-placeholder] .kb-card{visibility:hidden}",
  ".kb-card{position:relative;display:flex;flex-direction:column;gap:8px;min-height:64px;padding:11px 12px 10px;border-radius:12px;border:1px solid var(--kb-card-line);background:var(--kb-card-bg);color:var(--kb-text);box-shadow:var(--kb-sh),inset 0 1px 0 var(--kb-hl);transition:border-color .2s ease,box-shadow .3s ease,transform .34s var(--kb-ease)}",
  "@media (hover:hover){.kb-card{cursor:grab}.kb-li:not([data-placeholder]) .kb-card:hover{border-color:var(--kb-card-hi)}.kb-card:hover .kb-grip{color:var(--kb-muted)}}",
  ".kb-li[data-picked] .kb-card{border-color:var(--kb-card-hi);box-shadow:var(--kb-lift),inset 0 1px 0 var(--kb-hl);transform:scale(1.025)}",
  ".kb-li[data-settled] .kb-card{animation:kb-settle .9s ease-out}",
  "@keyframes kb-settle{0%{border-color:rgba(var(--kb-accent),.95);box-shadow:0 0 0 4px rgba(var(--kb-accent),.22),var(--kb-sh)}100%{border-color:var(--kb-card-line);box-shadow:0 0 0 0 rgba(var(--kb-accent),0),var(--kb-sh)}}",
  ".kb-tag{display:flex;align-items:center;gap:6px;margin:0;padding-right:30px;min-width:0;font-size:11px;font-weight:500;letter-spacing:.01em;line-height:1;color:var(--kb-muted)}",
  ".kb-tag i{flex:none;width:6px;height:6px;border-radius:50%;background:hsl(var(--kb-h) var(--kb-tag-c))}",
  ".kb-tag span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
  ".kb-title{margin:0;font-size:13.5px;font-weight:500;letter-spacing:-.005em;line-height:1.35;color:var(--kb-text);overflow-wrap:anywhere}",
  ".kb-card[data-notag] .kb-title{padding-right:30px}",
  ".kb-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:22px}",
  ".kb-prio{display:block;width:16px;height:16px;flex:none}",
  ".kb-avatar{display:grid;place-items:center;flex:none;width:22px;height:22px;border-radius:50%;background:hsl(var(--kb-h) var(--kb-av-bg-c));color:hsl(var(--kb-h) var(--kb-av-fg-c));font:600 9.5px/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.02em}",
  ".kb-grip{appearance:none;-webkit-appearance:none;position:absolute;top:6px;right:6px;display:grid;place-items:center;width:28px;height:28px;margin:0;padding:0;border:0;border-radius:8px;background:transparent;color:var(--kb-faint);cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;transition:color .2s ease,background-color .2s ease}",
  ".kb-grip::before{content:\"\";position:absolute;inset:-6px}",
  ".kb-grip:hover{background:var(--kb-pill)}",
  ".kb-li[data-picked] .kb-grip{color:var(--kb-text)}",
  ".kb-grip:focus-visible{outline:2px solid var(--kb-ring);outline-offset:2px;border-radius:8px}",
  "@media (pointer:coarse){.kb-grip{top:2px;right:2px;width:40px;height:40px}}",
  ".kb-grip svg{display:block;width:14px;height:14px}",
  ".kb-ghost{position:absolute;left:0;top:0;z-index:30;margin:0;pointer-events:none;will-change:translate,scale,rotate;transform-origin:50% 40%;transition:scale .34s var(--kb-ease),rotate .3s ease-out}",
  ".kb-ghost .kb-card{border-color:var(--kb-card-hi);box-shadow:var(--kb-lift),inset 0 1px 0 var(--kb-hl);cursor:grabbing;transition:box-shadow .4s ease,border-color .4s ease}",
  ".kb-ghost[data-landing] .kb-card{border-color:var(--kb-card-line);box-shadow:var(--kb-sh),inset 0 1px 0 var(--kb-hl)}",
  ".kb[data-dragging],.kb[data-dragging] *{user-select:none;-webkit-user-select:none}",
  ".kb[data-dragging] *{cursor:grabbing!important}",
  ".kb[data-dragging] .kb-scroll{scroll-snap-type:none}",
  ".kb-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}",
  "@media (prefers-reduced-motion:reduce){.kb-card{transition:box-shadow .15s ease,border-color .15s ease}.kb-li[data-picked] .kb-card{transform:none}.kb-li[data-settled] .kb-card,.kb-li[data-placeholder]::after{animation:none}.kb-roll-in,.kb-roll-out{animation:none!important}.kb-roll-out{display:none}.kb-col::before{transition:none}}",
].join("\n");

const PRIORITY_LABEL: Record<KanbanPriority, string> = {
  low: "Low priority",
  medium: "Medium priority",
  high: "High priority",
  urgent: "Urgent",
};

function hueOf(text: string): number {
  let h = 0;
  for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) % 360;
  return h;
}

function cloneColumns(src: KanbanColumns): KanbanColumns {
  const out: KanbanColumns = {};
  for (const name of Object.keys(src)) out[name] = (src[name] ?? []).map((c) => ({ ...c }));
  return out;
}

function PriorityIcon({ level }: { level: KanbanPriority }) {
  if (level === "urgent") {
    return (
      <svg className="kb-prio" viewBox="0 0 16 16" role="img" aria-label={PRIORITY_LABEL.urgent}>
        <rect x="1.5" y="1.5" width="13" height="13" rx="4" fill="#ff7a59" />
        <rect x="7.2" y="4.2" width="1.6" height="4.8" rx=".8" fill="#fff" />
        <circle cx="8" cy="11.2" r=".95" fill="#fff" />
      </svg>
    );
  }
  const on = level === "high" ? 3 : level === "medium" ? 2 : 1;
  return (
    <svg className="kb-prio" viewBox="0 0 16 16" role="img" aria-label={PRIORITY_LABEL[level]}>
      {[0, 1, 2].map((i) => (
        <rect
          key={i}
          x={2 + i * 4.4}
          y={10 - i * 3}
          width="3"
          height={4 + i * 3}
          rx="1"
          style={{ fill: i < on ? "var(--kb-bar-on)" : "var(--kb-bar)" }}
        />
      ))}
    </svg>
  );
}

function CardFace({ card }: { card: KanbanCard }) {
  const hasFoot = Boolean(card.priority || card.assignee);
  return (
    <>
      {card.tag ? (
        <p className="kb-tag" style={{ "--kb-h": hueOf(card.tag) } as CSSProperties}>
          <i aria-hidden="true" />
          <span>{card.tag}</span>
        </p>
      ) : null}
      <p className="kb-title">{card.label}</p>
      {hasFoot ? (
        <div className="kb-foot">
          {card.priority ? <PriorityIcon level={card.priority} /> : <span />}
          {card.assignee ? (
            <span
              className="kb-avatar"
              style={{ "--kb-h": hueOf(card.assignee) } as CSSProperties}
              role="img"
              aria-label={"Assigned to " + card.assignee}
            >
              {card.assignee.slice(0, 2).toUpperCase()}
            </span>
          ) : null}
        </div>
      ) : null}
    </>
  );
}

/* The count badge: the old number rolls out and the new one rolls in. */
function Roll({ value }: { value: number }) {
  const [s, setS] = useState({ to: value, from: value, n: 0, dir: "up" });
  if (s.to !== value) setS({ to: value, from: s.to, n: s.n + 1, dir: value > s.to ? "up" : "down" });
  const rolling = s.n > 0;
  return (
    <span className="kb-roll" aria-hidden="true">
      {rolling ? (
        <span key={"o" + s.n} className="kb-roll-out" data-roll={s.dir}>
          {s.from}
        </span>
      ) : null}
      <span key={"i" + s.n} className="kb-roll-in" data-roll={rolling ? s.dir : undefined}>
        {s.to}
      </span>
    </span>
  );
}

type Session = {
  id: string;
  pointerId: number;
  startX: number;
  startY: number;
  started: boolean;
  offX: number;
  offY: number;
  px: number;
  py: number;
  lastX: number;
  origin: KanbanColumns;
};

type Drag = { id: string; w: number; h: number };
type Where = { name: string; index: number };

const clamp = (n: number, lo: number, hi: number) => Math.min(Math.max(n, lo), hi);

export function KanbanDragColumns({
  initialColumns = DEFAULT_COLUMNS,
  onChange,
  tone = "dark",
  className = "w-full",
}: KanbanDragColumnsProps) {
  const [cols, setCols] = useState<KanbanColumns>(() => cloneColumns(initialColumns));
  const [drag, setDrag] = useState<Drag | null>(null);
  const [landing, setLanding] = useState(false);
  const [over, setOver] = useState<string | null>(null);
  const [picked, setPicked] = useState<string | null>(null);
  const [settled, setSettled] = useState<string | null>(null);
  const [message, setMessage] = useState("");
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");

  const rootRef = useRef<HTMLDivElement>(null);
  const scrollRef = useRef<HTMLDivElement>(null);
  const ghostRef = useRef<HTMLDivElement>(null);
  const colsRef = useRef(cols);
  const pickedRef = useRef<string | null>(null);
  const dragIdRef = useRef<string | null>(null);
  const sessionRef = useRef<Session | null>(null);
  const pendingRef = useRef<Map<string, { x: number; y: number }> | null>(null);
  const keyOriginRef = useRef<KanbanColumns | null>(null);
  const cancelRef = useRef(false);
  const focusAfterRef = useRef<string | null>(null);
  const ghostPosRef = useRef({ x: 0, y: 0 });
  const reducedRef = useRef(false);
  const onChangeRef = useRef(onChange);
  const retargetRef = useRef<() => void>(() => {});
  const cleanupRef = useRef<(() => void) | null>(null);
  const tiltTimer = useRef(0);
  const landTimer = useRef(0);
  const settleTimer = useRef(0);

  useIsoLayoutEffect(() => {
    colsRef.current = cols;
    pickedRef.current = picked;
    dragIdRef.current = drag ? drag.id : null;
    onChangeRef.current = onChange;
  });

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const sync = () => {
      reducedRef.current = mq.matches;
    };
    sync();
    mq.addEventListener("change", sync);
    return () => mq.removeEventListener("change", sync);
  }, []);

  useEffect(
    () => () => {
      window.clearTimeout(tiltTimer.current);
      window.clearTimeout(landTimer.current);
      window.clearTimeout(settleTimer.current);
      cleanupRef.current?.();
    },
    [],
  );

  /* ---- Measuring -------------------------------------------------------- */

  const scaleOf = useCallback((): number => {
    const r = rootRef.current;
    if (!r) return 1;
    const b = r.getBoundingClientRect();
    return b.width ? r.offsetWidth / b.width : 1;
  }, []);

  const lis = useCallback((): HTMLElement[] => {
    const r = rootRef.current;
    return r ? Array.from(r.querySelectorAll<HTMLElement>("[data-cid]")) : [];
  }, []);

  const liFor = useCallback(
    (id: string): HTMLElement | undefined => lis().find((el) => el.dataset.cid === id),
    [lis],
  );

  /* Where every card visually is right now, in layout pixels from the root's corner. */
  const captureAll = useCallback((): Map<string, { x: number; y: number }> => {
    const out = new Map<string, { x: number; y: number }>();
    const root = rootRef.current;
    if (!root) return out;
    const rb = root.getBoundingClientRect();
    const s = scaleOf();
    for (const li of lis()) {
      const id = li.dataset.cid;
      if (!id) continue;
      const b = li.getBoundingClientRect();
      out.set(id, { x: (b.left - rb.left) * s, y: (b.top - rb.top) * s });
    }
    return out;
  }, [lis, scaleOf]);

  const locate = useCallback((id: string, from?: KanbanColumns): Where | null => {
    const board = from ?? colsRef.current;
    for (const name of Object.keys(board)) {
      const index = (board[name] ?? []).findIndex((c) => c.id === id);
      if (index >= 0) return { name, index };
    }
    return null;
  }, []);

  const say = useCallback(
    (text: string) => setMessage((prev) => (prev === text ? text + " " : text)),
    [],
  );

  const flash = useCallback((id: string) => {
    setSettled(id);
    window.clearTimeout(settleTimer.current);
    settleTimer.current = window.setTimeout(() => setSettled(null), 900);
  }, []);

  const emit = useCallback((board: KanbanColumns) => {
    onChangeRef.current?.(cloneColumns(board));
  }, []);

  /* Move a card in the board, remembering where everything was so the layout effect can FLIP it. */
  const moveTo = useCallback(
    (id: string, to: string, toIndex: number): boolean => {
      const prev = colsRef.current;
      const at = locate(id, prev);
      const card = at ? (prev[at.name] ?? [])[at.index] : undefined;
      if (!at || !card) return false;
      const target = (prev[to] ?? []).filter((c) => c.id !== id);
      const idx = clamp(toIndex, 0, target.length);
      if (at.name === to && at.index === idx) return false;
      const next: KanbanColumns = {};
      for (const name of Object.keys(prev)) {
        const list = (prev[name] ?? []).filter((c) => c.id !== id);
        if (name === to) list.splice(idx, 0, card);
        next[name] = list;
      }
      pendingRef.current = captureAll();
      colsRef.current = next;
      setCols(next);
      return true;
    },
    [captureAll, locate],
  );

  const revealColumn = useCallback((name: string) => {
    const sc = scrollRef.current;
    const root = rootRef.current;
    if (!sc || !root) return;
    const el = Array.from(root.querySelectorAll<HTMLElement>("[data-col]")).find((c) => c.dataset.col === name);
    if (!el) return;
    const sb = sc.getBoundingClientRect();
    const cb = el.getBoundingClientRect();
    const k = scaleOf();
    let dx = 0;
    if (cb.left < sb.left + 4) dx = (cb.left - sb.left - 8) * k;
    else if (cb.right > sb.right - 4) dx = (cb.right - sb.right + 8) * k;
    if (dx) sc.scrollBy({ left: dx, behavior: reducedRef.current ? "auto" : "smooth" });
  }, [scaleOf]);

  /* FLIP after every commit; refocus the held card's grip, which remounts when it changes column. */
  useIsoLayoutEffect(() => {
    const from = pendingRef.current;
    pendingRef.current = null;
    if (from && !reducedRef.current) {
      const root = rootRef.current;
      if (root) {
        const ease = getComputedStyle(root).getPropertyValue("--kb-ease").trim() || FALLBACK_EASE;
        const els = lis();
        els.forEach((li) => li.getAnimations().forEach((a) => a.cancel()));
        const rb = root.getBoundingClientRect();
        const s = scaleOf();
        for (const li of els) {
          const id = li.dataset.cid;
          const was = id ? from.get(id) : undefined;
          if (!was || typeof li.animate !== "function") continue;
          const b = li.getBoundingClientRect();
          const dx = was.x - (b.left - rb.left) * s;
          const dy = was.y - (b.top - rb.top) * s;
          if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue;
          li.animate(
            [{ transform: "translate3d(" + dx + "px," + dy + "px,0)" }, { transform: "translate3d(0,0,0)" }],
            { duration: FLIP_MS, easing: ease },
          );
        }
      }
    }
    const held = pickedRef.current ?? focusAfterRef.current;
    focusAfterRef.current = null;
    if (held) {
      const grip = liFor(held)?.querySelector<HTMLElement>("button");
      if (grip && document.activeElement !== grip) grip.focus({ preventScroll: true });
    }
  }, [cols, lis, liFor, scaleOf]);

  /* ---- The floating copy ------------------------------------------------ */

  const applyGhost = useCallback(() => {
    const g = ghostRef.current;
    if (!g) return;
    g.style.translate = ghostPosRef.current.x + "px " + ghostPosRef.current.y + "px";
  }, []);

  useIsoLayoutEffect(() => {
    const g = ghostRef.current;
    if (!g || !drag) return;
    g.style.translate = ghostPosRef.current.x + "px " + ghostPosRef.current.y + "px";
    g.style.scale = "1";
    g.style.rotate = "0deg";
    if (reducedRef.current) {
      g.style.transition = "none";
      return;
    }
    void g.offsetWidth;
    g.style.scale = "1.035";
  }, [drag]);

  /* ---- Pointer dragging ------------------------------------------------- */

  const retarget = useCallback(() => {
    const s = sessionRef.current;
    const root = rootRef.current;
    if (!s || !s.started || !root) return;
    let best: HTMLElement | null = null;
    let bestD = Infinity;
    for (const el of Array.from(root.querySelectorAll<HTMLElement>("[data-col]"))) {
      const r = el.getBoundingClientRect();
      const d = s.px < r.left ? r.left - s.px : s.px > r.right ? s.px - r.right : 0;
      if (d < bestD) {
        bestD = d;
        best = el;
      }
    }
    const name = best?.dataset.col;
    const ul = best?.querySelector<HTMLElement>("ul");
    if (!best || !name || !ul) return;
    setOver(name);
    const ub = ul.getBoundingClientRect();
    const k = ub.height ? ul.offsetHeight / ub.height : 1;
    let count = 0;
    for (const li of Array.from(ul.children) as HTMLElement[]) {
      const cid = li.dataset.cid;
      if (!cid || cid === s.id) continue;
      const mid = ub.top + (li.offsetTop + li.offsetHeight / 2) / k;
      if (mid < s.py) count++;
    }
    moveTo(s.id, name, count);
  }, [moveTo]);

  useIsoLayoutEffect(() => {
    retargetRef.current = retarget;
  }, [retarget]);

  const dragging = drag !== null && !landing;
  useEffect(() => {
    if (!dragging) return;
    let raf = 0;
    const tick = () => {
      const s = sessionRef.current;
      const sc = scrollRef.current;
      if (s && s.started && sc) {
        const r = sc.getBoundingClientRect();
        const zone = 64;
        let v = 0;
        if (s.px < r.left + zone) v = -(1 - clamp(s.px - r.left, 0, zone) / zone);
        else if (s.px > r.right - zone) v = 1 - clamp(r.right - s.px, 0, zone) / zone;
        if (v) {
          const before = sc.scrollLeft;
          sc.scrollLeft += v * 16;
          if (sc.scrollLeft !== before) retargetRef.current();
        }
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [dragging]);

  /* Release (or cancel): fly the copy into its slot, then swap it for the real card. */
  useIsoLayoutEffect(() => {
    if (!landing) return;
    const id = dragIdRef.current;
    const g = ghostRef.current;
    const root = rootRef.current;
    if (!id || !g || !root) return;
    const li = liFor(id);
    if (li) {
      li.getAnimations().forEach((a) => a.cancel());
      const rb = root.getBoundingClientRect();
      const b = li.getBoundingClientRect();
      const s = scaleOf();
      ghostPosRef.current = { x: (b.left - rb.left) * s, y: (b.top - rb.top) * s };
    }
    const reduced = reducedRef.current;
    if (!reduced) {
      const ease = getComputedStyle(root).getPropertyValue("--kb-ease").trim() || FALLBACK_EASE;
      const t = LAND_MS / 1000 + "s " + ease;
      g.style.transition = "translate " + t + ", scale " + t + ", rotate " + t;
    }
    g.dataset.landing = "";
    g.style.translate = ghostPosRef.current.x + "px " + ghostPosRef.current.y + "px";
    g.style.scale = "1";
    g.style.rotate = "0deg";
    window.clearTimeout(landTimer.current);
    landTimer.current = window.setTimeout(
      () => {
        const board = colsRef.current;
        const origin = keyOriginRef.current;
        keyOriginRef.current = null;
        setDrag(null);
        setLanding(false);
        flash(id);
        const at = locate(id, board);
        const card = at ? (board[at.name] ?? [])[at.index] : undefined;
        if (at && card) {
          if (origin && !sameBoard(origin, board)) emit(board);
          say(cancelRef.current ? "Cancelled. " + card.label + " returned to " + at.name + "." : card.label + " dropped in " + at.name + ", position " + (at.index + 1) + " of " + (board[at.name] ?? []).length + ".");
        }
      },
      reduced ? 0 : LAND_MS,
    );
  }, [landing, liFor, scaleOf, flash, locate, emit, say]);

  const finish = useCallback(
    (cancel: boolean) => {
      const s = sessionRef.current;
      if (!s) return;
      sessionRef.current = null;
      cleanupRef.current?.();
      cleanupRef.current = null;
      window.clearTimeout(tiltTimer.current);
      setOver(null);
      if (!s.started) return;
      keyOriginRef.current = s.origin;
      cancelRef.current = cancel;
      if (cancel) {
        pendingRef.current = captureAll();
        colsRef.current = s.origin;
        setCols(s.origin);
      }
      setLanding(true);
    },
    [captureAll],
  );

  const onGripDown = (e: ReactPointerEvent<HTMLElement>, id: string) => {
    if (sessionRef.current || pickedRef.current || dragIdRef.current) return;
    if (e.pointerType === "mouse" && e.button !== 0) return;
    const onGrip = Boolean((e.target as HTMLElement).closest(".kb-grip"));
    if (e.pointerType === "touch" && !onGrip) return;
    if (e.pointerType === "mouse") e.preventDefault();
    sessionRef.current = {
      id,
      pointerId: e.pointerId,
      startX: e.clientX,
      startY: e.clientY,
      started: false,
      offX: 0,
      offY: 0,
      px: e.clientX,
      py: e.clientY,
      lastX: e.clientX,
      origin: cloneColumns(colsRef.current),
    };
    const onMove = (ev: PointerEvent) => {
      const s = sessionRef.current;
      if (!s || ev.pointerId !== s.pointerId) return;
      if (!s.started) {
        if (Math.hypot(ev.clientX - s.startX, ev.clientY - s.startY) < 4) return;
        const li = liFor(s.id);
        const root = rootRef.current;
        const card = li?.querySelector<HTMLElement>(".kb-card");
        if (!li || !root || !card) return;
        const k = scaleOf();
        const cb = card.getBoundingClientRect();
        s.started = true;
        s.offX = (s.startX - cb.left) * k;
        s.offY = (s.startY - cb.top) * k;
        li.getAnimations().forEach((a) => a.cancel());
        root.dataset.dragging = "";
        setDrag({ id: s.id, w: card.offsetWidth, h: card.offsetHeight });
        const at = locate(s.id);
        if (at) say("Dragging " + (colsRef.current[at.name] ?? [])[at.index]?.label + ".");
      }
      s.px = ev.clientX;
      s.py = ev.clientY;
      const root = rootRef.current;
      if (root) {
        const rb = root.getBoundingClientRect();
        const k = scaleOf();
        ghostPosRef.current = { x: (ev.clientX - rb.left) * k - s.offX, y: (ev.clientY - rb.top) * k - s.offY };
        applyGhost();
      }
      const g = ghostRef.current;
      if (g && !reducedRef.current) {
        g.style.rotate = clamp((ev.clientX - s.lastX) * 0.6, -6, 6) + "deg";
        window.clearTimeout(tiltTimer.current);
        tiltTimer.current = window.setTimeout(() => {
          if (ghostRef.current) ghostRef.current.style.rotate = "0deg";
        }, 90);
      }
      s.lastX = ev.clientX;
      retarget();
    };
    const onUp = (ev: PointerEvent) => {
      const s = sessionRef.current;
      if (!s || ev.pointerId !== s.pointerId) return;
      finish(ev.type === "pointercancel");
    };
    const onKey = (ev: KeyboardEvent) => {
      if (ev.key === "Escape" && sessionRef.current?.started) {
        ev.preventDefault();
        finish(true);
      }
    };
    window.addEventListener("pointermove", onMove);
    window.addEventListener("pointerup", onUp);
    window.addEventListener("pointercancel", onUp);
    window.addEventListener("keydown", onKey);
    cleanupRef.current = () => {
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
      window.removeEventListener("pointercancel", onUp);
      window.removeEventListener("keydown", onKey);
    };
  };

  /* ---- Keyboard --------------------------------------------------------- */

  const describe = (id: string): string => {
    const at = locate(id);
    const card = at ? (colsRef.current[at.name] ?? [])[at.index] : undefined;
    if (!at || !card) return "";
    return card.label + ", " + at.name + ", position " + (at.index + 1) + " of " + (colsRef.current[at.name] ?? []).length + ".";
  };

  const focusGrip = (id: string) => liFor(id)?.querySelector<HTMLElement>("button")?.focus();

  const keyMove = (id: string, to: string, index: number) => {
    const at = locate(id);
    if (!at) return;
    if (moveTo(id, to, index)) {
      revealColumn(to);
      say(describe(id));
    } else {
      say(describe(id).replace(/\.$/, "") + ". Can't move further.");
    }
  };

  const dropKey = (id: string) => {
    const board = colsRef.current;
    const origin = keyOriginRef.current;
    keyOriginRef.current = null;
    pickedRef.current = null;
    setPicked(null);
    flash(id);
    say("Dropped. " + describe(id));
    if (origin && !sameBoard(origin, board)) emit(board);
  };

  const onGripKey = (e: ReactKeyboardEvent<HTMLButtonElement>, id: string) => {
    if (sessionRef.current || dragIdRef.current) return;
    const names = Object.keys(colsRef.current);
    const at = locate(id);
    if (!at) return;
    const k = e.key;
    const colIdx = names.indexOf(at.name);
    const isPicked = pickedRef.current === id;
    if (!isPicked) {
      if (k === " " || k === "Enter") {
        e.preventDefault();
        keyOriginRef.current = cloneColumns(colsRef.current);
        pickedRef.current = id;
        setPicked(id);
        say("Picked up " + describe(id) + " Arrow keys move it, left and right change column, space drops, escape cancels.");
        return;
      }
      let dest: KanbanCard | undefined;
      if (k === "ArrowUp" || k === "ArrowDown") {
        dest = (colsRef.current[at.name] ?? [])[at.index + (k === "ArrowUp" ? -1 : 1)];
      } else if (k === "ArrowLeft" || k === "ArrowRight") {
        const step = k === "ArrowLeft" ? -1 : 1;
        for (let c = colIdx + step; c >= 0 && c < names.length && !dest; c += step) {
          const list = colsRef.current[names[c] ?? ""] ?? [];
          dest = list[Math.min(at.index, list.length - 1)];
        }
      } else return;
      e.preventDefault();
      if (dest) focusGrip(dest.id);
      return;
    }
    const lastIdx = (colsRef.current[at.name] ?? []).length - 1;
    if (k === "ArrowUp") {
      e.preventDefault();
      keyMove(id, at.name, at.index - 1);
    } else if (k === "ArrowDown") {
      e.preventDefault();
      keyMove(id, at.name, at.index + 1);
    } else if (k === "ArrowLeft" || k === "ArrowRight") {
      e.preventDefault();
      const dest = names[colIdx + (k === "ArrowLeft" ? -1 : 1)];
      if (dest) keyMove(id, dest, at.index);
      else say(describe(id).replace(/\.$/, "") + ". No column that way.");
    } else if (k === "Home") {
      e.preventDefault();
      keyMove(id, at.name, 0);
    } else if (k === "End") {
      e.preventDefault();
      keyMove(id, at.name, lastIdx);
    } else if (k === " " || k === "Enter") {
      e.preventDefault();
      dropKey(id);
    } else if (k === "Escape") {
      e.preventDefault();
      const origin = keyOriginRef.current;
      keyOriginRef.current = null;
      pickedRef.current = null;
      setPicked(null);
      if (origin) {
        focusAfterRef.current = id;
        pendingRef.current = captureAll();
        colsRef.current = origin;
        setCols(origin);
        say("Cancelled. " + (locate(id, origin) ? describeIn(origin, id) : ""));
      }
    }
  };

  const onGripBlur = (id: string) => {
    window.setTimeout(() => {
      if (pickedRef.current !== id) return;
      const grip = liFor(id)?.querySelector("button");
      if (grip && document.activeElement === grip) return;
      dropKey(id);
    }, 0);
  };

  const names = Object.keys(cols);
  const safeTone = tone === "light" ? "light" : "dark";
  const dragged = drag ? findCard(cols, drag.id) : null;

  return (
    <div ref={rootRef} className={"kb " + className} data-tone={safeTone}>
      <style>{CSS}</style>
      <div ref={scrollRef} className="kb-scroll" role="group" aria-label="Kanban board">
        {names.map((name, ci) => {
          const cards = cols[name] ?? [];
          return (
            <section
              key={name}
              className="kb-col"
              data-col={name}
              data-over={over === name ? "" : undefined}
              aria-labelledby={"kb-h-" + uid + "-" + ci}
              style={{ "--kb-dot": COLUMN_DOTS[ci % COLUMN_DOTS.length] } as CSSProperties}
            >
              <header className="kb-head">
                <span className="kb-dot" aria-hidden="true" />
                <h3 className="kb-name" id={"kb-h-" + uid + "-" + ci}>
                  {name}
                  <span className="kb-sr">{", " + cards.length + (cards.length === 1 ? " card" : " cards")}</span>
                </h3>
                <span className="kb-count">
                  <Roll value={cards.length} />
                </span>
              </header>
              <ul className="kb-list" data-empty={cards.length === 0 ? "" : undefined} aria-label={name + " cards"}>
                {cards.map((card, i) => (
                  <li
                    key={card.id}
                    className="kb-li"
                    data-cid={card.id}
                    data-placeholder={drag && drag.id === card.id ? "" : undefined}
                    data-picked={picked === card.id ? "" : undefined}
                    data-settled={settled === card.id ? "" : undefined}
                  >
                    <div className="kb-card" data-notag={card.tag ? undefined : ""} onPointerDown={(e) => onGripDown(e, card.id)}>
                      <CardFace card={card} />
                      <button
                        type="button"
                        className="kb-grip"
                        aria-label={"Move " + card.label + ", " + name + ", position " + (i + 1) + " of " + cards.length}
                        aria-pressed={picked === card.id}
                        aria-describedby={"kb-help-" + uid}
                        onKeyDown={(e) => onGripKey(e, card.id)}
                        onBlur={() => onGripBlur(card.id)}
                      >
                        <svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
                          <circle cx="5.5" cy="3.5" r="1.3" />
                          <circle cx="10.5" cy="3.5" r="1.3" />
                          <circle cx="5.5" cy="8" r="1.3" />
                          <circle cx="10.5" cy="8" r="1.3" />
                          <circle cx="5.5" cy="12.5" r="1.3" />
                          <circle cx="10.5" cy="12.5" r="1.3" />
                        </svg>
                      </button>
                    </div>
                  </li>
                ))}
              </ul>
            </section>
          );
        })}
      </div>
      {drag && dragged ? (
        <div ref={ghostRef} className="kb-ghost" aria-hidden="true" style={{ width: drag.w }}>
          <div className="kb-card" data-notag={dragged.tag ? undefined : ""}>
            <CardFace card={dragged} />
          </div>
        </div>
      ) : null}
      <div id={"kb-help-" + uid} className="kb-sr">
        Press space to pick up a card, the arrow keys to move it, space to drop and escape to cancel.
      </div>
      <div className="kb-sr" role="status" aria-live="assertive" aria-atomic="true">
        {message}
      </div>
    </div>
  );
}

function findCard(board: KanbanColumns, id: string): KanbanCard | null {
  for (const name of Object.keys(board)) {
    const hit = (board[name] ?? []).find((c) => c.id === id);
    if (hit) return hit;
  }
  return null;
}

function describeIn(board: KanbanColumns, id: string): string {
  for (const name of Object.keys(board)) {
    const list = board[name] ?? [];
    const i = list.findIndex((c) => c.id === id);
    const card = list[i];
    if (card) return card.label + " returned to " + name + ", position " + (i + 1) + " of " + list.length + ".";
  }
  return "";
}

function sameBoard(a: KanbanColumns, b: KanbanColumns): boolean {
  const an = Object.keys(a);
  if (an.length !== Object.keys(b).length) return false;
  for (const name of an) {
    const x = a[name] ?? [];
    const y = b[name] ?? [];
    if (x.length !== y.length) return false;
    for (let i = 0; i < x.length; i++) if (x[i]?.id !== y[i]?.id) return false;
  }
  return true;
}

Free. Pro unlocks everything, from $27/month.

Details

About this pattern

KanbanDragColumns takes initialColumns, a record of column name to cards, and renders one panel per column with a rolling count badge. Each card has a title and an optional tag, priority glyph (low, medium, high, urgent) and assignee initials. Press a card with a mouse or pen, or its grip with a finger, and move four pixels: a lifted copy (scale 1.035, deep shadow, a few degrees of tilt that follows your horizontal speed) follows the pointer while the card’s slot stays behind as a dashed placeholder. The column under the pointer lights up with an accent border, glow and top line, the placeholder jumps to the slot matching the pointer’s height (the card midpoint rule), and every other card glides to its new place on a damped spring (stiffness 380, damping 26) through FLIP with the Web Animations API. On release the copy flies into the placeholder and the card pulses an accent ring. Near the left or right edge the board auto-scrolls. On phones the columns are 84% wide with scroll snap, so the next column peeks in, and touch drags start from the grip so sideways scrolling still works everywhere else. Positions are measured in layout pixels, so it stays correct inside scaled previews. New in this version: onChange (called with the whole board after each committed move), tone (dark or light) and className, where the default w-full sets the width. Cards gained optional tag, priority and assignee fields. The board paints its own surface and has no dependencies. Limits: column names must be unique, the board is read once on mount, and there is no add, rename or delete.

Curator’s note

Rebuilt in October without Framer Motion: the same initialColumns board, now with hand-written pointer dragging, a floating copy of the card, spring FLIP motion and a keyboard path.

Pairs well with