webdevpuneet.com

SortableJS Multi-Column Drag — Kanban Board Snippet

SortableJS Multi-Column Drag · Dashboards · Plain HTML, CSS & JS · Live preview

CategoryDashboards

Copy & paste

Source Code

Requires
<div class="mcd-stage">
  <div class="mcd-head">
    <span class="mcd-tag">SortableJS · shared group</span>
    <h2>Sprint Board</h2>
    <p>Drag any card between columns — the count badge on each header updates on every drop.</p>
  </div>
  <div class="mcd-board" id="mcdBoard">
    <div class="mcd-col" data-col="backlog">
      <div class="mcd-col-head"><span>Backlog</span><span class="mcd-count" id="count-backlog">0</span></div>
      <div class="mcd-list" id="list-backlog">
        <div class="mcd-card"><strong>Audit unused CSS</strong><span>Cleanup</span></div>
        <div class="mcd-card"><strong>Spike: edge caching</strong><span>Research</span></div>
        <div class="mcd-card"><strong>Update onboarding copy</strong><span>Content</span></div>
      </div>
    </div>
    <div class="mcd-col" data-col="progress">
      <div class="mcd-col-head"><span>In Progress</span><span class="mcd-count" id="count-progress">0</span></div>
      <div class="mcd-list" id="list-progress">
        <div class="mcd-card"><strong>Refactor auth middleware</strong><span>Backend</span></div>
        <div class="mcd-card"><strong>New pricing page</strong><span>Frontend</span></div>
      </div>
    </div>
    <div class="mcd-col" data-col="review">
      <div class="mcd-col-head"><span>Review</span><span class="mcd-count" id="count-review">0</span></div>
      <div class="mcd-list" id="list-review">
        <div class="mcd-card"><strong>Rate limiter PR</strong><span>Backend</span></div>
      </div>
    </div>
    <div class="mcd-col" data-col="done">
      <div class="mcd-col-head"><span>Done</span><span class="mcd-count" id="count-done">0</span></div>
      <div class="mcd-list" id="list-done">
        <div class="mcd-card"><strong>Fix flaky test suite</strong><span>CI</span></div>
        <div class="mcd-card"><strong>Deploy staging env</strong><span>DevOps</span></div>
        <div class="mcd-card"><strong>Write API changelog</strong><span>Docs</span></div>
        <div class="mcd-card"><strong>Rotate secrets</strong><span>Security</span></div>
      </div>
    </div>
  </div>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Shared group cross-linking
A single group string on four Sortable instances is all that's needed to allow cards between them.
Live per-column counts
Header badges recompute from the DOM after every add, remove, and reorder.
Three-hook coverage
onAdd, onRemove, and onEnd together catch every kind of drop, cross-column or same-column.
True DOM node movement
Cards are moved, not cloned, so their data and listeners survive a drag.
Responsive column grid
The board collapses from 4 to 2 to 1 columns as the viewport narrows.
Chosen and ghost styling
CSS-only hooks highlight the active card and fade its placeholder while dragging.
Dependency-free
SortableJS needs no other libraries and works directly on plain DOM nodes.
Extensible pull/put rules
group can be an object to restrict specific columns to drop-only or drag-only.

About this UI Snippet

SortableJS Multi-Column Drag — How a Shared Group Enables Cross-List Dragging

Screenshot of the SortableJS Multi-Column Drag snippet rendered live

A single drag-and-drop list is one Sortable instance reordering itself. A kanban board is four (or more) *separate* DOM lists that a card needs to move *between* — and by default, SortableJS instances know nothing about each other. Dragging a card out of one list and over another does nothing unless they're explicitly linked.

The group option is what links the columns

new Sortable(list, { group: 'board', ... })

Every column's list gets its own Sortable instance, but all four are constructed with the same string value for group. Internally, SortableJS keeps a registry of every active instance and checks, on every drag-over event, whether the instance under the pointer shares a group name with the instance the drag started in. If they match, the target list accepts a drop; if not, the drag is rejected and the card snaps back. This is the entire mechanism — there's no shared state object to wire up, no manual "which list am I hovering over" tracking. A single matching string on four independent constructor calls is sufficient to make cards move freely across all of them.

group can also be an object ({ name: 'board', pull: true, put: true }) when you need asymmetric rules — for example a "Done" column that accepts drops but doesn't allow dragging back out — but the plain string form used here means every column both pulls from and puts into every other column equally.

Why counts need three hooks, not one

The single-list ranked snippet only needs onEnd because every drag starts and ends in the same list — nothing is added or removed, only reordered. A cross-list drag is different: the list the card *leaves* needs to update its count, and the list it *lands in* needs to update its count, and those are two different Sortable instances receiving two different events.

onRemove fires on the *source* list's instance when one of its items is dragged into a different list. onAdd fires on the *destination* list's instance when it receives an item from elsewhere. onEnd fires on whichever instance the drag started in, regardless of where it ended, and is kept here as a catch-all for same-column reorders (which fire neither onAdd nor onRemove, since nothing crossed a list boundary). All three point at the same updateCounts() function, which — like the ranked-list snippet — recomputes every column's badge from scratch by counting .mcd-card children, rather than incrementing or decrementing counters. That avoids any bookkeeping bugs where a fast double-drag could leave a badge off by one.

What actually moves in the DOM

SortableJS doesn't clone or recreate the dragged card — it physically moves the same DOM node from one <div class="mcd-list"> to another during the drag, so any event listeners or data attributes on the card survive the move untouched. This is also why updateCounts() can just re-query querySelectorAll('.mcd-card') on each list after any drop: the DOM is already the ground truth.

Reusing it

Add a fifth column by giving it the same group: 'board' value and it joins the shared pool automatically. Restrict flow with group: { name: 'board', put: false } on a column that should only ever lose cards, never receive them — useful for an "Archived" column fed only by a button, not drag. Pair this with the ranked list reorder snippet when you need same-list dragging with derived numbering instead of cross-list movement.

Step by step

How to Use

  1. 1
    Add the SortableJS CDNInclude Sortable.min.js from the CDN panel — a single script tag, no build step.
  2. 2
    Paste HTML, CSS, and JSA 4-column board renders with Backlog, In Progress, Review, and Done.
  3. 3
    Drag a card to another columnThe group: 'board' option shared across all four instances lets cards cross freely.
  4. 4
    Watch the header badgesonAdd, onRemove, and onEnd all call updateCounts() so every badge stays accurate.
  5. 5
    Reorder within a columnDragging inside one list fires onEnd only, still triggering a recount.
  6. 6
    Add or restrict columnsGive a new column the same group value to include it, or set put: false to make it drop-only.

Real-world uses

Common Use Cases

Sprint planning boards
Run a four-column board where one shared group string links every Sortable, so cards move freely between columns with header counts updating after each drop.
Lead and applicant funnels
Move leads or candidates between stages, where onAdd, onRemove and onEnd together catch every kind of drop and keep the badges accurate.
Editorial content pipelines
Drag posts between Draft, In review, Scheduled and Published, with true DOM node movement so data and listeners survive each drop.
Support triage queues
Move tickets between New, Assigned and Resolved, recomputing per-column counts from the DOM so the numbers can never disagree with what is on screen.
Cross-list drag reference
Study how separate Sortable instances stay unaware of each other until a shared group name connects them, the root of most kanban confusion.

Got questions?

Frequently Asked Questions

Every Sortable instance is constructed with group: 'board'. On drag-over, SortableJS checks whether the instance under the pointer shares that group name with the instance the drag started in — if the strings match, the drop is accepted. No manual list-to-list wiring is needed beyond giving them the same group value.

onEnd only fires on the instance where the drag started, so a cross-column move needs more: onRemove fires on the source list when a card leaves it, and onAdd fires on the destination list when a card arrives. Using all three ensures every column's count is recomputed regardless of which list the drag started or ended in.

No. SortableJS moves the actual DOM node from one list's children to another's during the drag. This means any data attributes, event listeners, or nested markup on the card are preserved automatically — nothing needs to be re-created or re-bound after the move.

Replace the plain string group value on that column's instance with an object: group: { name: 'board', pull: false }. pull: false stops cards from being dragged out of it while still allowing other columns (which still pull: true by default) to drop cards into it.

A fast drag involves multiple events firing in quick succession, and manually incrementing counters risks double-counting or missing an update if event order is unexpected. Recomputing with querySelectorAll('.mcd-card').length after every relevant event is cheap for a board this size and is guaranteed correct regardless of event ordering.

Inside onAdd (and onEnd for same-column reorders), read evt.to.id for the destination column and Array.from(evt.to.children).map(el => el.dataset.id) for the new order, then send both to your API. Store a stable id on each card via a data attribute so the server can match it back to a record.