Lattice Grid Buy a licence

htmx and the Gantt

htmx Gantt Chart over a Server Task Table

An htmx Gantt chart is the task table your server already renders, wrapped in a data-lattice-gantt element. The module turns it into a split view of task grid and timeline, re-raises every drag as a lattice:gantt-change event for hx-post, and restores the zoom when the reader presses Back.

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.

IDTaskStart EndDone %
L1Scope the launch2026-10-052026-10-08100
L2Build the landing page2026-10-092026-10-1660
L3Write the announcement2026-10-122026-10-1530
L4Review with legal2026-10-192026-10-210
L5Go live2026-10-222026-10-230

An htmx Gantt chart from a plain task table

The element wraps a real <table>: each th data-field names a column of the task grid on the left, and each row is a task. With JavaScript off it is still a readable table. The Gantt engine is a separate module, so the page loads it first and a page with htmx and no Gantt never pays for it.

Changes arrive as events

A move, resize, progress change, cell edit, link, add or delete is re-raised as a bubbling lattice:gantt-change event carrying the task id, the kind of change and the old and new values, with dates already as ISO strings. The hx-post on the element in the demo is the whole save path.

Zoom and state ride along on Back

On Back the module reapplies the zoom, visible range, scroll, collapsed rows and column widths from the snapshot. For the plan itself, see the Gantt chart; for the engine under the task grid, the core grid.

Posting a dragged bar with hx-post

Drag a bar in the demo and the Gantt reschedules the task, then fires lattice:gantt-change. The hx-vals on the element turns the event into form fields, the stand-in server stores the new dates, and its one-line reply is swapped in under the plan. An out-of-band task row in a later response goes the other way, through the Gantt's own row update.

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/gantt/plan?project=launch" hx-target="#gantt-view"
      hx-push-url="/htmx/gantt/?project=launch" aria-pressed="true">Product launch</button>
  <button hx-get="/htmx-demo/gantt/plan?project=migration" hx-target="#gantt-view"
      hx-push-url="/htmx/gantt/?project=migration" aria-pressed="false">Data migration</button><button class="action" hx-post="/htmx-demo/gantt/slip" hx-vals='{"project": "launch"}' hx-swap="none">Server slips one task</button></div>
<div data-lattice-gantt hx-post="/htmx-demo/gantt/save" hx-trigger="lattice:gantt-change"
  hx-vals='js:{id: event.detail.id, kind: event.detail.kind, changes: JSON.stringify(event.detail.changes)}'
  hx-target="#save-status" hx-swap="innerHTML" style="height:460px">
  <table><thead><tr><th data-field="id">ID</th><th data-field="name">Task</th><th data-field="start">Start</th>
    <th data-field="end">End</th><th data-field="percentComplete">Done %</th></tr></thead>
  <tbody>
  <tr ><td>L1</td><td>Scope the launch</td><td>2026-10-05</td><td>2026-10-08</td><td>100</td></tr>
  <tr ><td>L2</td><td>Build the landing page</td><td>2026-10-09</td><td>2026-10-16</td><td>60</td></tr>
  <tr ><td>L3</td><td>Write the announcement</td><td>2026-10-12</td><td>2026-10-15</td><td>30</td></tr>
  <tr ><td>L4</td><td>Review with legal</td><td>2026-10-19</td><td>2026-10-21</td><td>0</td></tr>
  <tr ><td>L5</td><td>Go live</td><td>2026-10-22</td><td>2026-10-23</td><td>0</td></tr>
  </tbody></table><i id="task-L1" data-lattice-row="L1"></i><i id="task-L2" data-lattice-row="L2"></i><i id="task-L3" data-lattice-row="L3"></i><i id="task-L4" data-lattice-row="L4"></i><i id="task-L5" data-lattice-row="L5"></i>
</div>
<script type="application/json" data-lattice-config>{"projectStart":"2026-10-05","split":{"editable":true,"dateAxis":true,"zoom":"week","zoomControl":true,"gridWidth":400}}</script></div>

Server template (route handlers)

const esc = (s: unknown) => String(s).replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
const names: Record<string, string> = { launch: 'Product launch', migration: 'Data migration' };

const taskRow = (t: (typeof plans)[string][number], attrs = '') =>
  `<tr ${attrs}><td>${t.id}</td><td>${esc(t.name)}</td><td>${t.start}</td><td>${t.end}</td><td>${t.percentComplete}</td></tr>`;

/** Project picker, plus the Gantt over the plan's task table. */
export function ganttFragment(project = 'launch'): string {
  const pick = Object.keys(plans)
    .map((p) => `<button hx-get="/htmx-demo/gantt/plan?project=${p}" hx-target="#gantt-view"
      hx-push-url="/htmx/gantt/?project=${p}" aria-pressed="${p === project}">${names[p]}</button>`)
    .join('\n  ');
  const list = plans[project];
  // The anchors sit inside the Gantt element: an out-of-band row lands on one.
  const anchors = list.map((t) => `<i id="task-${t.id}" data-lattice-row="${t.id}"></i>`).join('');
  const action = `<button class="action" hx-post="/htmx-demo/gantt/slip" hx-vals='{"project": "${project}"}' hx-swap="none">Server slips one task</button>`;
  return `<div class="view"><div class="filters">${pick}${action}</div>
<div data-lattice-gantt hx-post="/htmx-demo/gantt/save" hx-trigger="lattice:gantt-change"
  hx-vals='js:{id: event.detail.id, kind: event.detail.kind, changes: JSON.stringify(event.detail.changes)}'
  hx-target="#save-status" hx-swap="innerHTML" style="height:460px">
  <table><thead><tr><th data-field="id">ID</th><th data-field="name">Task</th><th data-field="start">Start</th>
    <th data-field="end">End</th><th data-field="percentComplete">Done %</th></tr></thead>
  <tbody>
  ${list.map((t) => taskRow(t)).join('\n  ')}
  </tbody></table>${anchors}
</div>
<script type="application/json" data-lattice-config>{"projectStart":"2026-10-05","split":{"editable":true,"dateAxis":true,"zoom":"week","zoomControl":true,"gridWidth":400}}</script></div>`;
}

export const routes: Routes = {
  // hx-get: another project's plan; the old Gantt is torn down and a new one built.
  'GET /htmx-demo/gantt/plan': ({ params }) => ganttFragment(params.project in plans ? params.project : 'launch'),

  // hx-post from lattice:gantt-change: record the change and confirm it.
  'POST /htmx-demo/gantt/save': ({ params }) => {
    const task = Object.values(plans).flat().find((t) => t.id === params.id);
    if (!task) return { html: 'Unknown task', status: 404 };
    const changes = JSON.parse(params.changes);
    for (const [field, v] of Object.entries<any>(changes)) (task as any)[field] = v?.to ?? v;
    return `Server saved ${esc(task.name)} (${esc(params.kind)}): ${esc(task.start)} to ${esc(task.end)}`;
  },

  // hx-post with hx-swap="none": one task comes back out of band and its bar moves.
  'POST /htmx-demo/gantt/slip': ({ params }) => {
    const list = plans[params.project in plans ? params.project : 'launch'];
    const task = list[1 + Math.floor(Math.random() * (list.length - 1))];
    const day = (iso: string) => new Date(Date.parse(iso) + 864e5).toISOString().slice(0, 10);
    task.start = day(task.start);
    task.end = day(task.end);
    return taskRow(task, `id="task-${task.id}" data-lattice-row="${task.id}" hx-swap-oob="true"`);
  },
};

Common questions

How do I add an htmx Gantt chart to a server-rendered page?

Wrap a task table in a data-lattice-gantt element, load the Gantt module and the Lattice htmx module, and the table becomes a split task grid and timeline. The demo above is built that way, and its markup is printed under it.

How do I save a dragged Gantt bar with htmx?

The Gantt re-raises every committed change as a lattice:gantt-change event on its element, so hx-trigger can post it with the task id and the changed fields. Drag a bar in the demo and read the server's confirmation.

Can the server move one task without redrawing the plan?

Yes: an out-of-band row whose data-lattice-row names the task is applied through the Gantt's own row update, so zoom, scroll and collapsed rows are untouched. The "Server slips one task" button shows it.

Does the Gantt keep its zoom when I press Back?

Yes: zoom, visible range, scroll, collapsed rows and column widths are saved with htmx's history snapshot and reapplied on Back. Change the zoom, switch project, then press Back.