Open demo

CSS code

HTML
<div><span class="lbl">Task board with a work-in-progress limit</span>
<section class="kb" data-dd aria-labelledby="kb1-title">
  <div class="kb-head"><h2 id="kb1-title">Website relaunch · Sprint 14</h2><p id="kb1-hint">Drag a card, or focus its handle, press Space and use the arrow keys. Left and Right change column.</p></div>
  <div class="kb-cols">
    <div class="kb-col" data-dd-zone><div class="kb-colhead"><span class="kb-dot"></span><h3>To do</h3><span class="kb-count"><span data-dd-count="kb1-todo">3</span><span class="sr-only"> cards</span></span></div>
      <ul class="kb-list" id="kb1-todo" data-dd-list="tasks" aria-label="To do">
        <li class="kb-card" data-dd-item><div class="kb-top"><span class="kb-tag t-copy">Copy</span><button class="kb-handle" type="button" data-dd-handle aria-describedby="kb1-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="kb-title" data-dd-name>Write the pricing page FAQ</p>
          <div class="kb-meta"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 22</span><span class="kb-av" role="img" aria-label="Priya Nair">PN</span></div></li>
        <li class="kb-card" data-dd-item><div class="kb-top"><span class="kb-tag t-design">Design</span><button class="kb-handle" type="button" data-dd-handle aria-describedby="kb1-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="kb-title" data-dd-name>Homepage hero illustrations</p>
          <div class="kb-meta"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 24</span><span class="kb-av" role="img" aria-label="Ana Ruiz">AR</span></div></li>
        <li class="kb-card" data-dd-item><div class="kb-top"><span class="kb-tag t-dev">Dev</span><button class="kb-handle" type="button" data-dd-handle aria-describedby="kb1-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="kb-title" data-dd-name>Set up 301 redirects</p>
          <div class="kb-meta"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 25</span><span class="kb-av" role="img" aria-label="Tomás Kral">TK</span></div></li>
      </ul></div>
    <div class="kb-col" data-dd-zone><div class="kb-colhead"><span class="kb-dot d-indigo"></span><h3>In progress</h3><span class="kb-count"><span data-dd-count="kb1-doing">2</span> / 3<span class="sr-only"> cards, limit 3</span></span></div>
      <ul class="kb-list" id="kb1-doing" data-dd-list="tasks" data-dd-max="3" aria-label="In progress">
        <li class="kb-card" data-dd-item><div class="kb-top"><span class="kb-tag t-dev">Dev</span><button class="kb-handle" type="button" data-dd-handle aria-describedby="kb1-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="kb-title" data-dd-name>Rebuild the navigation menu</p>
          <div class="kb-meta"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 18</span><span class="kb-av" role="img" aria-label="Tomás Kral">TK</span></div></li>
        <li class="kb-card" data-dd-item><div class="kb-top"><span class="kb-tag t-design">Design</span><button class="kb-handle" type="button" data-dd-handle aria-describedby="kb1-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="kb-title" data-dd-name>Retouch the product photos</p>
          <div class="kb-meta"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 19</span><span class="kb-av" role="img" aria-label="Ana Ruiz">AR</span></div></li>
      </ul></div>
    <div class="kb-col" data-dd-zone><div class="kb-colhead"><span class="kb-dot d-green"></span><h3>Done</h3><span class="kb-count"><span data-dd-count="kb1-done">2</span><span class="sr-only"> cards</span></span></div>
      <ul class="kb-list" id="kb1-done" data-dd-list="tasks" aria-label="Done">
        <li class="kb-card" data-dd-item><div class="kb-top"><span class="kb-tag t-copy">Copy</span><button class="kb-handle" type="button" data-dd-handle aria-describedby="kb1-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="kb-title" data-dd-name>Audit the old blog posts</p>
          <div class="kb-meta"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 12</span><span class="kb-av" role="img" aria-label="Priya Nair">PN</span></div></li>
        <li class="kb-card" data-dd-item><div class="kb-top"><span class="kb-tag t-design">Design</span><button class="kb-handle" type="button" data-dd-handle aria-describedby="kb1-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="kb-title" data-dd-name>Choose the new type pairing</p>
          <div class="kb-meta"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 10</span><span class="kb-av" role="img" aria-label="Lea Moreau">LM</span></div></li>
      </ul></div>
  </div>
  <p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>

<div><span class="lbl">Sales pipeline with column totals</span>
<section class="kb" data-dd data-pipeline aria-labelledby="kb2-title">
  <div class="kb-head"><h2 id="kb2-title">Q4 pipeline · <span data-pipe-total>$33,000</span> open</h2><p id="kb2-hint">Column totals update as deals move.</p></div>
  <div class="kb-cols">
    <div class="kb-col" data-dd-zone><div class="kb-colhead"><span class="kb-dot"></span><h3>Lead</h3><span class="kb-total" data-sum="kb2-lead">$11,600</span></div>
      <ul class="kb-list" id="kb2-lead" data-dd-list="deals" aria-label="Lead">
        <li class="kb-card" data-dd-item data-value="8400"><div class="kb-top"><span class="kb-title" data-dd-name>Northwind Clinics</span><button class="kb-handle" type="button" data-dd-handle aria-describedby="kb2-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="kb-meta"><span class="kb-value">$8,400</span><span>Maya Chen</span></div></li>
        <li class="kb-card" data-dd-item data-value="3200"><div class="kb-top"><span class="kb-title" data-dd-name>Harbor & Pine</span><button class="kb-handle" type="button" data-dd-handle aria-describedby="kb2-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="kb-meta"><span class="kb-value">$3,200</span><span>Leo Brandt</span></div></li>
      </ul></div>
    <div class="kb-col" data-dd-zone><div class="kb-colhead"><span class="kb-dot d-amber"></span><h3>Qualified</h3><span class="kb-total" data-sum="kb2-qual">$14,600</span></div>
      <ul class="kb-list" id="kb2-qual" data-dd-list="deals" aria-label="Qualified">
        <li class="kb-card" data-dd-item data-value="12000"><div class="kb-top"><span class="kb-title" data-dd-name>Atlas Freight</span><button class="kb-handle" type="button" data-dd-handle aria-describedby="kb2-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="kb-meta"><span class="kb-value">$12,000</span><span>Ines Duarte</span></div></li>
        <li class="kb-card" data-dd-item data-value="2600"><div class="kb-top"><span class="kb-title" data-dd-name>Blue Fern Studio</span><button class="kb-handle" type="button" data-dd-handle aria-describedby="kb2-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="kb-meta"><span class="kb-value">$2,600</span><span>Sam Okafor</span></div></li>
      </ul></div>
    <div class="kb-col" data-dd-zone><div class="kb-colhead"><span class="kb-dot d-indigo"></span><h3>Proposal</h3><span class="kb-total" data-sum="kb2-prop">$6,800</span></div>
      <ul class="kb-list" id="kb2-prop" data-dd-list="deals" aria-label="Proposal">
        <li class="kb-card" data-dd-item data-value="6800"><div class="kb-top"><span class="kb-title" data-dd-name>Kestrel Bikes</span><button class="kb-handle" type="button" data-dd-handle aria-describedby="kb2-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="kb-meta"><span class="kb-value">$6,800</span><span>Ruth Adler</span></div></li>
      </ul></div>
    <div class="kb-col" data-dd-zone><div class="kb-colhead"><span class="kb-dot d-green"></span><h3>Won</h3><span class="kb-total" data-sum="kb2-won">$4,500</span></div>
      <ul class="kb-list" id="kb2-won" data-dd-list="deals" aria-label="Won">
        <li class="kb-card" data-dd-item data-value="4500"><div class="kb-top"><span class="kb-title" data-dd-name>Orchard Dental</span><button class="kb-handle" type="button" data-dd-handle aria-describedby="kb2-hint"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="kb-meta"><span class="kb-value">$4,500</span><span>Kai Lindqvist</span></div></li>
      </ul></div>
  </div>
  <p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>
CSS
/* Kanban board: each column is a <ul data-dd-list="…"> with an aria-label; cards move between columns
   with the pointer, or with Space + arrow keys from the card's handle. Counts and totals are rewritten on every move. */
.kb{--kb-accent:#4f46e5;--kb-tint:#eef0ff;--kb-col:#eceef3;--kb-line:#dfe3ea;--kb-muted:#5d6679;--kb-ink:#101828;--kb-danger:#b42318;color:var(--kb-ink)}
.kb-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px 16px;flex-wrap:wrap;margin-bottom:14px}
.kb-head h2{font-size:1.1rem;letter-spacing:-.01em}
.kb-head p{font-size:.82rem;color:var(--kb-muted)}
.kb-cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px;align-items:start}
.kb-col{background:var(--kb-col);border-radius:16px;padding:12px}
.kb-colhead{display:flex;align-items:center;gap:8px;padding:2px 4px 10px}
.kb-colhead h3{font-size:.86rem;font-weight:700}
.kb-dot{width:8px;height:8px;border-radius:50%;background:#98a2b3}
.kb-dot.d-indigo{background:#4f46e5}.kb-dot.d-green{background:#12b76a}.kb-dot.d-amber{background:#f79009}
.kb-count{margin-left:auto;font-size:.75rem;font-weight:700;padding:1px 8px;border-radius:999px;background:#fff;color:#344054;font-variant-numeric:tabular-nums}
.kb-col:has([data-full]) .kb-count{background:#fef3f2;color:var(--kb-danger)}
.kb-list{list-style:none;display:grid;gap:8px;min-height:72px;padding:2px;border-radius:12px;outline:2px dashed transparent;outline-offset:2px}
.kb-list[data-empty]::after{content:"Drop cards here";display:grid;place-items:center;height:68px;border:2px dashed #b3bbc8;border-radius:12px;font-size:.8rem;color:var(--kb-muted)}
.kb[data-dd-active] .kb-list{outline-color:#c3c9d4}
.kb .kb-list[data-over]{outline-color:var(--kb-accent);background:rgba(79,70,229,.06)}
.kb .kb-list[data-over][data-full]:not(:has([data-dragging],[data-grabbed])){outline-color:var(--kb-danger);background:rgba(180,35,24,.06)}
.kb-card{display:grid;gap:8px;padding:12px 12px 12px 14px;background:#fff;border:1px solid var(--kb-line);border-radius:12px;box-shadow:0 1px 2px rgba(16,24,40,.06);cursor:grab;user-select:none}
.kb-top{display:flex;align-items:center;justify-content:space-between;gap:8px}
.kb-tag{font-size:.7rem;font-weight:700;padding:2px 8px;border-radius:6px;background:#f2f4f7;color:#344054}
.kb-tag.t-design{background:#eef4ff;color:#3538cd}.kb-tag.t-dev{background:#ecfdf3;color:#067647}.kb-tag.t-copy{background:#fdf2fa;color:#9e165f}
.kb-handle{display:grid;place-items:center;width:28px;height:28px;margin:-4px -4px -4px 0;border:0;border-radius:7px;background:none;color:#667085;cursor:grab;touch-action:none}
.kb-handle:hover{background:#f2f4f7;color:var(--kb-ink)}
.kb-handle:focus-visible{outline:2px solid var(--kb-accent);outline-offset:1px}
.kb-handle svg{width:16px;height:16px}
.kb-title{font-size:.9rem;font-weight:600;line-height:1.35}
.kb-meta{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:.76rem;color:var(--kb-muted)}
.kb-meta svg{width:14px;height:14px;vertical-align:-2px;margin-right:4px}
.kb-av{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;font-size:.62rem;font-weight:800;background:#e0e7ff;color:#3730a3}
.kb-value{font-size:.95rem;font-weight:800;font-variant-numeric:tabular-nums}
.kb-total{margin-left:auto;font-size:.78rem;font-weight:700;color:#344054;font-variant-numeric:tabular-nums}
/* states set by the script */
.kb-card[data-dragging]{background:var(--kb-tint);border:2px dashed #a5b4fc;box-shadow:none}
.kb-card[data-dragging]>*{visibility:hidden}
.kb-card[data-dd-ghost]{box-shadow:0 20px 40px -14px rgba(16,24,40,.4);rotate:2deg}
.kb-card[data-grabbed]{border-color:var(--kb-accent);box-shadow:0 0 0 2px var(--kb-accent),0 12px 24px -12px rgba(79,70,229,.55)}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.kb-list{transition:background-color .15s,outline-color .15s}
@media (prefers-reduced-motion:reduce){.kb-card[data-dd-ghost]{rotate:none}.kb-list{transition:none}}
JavaScript
// Pipeline: add up each column's deal values, and the open total (every column except the last, Won).
document.addEventListener('dd:change', e => {
  const root = e.target; if (!root.matches('[data-pipeline]')) return;
  const usd = n => '$' + n.toLocaleString('en-US'); let open = 0;
  const cols = root.querySelectorAll('[data-dd-list]');
  cols.forEach((l, i) => { const sum = [...l.querySelectorAll('[data-dd-item]')].reduce((s, li) => s + +li.dataset.value, 0);
    root.querySelector(`[data-sum="${l.id}"]`).textContent = usd(sum); if (i < cols.length - 1) open += sum; });
  root.querySelector('[data-pipe-total]').textContent = usd(open);
});
// Drag and drop with pointer events (mouse, touch and pen) plus a keyboard mode on every handle:
// Space or Enter picks an item up, the arrow keys move it, Space or Enter drops it, Escape puts it back.
// Every move is announced in the component's aria-live region. Markup contract: [data-dd] root,
// [data-dd-list="group"] lists, [data-dd-item] items with a [data-dd-name] and a [data-dd-handle] button.
// data-dd="grid" or "wrap" compares the pointer horizontally; in a grid, Up and Down jump a whole row.
document.querySelectorAll('[data-dd]').forEach(root => {
  const grid = root.dataset.dd === 'grid', horiz = grid || root.dataset.dd === 'wrap', live = root.querySelector('[data-dd-live]');
  const items = l => [...l.children].filter(c => c.hasAttribute('data-dd-item'));
  const lists = l => [...root.querySelectorAll(`[data-dd-list="${l.dataset.ddList}"]`)];
  const name = el => el.querySelector('[data-dd-name]').textContent.trim();
  const full = l => !!l.dataset.ddMax && items(l).length >= +l.dataset.ddMax;
  const pos = el => { const l = el.parentElement, all = items(l);
    return (lists(l).length > 1 ? l.getAttribute('aria-label') + ', ' : '') + 'position ' + (all.indexOf(el) + 1) + ' of ' + all.length; };
  const say = msg => { live.textContent = ''; setTimeout(() => { live.textContent = msg; }, 60); };
  const clearOver = keep => root.querySelectorAll('[data-over]').forEach(l => l !== keep && l.removeAttribute('data-over'));
  const marks = () => root.querySelectorAll('[data-dd-list]').forEach(l => { const n = items(l).length, c = root.querySelector(`[data-dd-count="${l.id}"]`);
    l.toggleAttribute('data-empty', !n); l.toggleAttribute('data-full', full(l)); if (c) c.textContent = n; });
  const changed = () => {
    marks();
    root.querySelectorAll('[data-dd-item]').forEach(it => it.querySelector('[data-dd-handle]').setAttribute('aria-label', 'Move ' + name(it)));
    root.dispatchEvent(new Event('dd:change', { bubbles: true }));
  };
  let drag, ghost, home, kb, kbh, sx, sy, ox, oy, pid, dragged, moving;
  const putBack = el => home[0].insertBefore(el, home[1] && home[1].parentNode === home[0] ? home[1] : null);

  root.addEventListener('pointerdown', e => {
    const item = e.target.closest('[data-dd-item]'), handle = e.target.closest('[data-dd-handle]');
    if (!item || e.button > 0 || kb) return;
    if (!handle && (e.pointerType !== 'mouse' || 'ddHandleOnly' in root.dataset || e.target.closest('a,button,input,select,textarea,label'))) return;
    e.preventDefault(); drag = item; pid = e.pointerId; sx = e.clientX; sy = e.clientY; ghost = null;
  });
  root.addEventListener('pointermove', e => {
    if (!drag || e.pointerId !== pid) return;
    if (!ghost) { if (Math.hypot(e.clientX - sx, e.clientY - sy) < 6) return; start(); root.setPointerCapture(pid); }
    ghost.style.translate = `${e.clientX - ox}px ${e.clientY - oy}px`;
    if (e.clientY < 40) scrollBy(0, -12); else if (e.clientY > innerHeight - 40) scrollBy(0, 12);
    const hit = document.elementFromPoint(e.clientX, e.clientY), sel = `[data-dd-list="${home[0].dataset.ddList}"]`;
    const list = hit && (hit.closest(sel) || hit.closest('[data-dd-zone]')?.querySelector(sel)); // a [data-dd-zone] wrapper widens the target
    clearOver(list);
    if (!list || !root.contains(list) || drag.contains(list)) return;
    list.setAttribute('data-over', '');
    if (list !== drag.parentElement && full(list)) return;
    const over = items(list).find(c => c !== drag && c.contains(hit));
    if (over) { const r = over.getBoundingClientRect();
      const ref = (horiz ? e.clientX > r.left + r.width / 2 : e.clientY > r.top + r.height / 2) ? over.nextSibling : over;
      if (ref !== drag && ref !== drag.nextSibling) { list.insertBefore(drag, ref); marks(); }
    } else if (list !== drag.parentElement && !drag.contains(hit)) { list.append(drag); marks(); }
  });
  const end = e => {
    if (!drag || e.pointerId !== pid) return;
    if (ghost) {
      ghost.remove(); drag.removeAttribute('data-dragging'); root.removeAttribute('data-dd-active'); document.body.style.cursor = ''; clearOver();
      if (e.type === 'pointercancel') putBack(drag); else say(name(drag) + ' moved to ' + pos(drag) + '.');
      changed(); dragged = true; setTimeout(() => { dragged = false; });
    }
    drag = null;
  };
  root.addEventListener('pointerup', end); root.addEventListener('pointercancel', end);
  function start() {
    const r = drag.getBoundingClientRect(); ox = sx - r.left; oy = sy - r.top; home = [drag.parentElement, drag.nextSibling];
    ghost = drag.cloneNode(true); ghost.removeAttribute('data-dd-item'); ghost.setAttribute('data-dd-ghost', ''); ghost.setAttribute('aria-hidden', 'true'); ghost.inert = true;
    ghost.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));
    Object.assign(ghost.style, { position: 'fixed', left: 0, top: 0, width: r.width + 'px', height: r.height + 'px', margin: 0, zIndex: 999, pointerEvents: 'none' });
    drag.after(ghost); drag.setAttribute('data-dragging', ''); root.setAttribute('data-dd-active', ''); document.body.style.cursor = 'grabbing';
  }

  // Keyboard and screen reader mode: the handle is a toggle button (aria-pressed) that picks the item up.
  const finish = msg => { kb.removeAttribute('data-grabbed'); kbh.setAttribute('aria-pressed', 'false'); root.removeAttribute('data-dd-active'); clearOver(); kb = null; changed(); say(msg); };
  const move = fn => { moving = true; fn(); kbh.focus(); moving = false; kbh.scrollIntoView({ block: 'nearest' }); clearOver(kb.parentElement); kb.parentElement.setAttribute('data-over', ''); changed(); say(name(kb) + ' moved to ' + pos(kb) + '.'); };
  root.addEventListener('click', e => {
    const h = e.target.closest('[data-dd-handle]'); if (!h || dragged) return;
    const item = h.closest('[data-dd-item]');
    if (kb === item) return finish(name(item) + ' dropped, ' + pos(item) + '.');
    if (kb) finish(name(kb) + ' dropped, ' + pos(kb) + '.');
    kb = item; kbh = h; home = [item.parentElement, item.nextSibling];
    item.setAttribute('data-grabbed', ''); h.setAttribute('aria-pressed', 'true'); root.setAttribute('data-dd-active', ''); item.parentElement.setAttribute('data-over', ''); h.focus();
    say(name(item) + ' picked up, ' + pos(item) + '. Use the arrow keys to move it, Space to drop it, Escape to cancel.');
  });
  root.addEventListener('keydown', e => {
    if (!kb || e.target !== kbh) return;
    if (e.key === 'Escape') { e.preventDefault(); moving = true; putBack(kb); kbh.focus(); moving = false; return finish('Move cancelled. ' + name(kb) + ' returned to ' + pos(kb) + '.'); }
    const d = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key]; if (!d) return;
    e.preventDefault();
    const l = kb.parentElement, all = items(l), i = all.indexOf(kb), ls = lists(l), side = e.key === 'ArrowLeft' || e.key === 'ArrowRight';
    if (side && ls.length > 1) {
      const t = ls[ls.indexOf(l) + d];
      if (!t) return say(name(kb) + ' is already in ' + l.getAttribute('aria-label') + ', the ' + (d < 0 ? 'first' : 'last') + ' list.');
      if (full(t)) return say(t.getAttribute('aria-label') + ' is full.');
      return move(() => t.insertBefore(kb, items(t)[i] || null));
    }
    const step = grid && !side ? d * all.filter(x => x.offsetTop === all[0].offsetTop).length : d, j = Math.max(0, Math.min(all.length - 1, i + step));
    if (j === i) return say(name(kb) + ' is already ' + (d < 0 ? 'first' : 'last') + '.');
    move(() => l.insertBefore(kb, j > i ? all[j].nextSibling : all[j]));
  });
  root.addEventListener('focusout', e => { if (kb && !moving && e.target === kbh) finish(name(kb) + ' dropped, ' + pos(kb) + '.'); });
  changed();
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Task board with a work-in-progress limit</span>
<section class="kb" data-dd aria-labelledby="kb1-title">
  <div class="kb-head d-flex flex-wrap align-items-baseline justify-content-between column-gap-3 row-gap-2 mb-3"><h2 class="fw-bold mb-0" id="kb1-title">Website relaunch · Sprint 14</h2><p class="text-body-secondary mb-0" id="kb1-hint">Drag a card, or focus its handle, press Space and use the arrow keys. Left and Right change column.</p></div>
  <div class="row row-cols-1 row-cols-md-3 g-3 align-items-start">
    <div class="col"><div class="kb-col" data-dd-zone><div class="d-flex align-items-center gap-2 px-1 pb-2"><span class="kb-dot rounded-circle d-grey"></span><h3 class="fw-bold mb-0">To do</h3><span class="kb-count badge rounded-pill bg-white text-body ms-auto"><span data-dd-count="kb1-todo">3</span><span class="visually-hidden"> cards</span></span></div>
      <ul class="kb-list list-unstyled" id="kb1-todo" data-dd-list="tasks" aria-label="To do">
        <li class="kb-card card d-grid" data-dd-item><div class="d-flex align-items-center justify-content-between gap-2"><span class="badge t-copy">Copy</span><button class="kb-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="kb1-hint"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="kb-title fw-semibold mb-0" data-dd-name>Write the pricing page FAQ</p>
          <div class="kb-meta d-flex align-items-center justify-content-between gap-2 text-body-secondary"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 22</span><span class="kb-av rounded-circle d-grid align-content-center text-center" role="img" aria-label="Priya Nair">PN</span></div></li>
        <li class="kb-card card d-grid" data-dd-item><div class="d-flex align-items-center justify-content-between gap-2"><span class="badge t-design">Design</span><button class="kb-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="kb1-hint"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="kb-title fw-semibold mb-0" data-dd-name>Homepage hero illustrations</p>
          <div class="kb-meta d-flex align-items-center justify-content-between gap-2 text-body-secondary"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 24</span><span class="kb-av rounded-circle d-grid align-content-center text-center" role="img" aria-label="Ana Ruiz">AR</span></div></li>
        <li class="kb-card card d-grid" data-dd-item><div class="d-flex align-items-center justify-content-between gap-2"><span class="badge t-dev">Dev</span><button class="kb-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="kb1-hint"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="kb-title fw-semibold mb-0" data-dd-name>Set up 301 redirects</p>
          <div class="kb-meta d-flex align-items-center justify-content-between gap-2 text-body-secondary"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 25</span><span class="kb-av rounded-circle d-grid align-content-center text-center" role="img" aria-label="Tomás Kral">TK</span></div></li>
      </ul></div></div>
    <div class="col"><div class="kb-col" data-dd-zone><div class="d-flex align-items-center gap-2 px-1 pb-2"><span class="kb-dot rounded-circle d-indigo"></span><h3 class="fw-bold mb-0">In progress</h3><span class="kb-count badge rounded-pill bg-white text-body ms-auto"><span data-dd-count="kb1-doing">2</span> / 3<span class="visually-hidden"> cards, limit 3</span></span></div>
      <ul class="kb-list list-unstyled" id="kb1-doing" data-dd-list="tasks" data-dd-max="3" aria-label="In progress">
        <li class="kb-card card d-grid" data-dd-item><div class="d-flex align-items-center justify-content-between gap-2"><span class="badge t-dev">Dev</span><button class="kb-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="kb1-hint"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="kb-title fw-semibold mb-0" data-dd-name>Rebuild the navigation menu</p>
          <div class="kb-meta d-flex align-items-center justify-content-between gap-2 text-body-secondary"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 18</span><span class="kb-av rounded-circle d-grid align-content-center text-center" role="img" aria-label="Tomás Kral">TK</span></div></li>
        <li class="kb-card card d-grid" data-dd-item><div class="d-flex align-items-center justify-content-between gap-2"><span class="badge t-design">Design</span><button class="kb-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="kb1-hint"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="kb-title fw-semibold mb-0" data-dd-name>Retouch the product photos</p>
          <div class="kb-meta d-flex align-items-center justify-content-between gap-2 text-body-secondary"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 19</span><span class="kb-av rounded-circle d-grid align-content-center text-center" role="img" aria-label="Ana Ruiz">AR</span></div></li>
      </ul></div></div>
    <div class="col"><div class="kb-col" data-dd-zone><div class="d-flex align-items-center gap-2 px-1 pb-2"><span class="kb-dot rounded-circle d-green"></span><h3 class="fw-bold mb-0">Done</h3><span class="kb-count badge rounded-pill bg-white text-body ms-auto"><span data-dd-count="kb1-done">2</span><span class="visually-hidden"> cards</span></span></div>
      <ul class="kb-list list-unstyled" id="kb1-done" data-dd-list="tasks" aria-label="Done">
        <li class="kb-card card d-grid" data-dd-item><div class="d-flex align-items-center justify-content-between gap-2"><span class="badge t-copy">Copy</span><button class="kb-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="kb1-hint"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="kb-title fw-semibold mb-0" data-dd-name>Audit the old blog posts</p>
          <div class="kb-meta d-flex align-items-center justify-content-between gap-2 text-body-secondary"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 12</span><span class="kb-av rounded-circle d-grid align-content-center text-center" role="img" aria-label="Priya Nair">PN</span></div></li>
        <li class="kb-card card d-grid" data-dd-item><div class="d-flex align-items-center justify-content-between gap-2"><span class="badge t-design">Design</span><button class="kb-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="kb1-hint"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="kb-title fw-semibold mb-0" data-dd-name>Choose the new type pairing</p>
          <div class="kb-meta d-flex align-items-center justify-content-between gap-2 text-body-secondary"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 10</span><span class="kb-av rounded-circle d-grid align-content-center text-center" role="img" aria-label="Lea Moreau">LM</span></div></li>
      </ul></div></div>
  </div>
  <p class="visually-hidden" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>

<div><span class="lbl">Sales pipeline with column totals</span>
<section class="kb" data-dd data-pipeline aria-labelledby="kb2-title">
  <div class="kb-head d-flex flex-wrap align-items-baseline justify-content-between column-gap-3 row-gap-2 mb-3"><h2 class="fw-bold mb-0" id="kb2-title">Q4 pipeline · <span data-pipe-total>$33,000</span> open</h2><p class="text-body-secondary mb-0" id="kb2-hint">Column totals update as deals move.</p></div>
  <div class="row row-cols-1 row-cols-sm-2 row-cols-lg-4 g-3 align-items-start">
    <div class="col"><div class="kb-col" data-dd-zone><div class="d-flex align-items-center gap-2 px-1 pb-2"><span class="kb-dot rounded-circle d-grey"></span><h3 class="fw-bold mb-0">Lead</h3><span class="kb-total fw-bold ms-auto" data-sum="kb2-lead">$11,600</span></div>
      <ul class="kb-list list-unstyled" id="kb2-lead" data-dd-list="deals" aria-label="Lead">
        <li class="kb-card card d-grid" data-dd-item data-value="8400"><div class="d-flex align-items-center justify-content-between gap-2"><span class="kb-title fw-semibold" data-dd-name>Northwind Clinics</span><button class="kb-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="kb2-hint"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="kb-meta d-flex align-items-center justify-content-between gap-2 text-body-secondary"><span class="kb-value fw-bolder text-body">$8,400</span><span>Maya Chen</span></div></li>
        <li class="kb-card card d-grid" data-dd-item data-value="3200"><div class="d-flex align-items-center justify-content-between gap-2"><span class="kb-title fw-semibold" data-dd-name>Harbor & Pine</span><button class="kb-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="kb2-hint"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="kb-meta d-flex align-items-center justify-content-between gap-2 text-body-secondary"><span class="kb-value fw-bolder text-body">$3,200</span><span>Leo Brandt</span></div></li>
      </ul></div></div>
    <div class="col"><div class="kb-col" data-dd-zone><div class="d-flex align-items-center gap-2 px-1 pb-2"><span class="kb-dot rounded-circle d-amber"></span><h3 class="fw-bold mb-0">Qualified</h3><span class="kb-total fw-bold ms-auto" data-sum="kb2-qual">$14,600</span></div>
      <ul class="kb-list list-unstyled" id="kb2-qual" data-dd-list="deals" aria-label="Qualified">
        <li class="kb-card card d-grid" data-dd-item data-value="12000"><div class="d-flex align-items-center justify-content-between gap-2"><span class="kb-title fw-semibold" data-dd-name>Atlas Freight</span><button class="kb-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="kb2-hint"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="kb-meta d-flex align-items-center justify-content-between gap-2 text-body-secondary"><span class="kb-value fw-bolder text-body">$12,000</span><span>Ines Duarte</span></div></li>
        <li class="kb-card card d-grid" data-dd-item data-value="2600"><div class="d-flex align-items-center justify-content-between gap-2"><span class="kb-title fw-semibold" data-dd-name>Blue Fern Studio</span><button class="kb-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="kb2-hint"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="kb-meta d-flex align-items-center justify-content-between gap-2 text-body-secondary"><span class="kb-value fw-bolder text-body">$2,600</span><span>Sam Okafor</span></div></li>
      </ul></div></div>
    <div class="col"><div class="kb-col" data-dd-zone><div class="d-flex align-items-center gap-2 px-1 pb-2"><span class="kb-dot rounded-circle d-indigo"></span><h3 class="fw-bold mb-0">Proposal</h3><span class="kb-total fw-bold ms-auto" data-sum="kb2-prop">$6,800</span></div>
      <ul class="kb-list list-unstyled" id="kb2-prop" data-dd-list="deals" aria-label="Proposal">
        <li class="kb-card card d-grid" data-dd-item data-value="6800"><div class="d-flex align-items-center justify-content-between gap-2"><span class="kb-title fw-semibold" data-dd-name>Kestrel Bikes</span><button class="kb-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="kb2-hint"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="kb-meta d-flex align-items-center justify-content-between gap-2 text-body-secondary"><span class="kb-value fw-bolder text-body">$6,800</span><span>Ruth Adler</span></div></li>
      </ul></div></div>
    <div class="col"><div class="kb-col" data-dd-zone><div class="d-flex align-items-center gap-2 px-1 pb-2"><span class="kb-dot rounded-circle d-green"></span><h3 class="fw-bold mb-0">Won</h3><span class="kb-total fw-bold ms-auto" data-sum="kb2-won">$4,500</span></div>
      <ul class="kb-list list-unstyled" id="kb2-won" data-dd-list="deals" aria-label="Won">
        <li class="kb-card card d-grid" data-dd-item data-value="4500"><div class="d-flex align-items-center justify-content-between gap-2"><span class="kb-title fw-semibold" data-dd-name>Orchard Dental</span><button class="kb-handle btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="kb2-hint"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="kb-meta d-flex align-items-center justify-content-between gap-2 text-body-secondary"><span class="kb-value fw-bolder text-body">$4,500</span><span>Kai Lindqvist</span></div></li>
      </ul></div></div>
  </div>
  <p class="visually-hidden" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>
CSS
/* Kanban board for Bootstrap 5: the .row grid lays out the columns, each card is a .card with .badge tags;
   these rules add the column surface, the handle and the states the script sets. */
.kb{--bs-primary:#4f46e5;--bs-primary-rgb:79,70,229;--bs-secondary-color:#5d6679;--kb-tint:#eef0ff;--kb-danger:#b42318;color:#101828}
.kb-head h2{font-size:1.1rem;letter-spacing:-.01em}
.kb-head p,.kb .small{font-size:.82rem}
.kb-col{background:#eceef3;border-radius:16px;padding:12px}
.kb-col h3{font-size:.86rem}
.kb-dot{width:8px;height:8px}
.kb .badge.kb-count{font-size:.75rem;padding:3px 8px;font-variant-numeric:tabular-nums}
.kb-col:has([data-full]) .kb-count{background:#fef3f2!important;color:var(--kb-danger)!important}
.kb-list{display:grid;gap:8px;min-height:72px;padding:2px;margin:0;border-radius:12px;outline:2px dashed transparent;outline-offset:2px;transition:background-color .15s,outline-color .15s}
.kb-list[data-empty]::after{content:"Drop cards here";display:grid;place-items:center;height:68px;border:2px dashed #b3bbc8;border-radius:12px;font-size:.8rem;color:#5d6679}
.kb[data-dd-active] .kb-list{outline-color:#c3c9d4}
.kb .kb-list[data-over]{outline-color:#4f46e5;background:rgba(79,70,229,.06)}
.kb .kb-list[data-over][data-full]:not(:has([data-dragging],[data-grabbed])){outline-color:var(--kb-danger);background:rgba(180,35,24,.06)}
.kb-card{--bs-card-border-color:#dfe3ea;--bs-card-border-radius:12px;padding:12px 12px 12px 14px;gap:8px;box-shadow:0 1px 2px rgba(16,24,40,.06);cursor:grab;user-select:none}
.kb .d-grey{background:#98a2b3}.kb .d-indigo{background:#4f46e5}.kb .d-green{background:#12b76a}.kb .d-amber{background:#f79009}
.kb .t-copy{background:#fdf2fa;color:#9e165f}.kb .t-design{background:#eef4ff;color:#3538cd}.kb .t-dev{background:#ecfdf3;color:#067647}
.kb-card .badge{font-size:.7rem;padding:4px 8px;border-radius:6px}
.kb-title{font-size:.9rem;line-height:1.35}
.kb-meta{font-size:.76rem}
.kb-meta svg{width:14px;height:14px;vertical-align:-2px;margin-right:4px}
.kb-av{width:24px;height:24px;font-size:.62rem;font-weight:800;background:#e0e7ff;color:#3730a3}
.kb-value{font-size:.95rem;font-variant-numeric:tabular-nums}
.kb-total{font-size:.78rem;font-variant-numeric:tabular-nums}
.kb-handle{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-color:#667085;--bs-btn-hover-bg:#f2f4f7;--bs-btn-hover-color:#101828;--bs-btn-active-bg:#f2f4f7;
  --bs-btn-border-width:0;--bs-btn-border-radius:7px;width:28px;height:28px;margin:-4px -4px -4px 0;cursor:grab;touch-action:none}
.kb-handle:focus-visible{outline:2px solid #4f46e5;outline-offset:1px;box-shadow:none}
.kb-card[data-dragging]{background:var(--kb-tint);border:2px dashed #a5b4fc;box-shadow:none}
.kb-card[data-dragging]>*{visibility:hidden}
.kb-card[data-dd-ghost]{box-shadow:0 20px 40px -14px rgba(16,24,40,.4);rotate:2deg}
.kb-card[data-grabbed]{border-color:#4f46e5;box-shadow:0 0 0 2px #4f46e5,0 12px 24px -12px rgba(79,70,229,.55)}
@media (prefers-reduced-motion:reduce){.kb-card[data-dd-ghost]{rotate:none}.kb-list{transition:none}}
JavaScript
// Pipeline: add up each column's deal values, and the open total (every column except the last, Won).
document.addEventListener('dd:change', e => {
  const root = e.target; if (!root.matches('[data-pipeline]')) return;
  const usd = n => '$' + n.toLocaleString('en-US'); let open = 0;
  const cols = root.querySelectorAll('[data-dd-list]');
  cols.forEach((l, i) => { const sum = [...l.querySelectorAll('[data-dd-item]')].reduce((s, li) => s + +li.dataset.value, 0);
    root.querySelector(`[data-sum="${l.id}"]`).textContent = usd(sum); if (i < cols.length - 1) open += sum; });
  root.querySelector('[data-pipe-total]').textContent = usd(open);
});
// Drag and drop with pointer events (mouse, touch and pen) plus a keyboard mode on every handle:
// Space or Enter picks an item up, the arrow keys move it, Space or Enter drops it, Escape puts it back.
// Every move is announced in the component's aria-live region. Markup contract: [data-dd] root,
// [data-dd-list="group"] lists, [data-dd-item] items with a [data-dd-name] and a [data-dd-handle] button.
// data-dd="grid" or "wrap" compares the pointer horizontally; in a grid, Up and Down jump a whole row.
document.querySelectorAll('[data-dd]').forEach(root => {
  const grid = root.dataset.dd === 'grid', horiz = grid || root.dataset.dd === 'wrap', live = root.querySelector('[data-dd-live]');
  const items = l => [...l.children].filter(c => c.hasAttribute('data-dd-item'));
  const lists = l => [...root.querySelectorAll(`[data-dd-list="${l.dataset.ddList}"]`)];
  const name = el => el.querySelector('[data-dd-name]').textContent.trim();
  const full = l => !!l.dataset.ddMax && items(l).length >= +l.dataset.ddMax;
  const pos = el => { const l = el.parentElement, all = items(l);
    return (lists(l).length > 1 ? l.getAttribute('aria-label') + ', ' : '') + 'position ' + (all.indexOf(el) + 1) + ' of ' + all.length; };
  const say = msg => { live.textContent = ''; setTimeout(() => { live.textContent = msg; }, 60); };
  const clearOver = keep => root.querySelectorAll('[data-over]').forEach(l => l !== keep && l.removeAttribute('data-over'));
  const marks = () => root.querySelectorAll('[data-dd-list]').forEach(l => { const n = items(l).length, c = root.querySelector(`[data-dd-count="${l.id}"]`);
    l.toggleAttribute('data-empty', !n); l.toggleAttribute('data-full', full(l)); if (c) c.textContent = n; });
  const changed = () => {
    marks();
    root.querySelectorAll('[data-dd-item]').forEach(it => it.querySelector('[data-dd-handle]').setAttribute('aria-label', 'Move ' + name(it)));
    root.dispatchEvent(new Event('dd:change', { bubbles: true }));
  };
  let drag, ghost, home, kb, kbh, sx, sy, ox, oy, pid, dragged, moving;
  const putBack = el => home[0].insertBefore(el, home[1] && home[1].parentNode === home[0] ? home[1] : null);

  root.addEventListener('pointerdown', e => {
    const item = e.target.closest('[data-dd-item]'), handle = e.target.closest('[data-dd-handle]');
    if (!item || e.button > 0 || kb) return;
    if (!handle && (e.pointerType !== 'mouse' || 'ddHandleOnly' in root.dataset || e.target.closest('a,button,input,select,textarea,label'))) return;
    e.preventDefault(); drag = item; pid = e.pointerId; sx = e.clientX; sy = e.clientY; ghost = null;
  });
  root.addEventListener('pointermove', e => {
    if (!drag || e.pointerId !== pid) return;
    if (!ghost) { if (Math.hypot(e.clientX - sx, e.clientY - sy) < 6) return; start(); root.setPointerCapture(pid); }
    ghost.style.translate = `${e.clientX - ox}px ${e.clientY - oy}px`;
    if (e.clientY < 40) scrollBy(0, -12); else if (e.clientY > innerHeight - 40) scrollBy(0, 12);
    const hit = document.elementFromPoint(e.clientX, e.clientY), sel = `[data-dd-list="${home[0].dataset.ddList}"]`;
    const list = hit && (hit.closest(sel) || hit.closest('[data-dd-zone]')?.querySelector(sel)); // a [data-dd-zone] wrapper widens the target
    clearOver(list);
    if (!list || !root.contains(list) || drag.contains(list)) return;
    list.setAttribute('data-over', '');
    if (list !== drag.parentElement && full(list)) return;
    const over = items(list).find(c => c !== drag && c.contains(hit));
    if (over) { const r = over.getBoundingClientRect();
      const ref = (horiz ? e.clientX > r.left + r.width / 2 : e.clientY > r.top + r.height / 2) ? over.nextSibling : over;
      if (ref !== drag && ref !== drag.nextSibling) { list.insertBefore(drag, ref); marks(); }
    } else if (list !== drag.parentElement && !drag.contains(hit)) { list.append(drag); marks(); }
  });
  const end = e => {
    if (!drag || e.pointerId !== pid) return;
    if (ghost) {
      ghost.remove(); drag.removeAttribute('data-dragging'); root.removeAttribute('data-dd-active'); document.body.style.cursor = ''; clearOver();
      if (e.type === 'pointercancel') putBack(drag); else say(name(drag) + ' moved to ' + pos(drag) + '.');
      changed(); dragged = true; setTimeout(() => { dragged = false; });
    }
    drag = null;
  };
  root.addEventListener('pointerup', end); root.addEventListener('pointercancel', end);
  function start() {
    const r = drag.getBoundingClientRect(); ox = sx - r.left; oy = sy - r.top; home = [drag.parentElement, drag.nextSibling];
    ghost = drag.cloneNode(true); ghost.removeAttribute('data-dd-item'); ghost.setAttribute('data-dd-ghost', ''); ghost.setAttribute('aria-hidden', 'true'); ghost.inert = true;
    ghost.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));
    Object.assign(ghost.style, { position: 'fixed', left: 0, top: 0, width: r.width + 'px', height: r.height + 'px', margin: 0, zIndex: 999, pointerEvents: 'none' });
    drag.after(ghost); drag.setAttribute('data-dragging', ''); root.setAttribute('data-dd-active', ''); document.body.style.cursor = 'grabbing';
  }

  // Keyboard and screen reader mode: the handle is a toggle button (aria-pressed) that picks the item up.
  const finish = msg => { kb.removeAttribute('data-grabbed'); kbh.setAttribute('aria-pressed', 'false'); root.removeAttribute('data-dd-active'); clearOver(); kb = null; changed(); say(msg); };
  const move = fn => { moving = true; fn(); kbh.focus(); moving = false; kbh.scrollIntoView({ block: 'nearest' }); clearOver(kb.parentElement); kb.parentElement.setAttribute('data-over', ''); changed(); say(name(kb) + ' moved to ' + pos(kb) + '.'); };
  root.addEventListener('click', e => {
    const h = e.target.closest('[data-dd-handle]'); if (!h || dragged) return;
    const item = h.closest('[data-dd-item]');
    if (kb === item) return finish(name(item) + ' dropped, ' + pos(item) + '.');
    if (kb) finish(name(kb) + ' dropped, ' + pos(kb) + '.');
    kb = item; kbh = h; home = [item.parentElement, item.nextSibling];
    item.setAttribute('data-grabbed', ''); h.setAttribute('aria-pressed', 'true'); root.setAttribute('data-dd-active', ''); item.parentElement.setAttribute('data-over', ''); h.focus();
    say(name(item) + ' picked up, ' + pos(item) + '. Use the arrow keys to move it, Space to drop it, Escape to cancel.');
  });
  root.addEventListener('keydown', e => {
    if (!kb || e.target !== kbh) return;
    if (e.key === 'Escape') { e.preventDefault(); moving = true; putBack(kb); kbh.focus(); moving = false; return finish('Move cancelled. ' + name(kb) + ' returned to ' + pos(kb) + '.'); }
    const d = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key]; if (!d) return;
    e.preventDefault();
    const l = kb.parentElement, all = items(l), i = all.indexOf(kb), ls = lists(l), side = e.key === 'ArrowLeft' || e.key === 'ArrowRight';
    if (side && ls.length > 1) {
      const t = ls[ls.indexOf(l) + d];
      if (!t) return say(name(kb) + ' is already in ' + l.getAttribute('aria-label') + ', the ' + (d < 0 ? 'first' : 'last') + ' list.');
      if (full(t)) return say(t.getAttribute('aria-label') + ' is full.');
      return move(() => t.insertBefore(kb, items(t)[i] || null));
    }
    const step = grid && !side ? d * all.filter(x => x.offsetTop === all[0].offsetTop).length : d, j = Math.max(0, Math.min(all.length - 1, i + step));
    if (j === i) return say(name(kb) + ' is already ' + (d < 0 ? 'first' : 'last') + '.');
    move(() => l.insertBefore(kb, j > i ? all[j].nextSibling : all[j]));
  });
  root.addEventListener('focusout', e => { if (kb && !moving && e.target === kbh) finish(name(kb) + ' dropped, ' + pos(kb) + '.'); });
  changed();
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-3 block text-[.72rem] tracking-[.12em] text-kb-muted uppercase">Task board with a work-in-progress limit</span>
<section class="group/kb text-kb-ink" data-dd aria-labelledby="kb1-title">
  <div class="mb-3.5 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2"><h2 class="text-[1.1rem] font-bold tracking-[-.01em]" id="kb1-title">Website relaunch · Sprint 14</h2><p class="text-[.82rem] text-kb-muted" id="kb1-hint">Drag a card, or focus its handle, press Space and use the arrow keys. Left and Right change column.</p></div>
  <div class="grid grid-cols-[repeat(auto-fit,minmax(230px,1fr))] items-start gap-3.5">
    <div class="group/col rounded-2xl bg-kb-col p-3" data-dd-zone><div class="flex items-center gap-2 px-1 pt-0.5 pb-2.5"><span class="size-2 rounded-full bg-[#98a2b3]"></span><h3 class="text-[.86rem] font-bold">To do</h3><span class="ml-auto rounded-full bg-white px-2 py-px text-[.75rem] font-bold text-[#344054] tabular-nums group-has-[[data-full]]/col:bg-[#fef3f2] group-has-[[data-full]]/col:text-kb-danger"><span data-dd-count="kb1-todo">3</span><span class="sr-only"> cards</span></span></div>
      <ul class="grid min-h-[72px] gap-2 rounded-xl p-0.5 outline-2 outline-offset-2 outline-dashed outline-transparent transition-[background-color,outline-color] motion-reduce:transition-none group-data-[dd-active]/kb:outline-[#c3c9d4] data-over:bg-kb-accent/6 data-over:outline-kb-accent! [&[data-over][data-full]:not(:has([data-dragging],[data-grabbed]))]:bg-kb-danger/6 [&[data-over][data-full]:not(:has([data-dragging],[data-grabbed]))]:outline-kb-danger! data-empty:after:grid data-empty:after:h-[68px] data-empty:after:place-items-center data-empty:after:rounded-xl data-empty:after:border-2 data-empty:after:border-dashed data-empty:after:border-[#b3bbc8] data-empty:after:text-[.8rem] data-empty:after:text-kb-muted data-empty:after:content-['Drop_cards_here'] *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#a5b4fc] *:data-dragging:bg-kb-tint *:data-dragging:shadow-none [&>[data-dragging]>*]:invisible *:data-dd-ghost:rotate-2 *:data-dd-ghost:shadow-[0_20px_40px_-14px_rgba(16,24,40,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-kb-accent *:data-grabbed:shadow-[0_0_0_2px_#4f46e5,0_12px_24px_-12px_rgba(79,70,229,.55)]" id="kb1-todo" data-dd-list="tasks" aria-label="To do">
        <li class="grid cursor-grab gap-2 rounded-xl border border-kb-line bg-white py-3 pr-3 pl-3.5 shadow-[0_1px_2px_rgba(16,24,40,.06)] select-none" data-dd-item><div class="flex items-center justify-between gap-2"><span class="rounded-md bg-[#fdf2fa] px-2 py-0.5 text-[.7rem] font-bold text-[#9e165f]">Copy</span><button class="-my-1 -mr-1 grid size-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-kb-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-kb-accent" type="button" data-dd-handle aria-describedby="kb1-hint"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="text-[.9rem] leading-[1.35] font-semibold" data-dd-name>Write the pricing page FAQ</p>
          <div class="flex items-center justify-between gap-2 text-[.76rem] text-kb-muted"><span><svg class="mr-1 inline size-3.5 align-[-2px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 22</span><span class="grid size-6 place-items-center rounded-full bg-[#e0e7ff] text-[.62rem] font-extrabold text-[#3730a3]" role="img" aria-label="Priya Nair">PN</span></div></li>
        <li class="grid cursor-grab gap-2 rounded-xl border border-kb-line bg-white py-3 pr-3 pl-3.5 shadow-[0_1px_2px_rgba(16,24,40,.06)] select-none" data-dd-item><div class="flex items-center justify-between gap-2"><span class="rounded-md bg-[#eef4ff] px-2 py-0.5 text-[.7rem] font-bold text-[#3538cd]">Design</span><button class="-my-1 -mr-1 grid size-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-kb-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-kb-accent" type="button" data-dd-handle aria-describedby="kb1-hint"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="text-[.9rem] leading-[1.35] font-semibold" data-dd-name>Homepage hero illustrations</p>
          <div class="flex items-center justify-between gap-2 text-[.76rem] text-kb-muted"><span><svg class="mr-1 inline size-3.5 align-[-2px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 24</span><span class="grid size-6 place-items-center rounded-full bg-[#e0e7ff] text-[.62rem] font-extrabold text-[#3730a3]" role="img" aria-label="Ana Ruiz">AR</span></div></li>
        <li class="grid cursor-grab gap-2 rounded-xl border border-kb-line bg-white py-3 pr-3 pl-3.5 shadow-[0_1px_2px_rgba(16,24,40,.06)] select-none" data-dd-item><div class="flex items-center justify-between gap-2"><span class="rounded-md bg-[#ecfdf3] px-2 py-0.5 text-[.7rem] font-bold text-[#067647]">Dev</span><button class="-my-1 -mr-1 grid size-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-kb-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-kb-accent" type="button" data-dd-handle aria-describedby="kb1-hint"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="text-[.9rem] leading-[1.35] font-semibold" data-dd-name>Set up 301 redirects</p>
          <div class="flex items-center justify-between gap-2 text-[.76rem] text-kb-muted"><span><svg class="mr-1 inline size-3.5 align-[-2px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 25</span><span class="grid size-6 place-items-center rounded-full bg-[#e0e7ff] text-[.62rem] font-extrabold text-[#3730a3]" role="img" aria-label="Tomás Kral">TK</span></div></li>
      </ul></div>
    <div class="group/col rounded-2xl bg-kb-col p-3" data-dd-zone><div class="flex items-center gap-2 px-1 pt-0.5 pb-2.5"><span class="size-2 rounded-full bg-[#4f46e5]"></span><h3 class="text-[.86rem] font-bold">In progress</h3><span class="ml-auto rounded-full bg-white px-2 py-px text-[.75rem] font-bold text-[#344054] tabular-nums group-has-[[data-full]]/col:bg-[#fef3f2] group-has-[[data-full]]/col:text-kb-danger"><span data-dd-count="kb1-doing">2</span> / 3<span class="sr-only"> cards, limit 3</span></span></div>
      <ul class="grid min-h-[72px] gap-2 rounded-xl p-0.5 outline-2 outline-offset-2 outline-dashed outline-transparent transition-[background-color,outline-color] motion-reduce:transition-none group-data-[dd-active]/kb:outline-[#c3c9d4] data-over:bg-kb-accent/6 data-over:outline-kb-accent! [&[data-over][data-full]:not(:has([data-dragging],[data-grabbed]))]:bg-kb-danger/6 [&[data-over][data-full]:not(:has([data-dragging],[data-grabbed]))]:outline-kb-danger! data-empty:after:grid data-empty:after:h-[68px] data-empty:after:place-items-center data-empty:after:rounded-xl data-empty:after:border-2 data-empty:after:border-dashed data-empty:after:border-[#b3bbc8] data-empty:after:text-[.8rem] data-empty:after:text-kb-muted data-empty:after:content-['Drop_cards_here'] *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#a5b4fc] *:data-dragging:bg-kb-tint *:data-dragging:shadow-none [&>[data-dragging]>*]:invisible *:data-dd-ghost:rotate-2 *:data-dd-ghost:shadow-[0_20px_40px_-14px_rgba(16,24,40,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-kb-accent *:data-grabbed:shadow-[0_0_0_2px_#4f46e5,0_12px_24px_-12px_rgba(79,70,229,.55)]" id="kb1-doing" data-dd-list="tasks" data-dd-max="3" aria-label="In progress">
        <li class="grid cursor-grab gap-2 rounded-xl border border-kb-line bg-white py-3 pr-3 pl-3.5 shadow-[0_1px_2px_rgba(16,24,40,.06)] select-none" data-dd-item><div class="flex items-center justify-between gap-2"><span class="rounded-md bg-[#ecfdf3] px-2 py-0.5 text-[.7rem] font-bold text-[#067647]">Dev</span><button class="-my-1 -mr-1 grid size-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-kb-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-kb-accent" type="button" data-dd-handle aria-describedby="kb1-hint"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="text-[.9rem] leading-[1.35] font-semibold" data-dd-name>Rebuild the navigation menu</p>
          <div class="flex items-center justify-between gap-2 text-[.76rem] text-kb-muted"><span><svg class="mr-1 inline size-3.5 align-[-2px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 18</span><span class="grid size-6 place-items-center rounded-full bg-[#e0e7ff] text-[.62rem] font-extrabold text-[#3730a3]" role="img" aria-label="Tomás Kral">TK</span></div></li>
        <li class="grid cursor-grab gap-2 rounded-xl border border-kb-line bg-white py-3 pr-3 pl-3.5 shadow-[0_1px_2px_rgba(16,24,40,.06)] select-none" data-dd-item><div class="flex items-center justify-between gap-2"><span class="rounded-md bg-[#eef4ff] px-2 py-0.5 text-[.7rem] font-bold text-[#3538cd]">Design</span><button class="-my-1 -mr-1 grid size-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-kb-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-kb-accent" type="button" data-dd-handle aria-describedby="kb1-hint"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="text-[.9rem] leading-[1.35] font-semibold" data-dd-name>Retouch the product photos</p>
          <div class="flex items-center justify-between gap-2 text-[.76rem] text-kb-muted"><span><svg class="mr-1 inline size-3.5 align-[-2px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 19</span><span class="grid size-6 place-items-center rounded-full bg-[#e0e7ff] text-[.62rem] font-extrabold text-[#3730a3]" role="img" aria-label="Ana Ruiz">AR</span></div></li>
      </ul></div>
    <div class="group/col rounded-2xl bg-kb-col p-3" data-dd-zone><div class="flex items-center gap-2 px-1 pt-0.5 pb-2.5"><span class="size-2 rounded-full bg-[#12b76a]"></span><h3 class="text-[.86rem] font-bold">Done</h3><span class="ml-auto rounded-full bg-white px-2 py-px text-[.75rem] font-bold text-[#344054] tabular-nums group-has-[[data-full]]/col:bg-[#fef3f2] group-has-[[data-full]]/col:text-kb-danger"><span data-dd-count="kb1-done">2</span><span class="sr-only"> cards</span></span></div>
      <ul class="grid min-h-[72px] gap-2 rounded-xl p-0.5 outline-2 outline-offset-2 outline-dashed outline-transparent transition-[background-color,outline-color] motion-reduce:transition-none group-data-[dd-active]/kb:outline-[#c3c9d4] data-over:bg-kb-accent/6 data-over:outline-kb-accent! [&[data-over][data-full]:not(:has([data-dragging],[data-grabbed]))]:bg-kb-danger/6 [&[data-over][data-full]:not(:has([data-dragging],[data-grabbed]))]:outline-kb-danger! data-empty:after:grid data-empty:after:h-[68px] data-empty:after:place-items-center data-empty:after:rounded-xl data-empty:after:border-2 data-empty:after:border-dashed data-empty:after:border-[#b3bbc8] data-empty:after:text-[.8rem] data-empty:after:text-kb-muted data-empty:after:content-['Drop_cards_here'] *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#a5b4fc] *:data-dragging:bg-kb-tint *:data-dragging:shadow-none [&>[data-dragging]>*]:invisible *:data-dd-ghost:rotate-2 *:data-dd-ghost:shadow-[0_20px_40px_-14px_rgba(16,24,40,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-kb-accent *:data-grabbed:shadow-[0_0_0_2px_#4f46e5,0_12px_24px_-12px_rgba(79,70,229,.55)]" id="kb1-done" data-dd-list="tasks" aria-label="Done">
        <li class="grid cursor-grab gap-2 rounded-xl border border-kb-line bg-white py-3 pr-3 pl-3.5 shadow-[0_1px_2px_rgba(16,24,40,.06)] select-none" data-dd-item><div class="flex items-center justify-between gap-2"><span class="rounded-md bg-[#fdf2fa] px-2 py-0.5 text-[.7rem] font-bold text-[#9e165f]">Copy</span><button class="-my-1 -mr-1 grid size-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-kb-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-kb-accent" type="button" data-dd-handle aria-describedby="kb1-hint"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="text-[.9rem] leading-[1.35] font-semibold" data-dd-name>Audit the old blog posts</p>
          <div class="flex items-center justify-between gap-2 text-[.76rem] text-kb-muted"><span><svg class="mr-1 inline size-3.5 align-[-2px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 12</span><span class="grid size-6 place-items-center rounded-full bg-[#e0e7ff] text-[.62rem] font-extrabold text-[#3730a3]" role="img" aria-label="Priya Nair">PN</span></div></li>
        <li class="grid cursor-grab gap-2 rounded-xl border border-kb-line bg-white py-3 pr-3 pl-3.5 shadow-[0_1px_2px_rgba(16,24,40,.06)] select-none" data-dd-item><div class="flex items-center justify-between gap-2"><span class="rounded-md bg-[#eef4ff] px-2 py-0.5 text-[.7rem] font-bold text-[#3538cd]">Design</span><button class="-my-1 -mr-1 grid size-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-kb-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-kb-accent" type="button" data-dd-handle aria-describedby="kb1-hint"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><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></button></div>
          <p class="text-[.9rem] leading-[1.35] font-semibold" data-dd-name>Choose the new type pairing</p>
          <div class="flex items-center justify-between gap-2 text-[.76rem] text-kb-muted"><span><svg class="mr-1 inline size-3.5 align-[-2px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Oct 10</span><span class="grid size-6 place-items-center rounded-full bg-[#e0e7ff] text-[.62rem] font-extrabold text-[#3730a3]" role="img" aria-label="Lea Moreau">LM</span></div></li>
      </ul></div>
  </div>
  <p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>

<div><span class="mb-3 block text-[.72rem] tracking-[.12em] text-kb-muted uppercase">Sales pipeline with column totals</span>
<section class="group/kb text-kb-ink" data-dd data-pipeline aria-labelledby="kb2-title">
  <div class="mb-3.5 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2"><h2 class="text-[1.1rem] font-bold tracking-[-.01em]" id="kb2-title">Q4 pipeline · <span data-pipe-total>$33,000</span> open</h2><p class="text-[.82rem] text-kb-muted" id="kb2-hint">Column totals update as deals move.</p></div>
  <div class="grid grid-cols-[repeat(auto-fit,minmax(230px,1fr))] items-start gap-3.5">
    <div class="rounded-2xl bg-kb-col p-3" data-dd-zone><div class="flex items-center gap-2 px-1 pt-0.5 pb-2.5"><span class="size-2 rounded-full bg-[#98a2b3]"></span><h3 class="text-[.86rem] font-bold">Lead</h3><span class="ml-auto text-[.78rem] font-bold text-[#344054] tabular-nums" data-sum="kb2-lead">$11,600</span></div>
      <ul class="grid min-h-[72px] gap-2 rounded-xl p-0.5 outline-2 outline-offset-2 outline-dashed outline-transparent transition-[background-color,outline-color] motion-reduce:transition-none group-data-[dd-active]/kb:outline-[#c3c9d4] data-over:bg-kb-accent/6 data-over:outline-kb-accent! [&[data-over][data-full]:not(:has([data-dragging],[data-grabbed]))]:bg-kb-danger/6 [&[data-over][data-full]:not(:has([data-dragging],[data-grabbed]))]:outline-kb-danger! data-empty:after:grid data-empty:after:h-[68px] data-empty:after:place-items-center data-empty:after:rounded-xl data-empty:after:border-2 data-empty:after:border-dashed data-empty:after:border-[#b3bbc8] data-empty:after:text-[.8rem] data-empty:after:text-kb-muted data-empty:after:content-['Drop_cards_here'] *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#a5b4fc] *:data-dragging:bg-kb-tint *:data-dragging:shadow-none [&>[data-dragging]>*]:invisible *:data-dd-ghost:rotate-2 *:data-dd-ghost:shadow-[0_20px_40px_-14px_rgba(16,24,40,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-kb-accent *:data-grabbed:shadow-[0_0_0_2px_#4f46e5,0_12px_24px_-12px_rgba(79,70,229,.55)]" id="kb2-lead" data-dd-list="deals" aria-label="Lead">
        <li class="grid cursor-grab gap-2 rounded-xl border border-kb-line bg-white py-3 pr-3 pl-3.5 shadow-[0_1px_2px_rgba(16,24,40,.06)] select-none" data-dd-item data-value="8400"><div class="flex items-center justify-between gap-2"><span class="text-[.9rem] leading-[1.35] font-semibold" data-dd-name>Northwind Clinics</span><button class="-my-1 -mr-1 grid size-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-kb-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-kb-accent" type="button" data-dd-handle aria-describedby="kb2-hint"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="flex items-center justify-between gap-2 text-[.76rem] text-kb-muted"><span class="text-[.95rem] font-extrabold text-kb-ink tabular-nums">$8,400</span><span>Maya Chen</span></div></li>
        <li class="grid cursor-grab gap-2 rounded-xl border border-kb-line bg-white py-3 pr-3 pl-3.5 shadow-[0_1px_2px_rgba(16,24,40,.06)] select-none" data-dd-item data-value="3200"><div class="flex items-center justify-between gap-2"><span class="text-[.9rem] leading-[1.35] font-semibold" data-dd-name>Harbor & Pine</span><button class="-my-1 -mr-1 grid size-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-kb-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-kb-accent" type="button" data-dd-handle aria-describedby="kb2-hint"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="flex items-center justify-between gap-2 text-[.76rem] text-kb-muted"><span class="text-[.95rem] font-extrabold text-kb-ink tabular-nums">$3,200</span><span>Leo Brandt</span></div></li>
      </ul></div>
    <div class="rounded-2xl bg-kb-col p-3" data-dd-zone><div class="flex items-center gap-2 px-1 pt-0.5 pb-2.5"><span class="size-2 rounded-full bg-[#f79009]"></span><h3 class="text-[.86rem] font-bold">Qualified</h3><span class="ml-auto text-[.78rem] font-bold text-[#344054] tabular-nums" data-sum="kb2-qual">$14,600</span></div>
      <ul class="grid min-h-[72px] gap-2 rounded-xl p-0.5 outline-2 outline-offset-2 outline-dashed outline-transparent transition-[background-color,outline-color] motion-reduce:transition-none group-data-[dd-active]/kb:outline-[#c3c9d4] data-over:bg-kb-accent/6 data-over:outline-kb-accent! [&[data-over][data-full]:not(:has([data-dragging],[data-grabbed]))]:bg-kb-danger/6 [&[data-over][data-full]:not(:has([data-dragging],[data-grabbed]))]:outline-kb-danger! data-empty:after:grid data-empty:after:h-[68px] data-empty:after:place-items-center data-empty:after:rounded-xl data-empty:after:border-2 data-empty:after:border-dashed data-empty:after:border-[#b3bbc8] data-empty:after:text-[.8rem] data-empty:after:text-kb-muted data-empty:after:content-['Drop_cards_here'] *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#a5b4fc] *:data-dragging:bg-kb-tint *:data-dragging:shadow-none [&>[data-dragging]>*]:invisible *:data-dd-ghost:rotate-2 *:data-dd-ghost:shadow-[0_20px_40px_-14px_rgba(16,24,40,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-kb-accent *:data-grabbed:shadow-[0_0_0_2px_#4f46e5,0_12px_24px_-12px_rgba(79,70,229,.55)]" id="kb2-qual" data-dd-list="deals" aria-label="Qualified">
        <li class="grid cursor-grab gap-2 rounded-xl border border-kb-line bg-white py-3 pr-3 pl-3.5 shadow-[0_1px_2px_rgba(16,24,40,.06)] select-none" data-dd-item data-value="12000"><div class="flex items-center justify-between gap-2"><span class="text-[.9rem] leading-[1.35] font-semibold" data-dd-name>Atlas Freight</span><button class="-my-1 -mr-1 grid size-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-kb-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-kb-accent" type="button" data-dd-handle aria-describedby="kb2-hint"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="flex items-center justify-between gap-2 text-[.76rem] text-kb-muted"><span class="text-[.95rem] font-extrabold text-kb-ink tabular-nums">$12,000</span><span>Ines Duarte</span></div></li>
        <li class="grid cursor-grab gap-2 rounded-xl border border-kb-line bg-white py-3 pr-3 pl-3.5 shadow-[0_1px_2px_rgba(16,24,40,.06)] select-none" data-dd-item data-value="2600"><div class="flex items-center justify-between gap-2"><span class="text-[.9rem] leading-[1.35] font-semibold" data-dd-name>Blue Fern Studio</span><button class="-my-1 -mr-1 grid size-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-kb-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-kb-accent" type="button" data-dd-handle aria-describedby="kb2-hint"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="flex items-center justify-between gap-2 text-[.76rem] text-kb-muted"><span class="text-[.95rem] font-extrabold text-kb-ink tabular-nums">$2,600</span><span>Sam Okafor</span></div></li>
      </ul></div>
    <div class="rounded-2xl bg-kb-col p-3" data-dd-zone><div class="flex items-center gap-2 px-1 pt-0.5 pb-2.5"><span class="size-2 rounded-full bg-[#4f46e5]"></span><h3 class="text-[.86rem] font-bold">Proposal</h3><span class="ml-auto text-[.78rem] font-bold text-[#344054] tabular-nums" data-sum="kb2-prop">$6,800</span></div>
      <ul class="grid min-h-[72px] gap-2 rounded-xl p-0.5 outline-2 outline-offset-2 outline-dashed outline-transparent transition-[background-color,outline-color] motion-reduce:transition-none group-data-[dd-active]/kb:outline-[#c3c9d4] data-over:bg-kb-accent/6 data-over:outline-kb-accent! [&[data-over][data-full]:not(:has([data-dragging],[data-grabbed]))]:bg-kb-danger/6 [&[data-over][data-full]:not(:has([data-dragging],[data-grabbed]))]:outline-kb-danger! data-empty:after:grid data-empty:after:h-[68px] data-empty:after:place-items-center data-empty:after:rounded-xl data-empty:after:border-2 data-empty:after:border-dashed data-empty:after:border-[#b3bbc8] data-empty:after:text-[.8rem] data-empty:after:text-kb-muted data-empty:after:content-['Drop_cards_here'] *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#a5b4fc] *:data-dragging:bg-kb-tint *:data-dragging:shadow-none [&>[data-dragging]>*]:invisible *:data-dd-ghost:rotate-2 *:data-dd-ghost:shadow-[0_20px_40px_-14px_rgba(16,24,40,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-kb-accent *:data-grabbed:shadow-[0_0_0_2px_#4f46e5,0_12px_24px_-12px_rgba(79,70,229,.55)]" id="kb2-prop" data-dd-list="deals" aria-label="Proposal">
        <li class="grid cursor-grab gap-2 rounded-xl border border-kb-line bg-white py-3 pr-3 pl-3.5 shadow-[0_1px_2px_rgba(16,24,40,.06)] select-none" data-dd-item data-value="6800"><div class="flex items-center justify-between gap-2"><span class="text-[.9rem] leading-[1.35] font-semibold" data-dd-name>Kestrel Bikes</span><button class="-my-1 -mr-1 grid size-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-kb-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-kb-accent" type="button" data-dd-handle aria-describedby="kb2-hint"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="flex items-center justify-between gap-2 text-[.76rem] text-kb-muted"><span class="text-[.95rem] font-extrabold text-kb-ink tabular-nums">$6,800</span><span>Ruth Adler</span></div></li>
      </ul></div>
    <div class="rounded-2xl bg-kb-col p-3" data-dd-zone><div class="flex items-center gap-2 px-1 pt-0.5 pb-2.5"><span class="size-2 rounded-full bg-[#12b76a]"></span><h3 class="text-[.86rem] font-bold">Won</h3><span class="ml-auto text-[.78rem] font-bold text-[#344054] tabular-nums" data-sum="kb2-won">$4,500</span></div>
      <ul class="grid min-h-[72px] gap-2 rounded-xl p-0.5 outline-2 outline-offset-2 outline-dashed outline-transparent transition-[background-color,outline-color] motion-reduce:transition-none group-data-[dd-active]/kb:outline-[#c3c9d4] data-over:bg-kb-accent/6 data-over:outline-kb-accent! [&[data-over][data-full]:not(:has([data-dragging],[data-grabbed]))]:bg-kb-danger/6 [&[data-over][data-full]:not(:has([data-dragging],[data-grabbed]))]:outline-kb-danger! data-empty:after:grid data-empty:after:h-[68px] data-empty:after:place-items-center data-empty:after:rounded-xl data-empty:after:border-2 data-empty:after:border-dashed data-empty:after:border-[#b3bbc8] data-empty:after:text-[.8rem] data-empty:after:text-kb-muted data-empty:after:content-['Drop_cards_here'] *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#a5b4fc] *:data-dragging:bg-kb-tint *:data-dragging:shadow-none [&>[data-dragging]>*]:invisible *:data-dd-ghost:rotate-2 *:data-dd-ghost:shadow-[0_20px_40px_-14px_rgba(16,24,40,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-kb-accent *:data-grabbed:shadow-[0_0_0_2px_#4f46e5,0_12px_24px_-12px_rgba(79,70,229,.55)]" id="kb2-won" data-dd-list="deals" aria-label="Won">
        <li class="grid cursor-grab gap-2 rounded-xl border border-kb-line bg-white py-3 pr-3 pl-3.5 shadow-[0_1px_2px_rgba(16,24,40,.06)] select-none" data-dd-item data-value="4500"><div class="flex items-center justify-between gap-2"><span class="text-[.9rem] leading-[1.35] font-semibold" data-dd-name>Orchard Dental</span><button class="-my-1 -mr-1 grid size-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-kb-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-kb-accent" type="button" data-dd-handle aria-describedby="kb2-hint"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></div><div class="flex items-center justify-between gap-2 text-[.76rem] text-kb-muted"><span class="text-[.95rem] font-extrabold text-kb-ink tabular-nums">$4,500</span><span>Kai Lindqvist</span></div></li>
      </ul></div>
  </div>
  <p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>
CSS
@theme {
  --color-kb-accent: #4f46e5;
  --color-kb-tint: #eef0ff;
  --color-kb-col: #eceef3;
  --color-kb-line: #dfe3ea;
  --color-kb-muted: #5d6679;
  --color-kb-ink: #101828;
  --color-kb-danger: #b42318;
}
JavaScript
// Pipeline: add up each column's deal values, and the open total (every column except the last, Won).
document.addEventListener('dd:change', e => {
  const root = e.target; if (!root.matches('[data-pipeline]')) return;
  const usd = n => '$' + n.toLocaleString('en-US'); let open = 0;
  const cols = root.querySelectorAll('[data-dd-list]');
  cols.forEach((l, i) => { const sum = [...l.querySelectorAll('[data-dd-item]')].reduce((s, li) => s + +li.dataset.value, 0);
    root.querySelector(`[data-sum="${l.id}"]`).textContent = usd(sum); if (i < cols.length - 1) open += sum; });
  root.querySelector('[data-pipe-total]').textContent = usd(open);
});
// Drag and drop with pointer events (mouse, touch and pen) plus a keyboard mode on every handle:
// Space or Enter picks an item up, the arrow keys move it, Space or Enter drops it, Escape puts it back.
// Every move is announced in the component's aria-live region. Markup contract: [data-dd] root,
// [data-dd-list="group"] lists, [data-dd-item] items with a [data-dd-name] and a [data-dd-handle] button.
// data-dd="grid" or "wrap" compares the pointer horizontally; in a grid, Up and Down jump a whole row.
document.querySelectorAll('[data-dd]').forEach(root => {
  const grid = root.dataset.dd === 'grid', horiz = grid || root.dataset.dd === 'wrap', live = root.querySelector('[data-dd-live]');
  const items = l => [...l.children].filter(c => c.hasAttribute('data-dd-item'));
  const lists = l => [...root.querySelectorAll(`[data-dd-list="${l.dataset.ddList}"]`)];
  const name = el => el.querySelector('[data-dd-name]').textContent.trim();
  const full = l => !!l.dataset.ddMax && items(l).length >= +l.dataset.ddMax;
  const pos = el => { const l = el.parentElement, all = items(l);
    return (lists(l).length > 1 ? l.getAttribute('aria-label') + ', ' : '') + 'position ' + (all.indexOf(el) + 1) + ' of ' + all.length; };
  const say = msg => { live.textContent = ''; setTimeout(() => { live.textContent = msg; }, 60); };
  const clearOver = keep => root.querySelectorAll('[data-over]').forEach(l => l !== keep && l.removeAttribute('data-over'));
  const marks = () => root.querySelectorAll('[data-dd-list]').forEach(l => { const n = items(l).length, c = root.querySelector(`[data-dd-count="${l.id}"]`);
    l.toggleAttribute('data-empty', !n); l.toggleAttribute('data-full', full(l)); if (c) c.textContent = n; });
  const changed = () => {
    marks();
    root.querySelectorAll('[data-dd-item]').forEach(it => it.querySelector('[data-dd-handle]').setAttribute('aria-label', 'Move ' + name(it)));
    root.dispatchEvent(new Event('dd:change', { bubbles: true }));
  };
  let drag, ghost, home, kb, kbh, sx, sy, ox, oy, pid, dragged, moving;
  const putBack = el => home[0].insertBefore(el, home[1] && home[1].parentNode === home[0] ? home[1] : null);

  root.addEventListener('pointerdown', e => {
    const item = e.target.closest('[data-dd-item]'), handle = e.target.closest('[data-dd-handle]');
    if (!item || e.button > 0 || kb) return;
    if (!handle && (e.pointerType !== 'mouse' || 'ddHandleOnly' in root.dataset || e.target.closest('a,button,input,select,textarea,label'))) return;
    e.preventDefault(); drag = item; pid = e.pointerId; sx = e.clientX; sy = e.clientY; ghost = null;
  });
  root.addEventListener('pointermove', e => {
    if (!drag || e.pointerId !== pid) return;
    if (!ghost) { if (Math.hypot(e.clientX - sx, e.clientY - sy) < 6) return; start(); root.setPointerCapture(pid); }
    ghost.style.translate = `${e.clientX - ox}px ${e.clientY - oy}px`;
    if (e.clientY < 40) scrollBy(0, -12); else if (e.clientY > innerHeight - 40) scrollBy(0, 12);
    const hit = document.elementFromPoint(e.clientX, e.clientY), sel = `[data-dd-list="${home[0].dataset.ddList}"]`;
    const list = hit && (hit.closest(sel) || hit.closest('[data-dd-zone]')?.querySelector(sel)); // a [data-dd-zone] wrapper widens the target
    clearOver(list);
    if (!list || !root.contains(list) || drag.contains(list)) return;
    list.setAttribute('data-over', '');
    if (list !== drag.parentElement && full(list)) return;
    const over = items(list).find(c => c !== drag && c.contains(hit));
    if (over) { const r = over.getBoundingClientRect();
      const ref = (horiz ? e.clientX > r.left + r.width / 2 : e.clientY > r.top + r.height / 2) ? over.nextSibling : over;
      if (ref !== drag && ref !== drag.nextSibling) { list.insertBefore(drag, ref); marks(); }
    } else if (list !== drag.parentElement && !drag.contains(hit)) { list.append(drag); marks(); }
  });
  const end = e => {
    if (!drag || e.pointerId !== pid) return;
    if (ghost) {
      ghost.remove(); drag.removeAttribute('data-dragging'); root.removeAttribute('data-dd-active'); document.body.style.cursor = ''; clearOver();
      if (e.type === 'pointercancel') putBack(drag); else say(name(drag) + ' moved to ' + pos(drag) + '.');
      changed(); dragged = true; setTimeout(() => { dragged = false; });
    }
    drag = null;
  };
  root.addEventListener('pointerup', end); root.addEventListener('pointercancel', end);
  function start() {
    const r = drag.getBoundingClientRect(); ox = sx - r.left; oy = sy - r.top; home = [drag.parentElement, drag.nextSibling];
    ghost = drag.cloneNode(true); ghost.removeAttribute('data-dd-item'); ghost.setAttribute('data-dd-ghost', ''); ghost.setAttribute('aria-hidden', 'true'); ghost.inert = true;
    ghost.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));
    Object.assign(ghost.style, { position: 'fixed', left: 0, top: 0, width: r.width + 'px', height: r.height + 'px', margin: 0, zIndex: 999, pointerEvents: 'none' });
    drag.after(ghost); drag.setAttribute('data-dragging', ''); root.setAttribute('data-dd-active', ''); document.body.style.cursor = 'grabbing';
  }

  // Keyboard and screen reader mode: the handle is a toggle button (aria-pressed) that picks the item up.
  const finish = msg => { kb.removeAttribute('data-grabbed'); kbh.setAttribute('aria-pressed', 'false'); root.removeAttribute('data-dd-active'); clearOver(); kb = null; changed(); say(msg); };
  const move = fn => { moving = true; fn(); kbh.focus(); moving = false; kbh.scrollIntoView({ block: 'nearest' }); clearOver(kb.parentElement); kb.parentElement.setAttribute('data-over', ''); changed(); say(name(kb) + ' moved to ' + pos(kb) + '.'); };
  root.addEventListener('click', e => {
    const h = e.target.closest('[data-dd-handle]'); if (!h || dragged) return;
    const item = h.closest('[data-dd-item]');
    if (kb === item) return finish(name(item) + ' dropped, ' + pos(item) + '.');
    if (kb) finish(name(kb) + ' dropped, ' + pos(kb) + '.');
    kb = item; kbh = h; home = [item.parentElement, item.nextSibling];
    item.setAttribute('data-grabbed', ''); h.setAttribute('aria-pressed', 'true'); root.setAttribute('data-dd-active', ''); item.parentElement.setAttribute('data-over', ''); h.focus();
    say(name(item) + ' picked up, ' + pos(item) + '. Use the arrow keys to move it, Space to drop it, Escape to cancel.');
  });
  root.addEventListener('keydown', e => {
    if (!kb || e.target !== kbh) return;
    if (e.key === 'Escape') { e.preventDefault(); moving = true; putBack(kb); kbh.focus(); moving = false; return finish('Move cancelled. ' + name(kb) + ' returned to ' + pos(kb) + '.'); }
    const d = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key]; if (!d) return;
    e.preventDefault();
    const l = kb.parentElement, all = items(l), i = all.indexOf(kb), ls = lists(l), side = e.key === 'ArrowLeft' || e.key === 'ArrowRight';
    if (side && ls.length > 1) {
      const t = ls[ls.indexOf(l) + d];
      if (!t) return say(name(kb) + ' is already in ' + l.getAttribute('aria-label') + ', the ' + (d < 0 ? 'first' : 'last') + ' list.');
      if (full(t)) return say(t.getAttribute('aria-label') + ' is full.');
      return move(() => t.insertBefore(kb, items(t)[i] || null));
    }
    const step = grid && !side ? d * all.filter(x => x.offsetTop === all[0].offsetTop).length : d, j = Math.max(0, Math.min(all.length - 1, i + step));
    if (j === i) return say(name(kb) + ' is already ' + (d < 0 ? 'first' : 'last') + '.');
    move(() => l.insertBefore(kb, j > i ? all[j].nextSibling : all[j]));
  });
  root.addEventListener('focusout', e => { if (kb && !moving && e.target === kbh) finish(name(kb) + ' dropped, ' + pos(kb) + '.'); });
  changed();
});

About this set

Two kanban boards where cards move between columns and the numbers follow them. Task board with a work-in-progress limit has To do, In progress and Done columns with a count in each header; In progress holds at most three cards, its count turns red when it is full, and a card dragged over it gets a red outline instead of a place. Sales pipeline with column totals has Lead, Qualified, Proposal and Won columns whose dollar totals, and the open total in the heading, are added up again after every move. Use the first for project and support boards and the second for deals, hiring or content pipelines. Each column is a list with an accessible name, so a move reads as “moved to Done, position 2 of 3”. Cards can be dragged from anywhere with a mouse and from their handle on touch screens, the whole column including its header accepts a drop, empty columns show a Drop cards here slot, and on narrow screens the columns stack. From the keyboard, Up and Down reorder a card and Left and Right carry it to the next column.

What’s included

  • Cards move between columns and within them
  • Column counts and dollar totals update live
  • Work-in-progress limit through data-dd-max, refused drops shown in red
  • Left and Right arrow keys move a card to the next column
  • Columns stack on small screens; the whole column is a drop target

Accessibility

Every handle is a toggle button named Move plus the item name, with aria-pressed and the visible instructions joined through aria-describedby. Space or Enter picks the item up, the arrow keys move it, Space or Enter drops it and Escape puts it back, and each step is announced in an assertive aria-live region, for example "Write the pricing page FAQ moved to Done, position 2 of 3.". Handles show a visible focus outline, and the tilt of the dragged copy and any transitions switch off under prefers-reduced-motion.

Customise it

Set --kb-accent, --kb-col and --kb-danger on .kb, and data-dd-max on any column list to give it a limit. Bootstrap uses the .row grid with the same variables; Tailwind keeps the kb colours in @theme.