Lattice Grid Buy a licence

htmx and the Kanban board

htmx Kanban Board from a Server-Rendered Table

An htmx Kanban board is a data-lattice-kanban element wrapped around the card table your server already renders. The module builds the board on load, re-raises each card move as lattice:kanban-change for hx-post, applies an out-of-band card in place, and rebuilds the board on Back. This page runs every step live.

A stand-in server is answering in your browser. This site is static, so there is no backend: a small script intercepts htmx's requests to /htmx-demo/ and replies with HTML built from demo data in memory, after a short delay so each swap is visible. Nothing leaves the page. The routes it runs are printed below, so you can see what your own server would return.

RefCardOwner StatusPoints
K-101Audit the checkout funnelAna Lopezdone5
K-102Redesign the pricing tableDev Pateldoing8
K-103Write the launch emailChloe Marshdoing3
K-104Set up the A/B testBen Okafortodo5
K-105Landing page for the webinarEva Lindqvisttodo8
K-201Move billing to the new ledgerBen Okafordoing13
K-202Rotate the API keysEva Lindqvistdone2
K-203Cache the pricing lookupsFemi Adeyemidoing5
K-204Retire the v1 webhooksChloe Marshtodo8
K-205Database failover drillDev Pateltodo2

A Kanban board from a plain table

The element wraps a real <table>: each row is one card, each th data-field names a field, and data-lattice-row gives the card its id. The options, including the column property, the columns and the workflow rule, sit in a JSON script beside it.

The workflow rule runs first

transitions maps each column to the columns a card may move to. A refused drop is never posted, so the server only ever sees moves it can accept. Read more about the Kanban board.

Out-of-band cards keep the board

A card the server sends out of band is applied through the board's own row update, so collapsed columns and the selection are untouched.

Posting a move with hx-post

Drop a card and the board fires lattice:kanban-change on its element. The hx-vals turns the event into form fields, and the stand-in server's one-line reply is swapped in under the board.

The markup and the server template

Both blocks are the code the demo above runs. The first is the page's HTML; the second is the stand-in server's route handlers, which are what a server of yours would do.

Page markup

<div class="view"><div class="filters"><button hx-get="/htmx-demo/kanban/cards?team=" hx-target="#kanban-view"
      hx-push-url="/htmx/kanban/?team=" aria-pressed="true">All teams</button>
  <button hx-get="/htmx-demo/kanban/cards?team=Growth" hx-target="#kanban-view"
      hx-push-url="/htmx/kanban/?team=Growth" aria-pressed="false">Growth</button>
  <button hx-get="/htmx-demo/kanban/cards?team=Platform" hx-target="#kanban-view"
      hx-push-url="/htmx/kanban/?team=Platform" aria-pressed="false">Platform</button><button class="action" hx-post="/htmx-demo/kanban/reassign" hx-vals='{"team": ""}' hx-swap="none">Server reassigns one card</button></div>
<div data-lattice-kanban hx-post="/htmx-demo/kanban/move" hx-trigger="lattice:kanban-change"
  hx-vals='js:{id: event.detail.id, from: event.detail.from, to: event.detail.to}'
  hx-target="#save-status" hx-swap="innerHTML" style="height:460px;--lattice-board-column-width:250px">
  <table>
    <thead><tr><th data-field="id">Ref</th><th data-field="title">Card</th><th data-field="owner">Owner</th>
      <th data-field="status">Status</th><th data-field="points" data-type="number">Points</th></tr></thead>
    <tbody>
  <tr data-lattice-row="K-101" ><td>K-101</td><td>Audit the checkout funnel</td><td>Ana Lopez</td><td>done</td><td>5</td></tr>
  <tr data-lattice-row="K-102" ><td>K-102</td><td>Redesign the pricing table</td><td>Dev Patel</td><td>doing</td><td>8</td></tr>
  <tr data-lattice-row="K-103" ><td>K-103</td><td>Write the launch email</td><td>Chloe Marsh</td><td>doing</td><td>3</td></tr>
  <tr data-lattice-row="K-104" ><td>K-104</td><td>Set up the A/B test</td><td>Ben Okafor</td><td>todo</td><td>5</td></tr>
  <tr data-lattice-row="K-105" ><td>K-105</td><td>Landing page for the webinar</td><td>Eva Lindqvist</td><td>todo</td><td>8</td></tr>
  <tr data-lattice-row="K-201" ><td>K-201</td><td>Move billing to the new ledger</td><td>Ben Okafor</td><td>doing</td><td>13</td></tr>
  <tr data-lattice-row="K-202" ><td>K-202</td><td>Rotate the API keys</td><td>Eva Lindqvist</td><td>done</td><td>2</td></tr>
  <tr data-lattice-row="K-203" ><td>K-203</td><td>Cache the pricing lookups</td><td>Femi Adeyemi</td><td>doing</td><td>5</td></tr>
  <tr data-lattice-row="K-204" ><td>K-204</td><td>Retire the v1 webhooks</td><td>Chloe Marsh</td><td>todo</td><td>8</td></tr>
  <tr data-lattice-row="K-205" ><td>K-205</td><td>Database failover drill</td><td>Dev Patel</td><td>todo</td><td>2</td></tr>
    </tbody>
  </table>
  <i id="card-K-101" data-lattice-row="K-101"></i><i id="card-K-102" data-lattice-row="K-102"></i><i id="card-K-103" data-lattice-row="K-103"></i><i id="card-K-104" data-lattice-row="K-104"></i><i id="card-K-105" data-lattice-row="K-105"></i><i id="card-K-201" data-lattice-row="K-201"></i><i id="card-K-202" data-lattice-row="K-202"></i><i id="card-K-203" data-lattice-row="K-203"></i><i id="card-K-204" data-lattice-row="K-204"></i><i id="card-K-205" data-lattice-row="K-205"></i>
</div>
<script type="application/json" data-lattice-config>{"columnProperty":"status","transitions":{"todo":["doing"],"doing":["todo","done"],"done":["doing"]},"columns":[{"id":"todo","title":"Todo"},{"id":"doing","title":"Doing"},{"id":"done","title":"Done"}],"card":{"title":"title","subtitle":"owner"}}</script></div>

Server template (route handlers)

const esc = (s: unknown) => String(s).replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
const teams = ['Growth', 'Platform'];
const columns = ['todo', 'doing', 'done'];

/** The workflow: a card moves one step forward or back; nothing jumps from Todo straight to Done. */
const transitions = { todo: ['doing'], doing: ['todo', 'done'], done: ['doing'] };

const cardRow = (r: (typeof cards)[number], attrs = '') =>
  `<tr data-lattice-row="${r.id}" ${attrs}><td>${r.id}</td><td>${esc(r.title)}</td><td>${esc(r.owner)}</td><td>${r.status}</td><td>${r.points}</td></tr>`;

/** The whole view for one team: the picker, and the board built from the card table inside data-lattice-kanban. */
export function kanbanFragment(team = ''): string {
  const list = team ? cards.filter((r) => r.team === team) : cards;
  const picker = ['', ...teams]
    .map((t) => `<button hx-get="/htmx-demo/kanban/cards?team=${t}" hx-target="#kanban-view"
      hx-push-url="/htmx/kanban/?team=${t}" aria-pressed="${t === team}">${t || 'All teams'}</button>`)
    .join('\n  ');
  const action = `<button class="action" hx-post="/htmx-demo/kanban/reassign" hx-vals='{"team": "${team}"}' hx-swap="none">Server reassigns one card</button>`;
  const config = JSON.stringify({
    columnProperty: 'status', transitions,
    columns: columns.map((id) => ({ id, title: id[0].toUpperCase() + id.slice(1) })),
    card: { title: 'title', subtitle: 'owner' },
  });
  return `<div class="view"><div class="filters">${picker}${action}</div>
<div data-lattice-kanban hx-post="/htmx-demo/kanban/move" hx-trigger="lattice:kanban-change"
  hx-vals='js:{id: event.detail.id, from: event.detail.from, to: event.detail.to}'
  hx-target="#save-status" hx-swap="innerHTML" style="height:460px;--lattice-board-column-width:250px">
  <table>
    <thead><tr><th data-field="id">Ref</th><th data-field="title">Card</th><th data-field="owner">Owner</th>
      <th data-field="status">Status</th><th data-field="points" data-type="number">Points</th></tr></thead>
    <tbody>
  ${list.map((r) => cardRow(r)).join('\n  ')}
    </tbody>
  </table>
  ${list.map((r) => `<i id="card-${r.id}" data-lattice-row="${r.id}"></i>`).join('')}
</div>
<script type="application/json" data-lattice-config>${config}</script></div>`;
}

export const routes: Routes = {
  // hx-get: another team's cards; the old board is torn down and a new one built.
  'GET /htmx-demo/kanban/cards': ({ params }) => kanbanFragment(params.team),

  // hx-post from lattice:kanban-change: the card id and the column it left and reached.
  'POST /htmx-demo/kanban/move': ({ params }) => {
    const card = cards.find((r) => r.id === params.id);
    if (!card) return { html: 'Unknown card', status: 404 };
    card.status = params.to;
    return `Server saved ${esc(card.title)}: ${esc(params.from)} to ${esc(params.to)}`;
  },

  // hx-post with hx-swap="none": one card comes back out of band and the board patches it in place.
  'POST /htmx-demo/kanban/reassign': ({ params }) => {
    const list = params.team ? cards.filter((r) => r.team === params.team) : cards;
    const owners = ['Ana Lopez', 'Ben Okafor', 'Chloe Marsh', 'Dev Patel'];
    const card = list[Math.floor(Math.random() * list.length)];
    card.owner = owners[(owners.indexOf(card.owner) + 1) % owners.length];
    return cardRow(card, `id="card-${card.id}" hx-swap-oob="true"`);
  },
};

Common questions

How do I build an htmx Kanban board from server data?

Wrap a server-rendered table in a data-lattice-kanban element and put the board options in a sibling data-lattice-config script: each row becomes a card and each th data-field a field. The demo above is built that way.

How do I save a card move with htmx?

The board re-raises every move as a lattice:kanban-change event with the card id and the columns it left and reached, so hx-trigger can post it. Drag a card in the demo and read the server's confirmation.

Can the board refuse a move?

Yes: the transitions option lists the columns each column may reach, and a drop that breaks the rule is refused before anything is posted. Try dragging a Todo card straight to Done in the demo.

Does the htmx Kanban board keep its state when I press Back?

Yes: the team is in the markup the server sends and the board's own view state is saved with the history snapshot, so Back rebuilds the board you left. Switch team, then press Back.