Lattice Grid Buy a licence

htmx and the Designer

htmx Dashboard Designer over a Server Source

An htmx dashboard designer is a data-lattice-designer element that starts from the state your server saved. The widgets read a grid, a swap of that grid refreshes them all, every edit goes back as lattice:designer-change for hx-post, and Back returns to the page the author was on. 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.

RefWork itemStatus OwnerTeamStart EndPoints
W-101Audit the checkout funnelDoneAna LopezGrowth 2026-10-052026-10-085
W-102Redesign the pricing tableDoingDev PatelGrowth 2026-10-072026-10-148
W-103Write the launch emailDoingChloe MarshGrowth 2026-10-122026-10-153
W-104Set up the A/B testTodoBen OkaforGrowth 2026-10-142026-10-205
W-105Landing page for the webinarDoingEva LindqvistGrowth 2026-10-132026-10-198
W-106Referral programme briefTodoFemi AdeyemiGrowth 2026-10-192026-10-233
W-107Review the attribution modelDoingAna LopezGrowth 2026-10-092026-10-135
W-108Quarterly results pageTodoDev PatelGrowth 2026-10-212026-10-288
W-201Move billing to the new ledgerDoingBen OkaforPlatform 2026-10-062026-10-1613
W-202Rotate the API keysDoneEva LindqvistPlatform 2026-10-052026-10-062
W-203Cache the pricing lookupsDoingFemi AdeyemiPlatform 2026-10-082026-10-135
W-204Retire the v1 webhooksTodoChloe MarshPlatform 2026-10-152026-10-228
W-205Load test the export queueDoingAna LopezPlatform 2026-10-122026-10-143
W-206Database failover drillTodoDev PatelPlatform 2026-10-202026-10-212
W-207Upgrade the job runnerDoingBen OkaforPlatform 2026-10-092026-10-158
W-208Audit log retentionTodoEva LindqvistPlatform 2026-10-222026-10-275

A dashboard designer declared in HTML

The Designer element carries its mode and its saved state in a JSON config, and data-lattice-bind names the grid its widgets read. The state is plain JSON your server can store as it is: pages, each with a layout and a list of widgets. Here the three widgets are the newest kinds, a Board, a Calendar and a Gantt, all over the same work items.

The source grid is swapped, not the Designer

Only the grid's own element is replaced. The Designer notices, re-reads the new grid and refreshes every widget, so the author's layout and selected page are untouched. For the engine under the widgets, see the dashboard Designer and the core grid.

State travels both ways

The server sends the saved state in the config, and every change comes back as one event with the whole state and the cause. On Back the state is restored from the history snapshot, selected page included, and the source table is hydrated before the Designer binds to it, so every widget is populated again.

Posting the dashboard with hx-post

Move a widget and the Designer fires lattice:designer-change on its element. The hx-vals turns the event into a state field and a cause, the stand-in server keeps the state, and its one-line reply is swapped in under the Designer. The next swap sends the saved state back, so the dashboard you arranged is the one you see.

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" id="team-filters" ><button hx-get="/htmx-demo/designer/source?team=" hx-target="#source"
      hx-push-url="/htmx/designer/?team=" aria-pressed="true">All teams</button>
  <button hx-get="/htmx-demo/designer/source?team=Growth" hx-target="#source"
      hx-push-url="/htmx/designer/?team=Growth" aria-pressed="false">Growth</button>
  <button hx-get="/htmx-demo/designer/source?team=Platform" hx-target="#source"
      hx-push-url="/htmx/designer/?team=Platform" aria-pressed="false">Platform</button></div>
<div id="source"><table id="items" data-lattice-grid>
  <thead><tr><th data-field="id">Ref</th><th data-field="title">Work item</th><th data-field="status">Status</th>
    <th data-field="owner">Owner</th><th data-field="team">Team</th><th data-field="start" data-type="date">Start</th>
    <th data-field="end" data-type="date">End</th><th data-field="points" data-type="number">Points</th></tr></thead>
  <tbody>
  <tr data-lattice-row="W-101"><td>W-101</td><td>Audit the checkout funnel</td><td>Done</td><td>Ana Lopez</td><td>Growth</td>
    <td>2026-10-05</td><td>2026-10-08</td><td>5</td></tr>
  <tr data-lattice-row="W-102"><td>W-102</td><td>Redesign the pricing table</td><td>Doing</td><td>Dev Patel</td><td>Growth</td>
    <td>2026-10-07</td><td>2026-10-14</td><td>8</td></tr>
  <tr data-lattice-row="W-103"><td>W-103</td><td>Write the launch email</td><td>Doing</td><td>Chloe Marsh</td><td>Growth</td>
    <td>2026-10-12</td><td>2026-10-15</td><td>3</td></tr>
  <tr data-lattice-row="W-104"><td>W-104</td><td>Set up the A/B test</td><td>Todo</td><td>Ben Okafor</td><td>Growth</td>
    <td>2026-10-14</td><td>2026-10-20</td><td>5</td></tr>
  <tr data-lattice-row="W-105"><td>W-105</td><td>Landing page for the webinar</td><td>Doing</td><td>Eva Lindqvist</td><td>Growth</td>
    <td>2026-10-13</td><td>2026-10-19</td><td>8</td></tr>
  <tr data-lattice-row="W-106"><td>W-106</td><td>Referral programme brief</td><td>Todo</td><td>Femi Adeyemi</td><td>Growth</td>
    <td>2026-10-19</td><td>2026-10-23</td><td>3</td></tr>
  <tr data-lattice-row="W-107"><td>W-107</td><td>Review the attribution model</td><td>Doing</td><td>Ana Lopez</td><td>Growth</td>
    <td>2026-10-09</td><td>2026-10-13</td><td>5</td></tr>
  <tr data-lattice-row="W-108"><td>W-108</td><td>Quarterly results page</td><td>Todo</td><td>Dev Patel</td><td>Growth</td>
    <td>2026-10-21</td><td>2026-10-28</td><td>8</td></tr>
  <tr data-lattice-row="W-201"><td>W-201</td><td>Move billing to the new ledger</td><td>Doing</td><td>Ben Okafor</td><td>Platform</td>
    <td>2026-10-06</td><td>2026-10-16</td><td>13</td></tr>
  <tr data-lattice-row="W-202"><td>W-202</td><td>Rotate the API keys</td><td>Done</td><td>Eva Lindqvist</td><td>Platform</td>
    <td>2026-10-05</td><td>2026-10-06</td><td>2</td></tr>
  <tr data-lattice-row="W-203"><td>W-203</td><td>Cache the pricing lookups</td><td>Doing</td><td>Femi Adeyemi</td><td>Platform</td>
    <td>2026-10-08</td><td>2026-10-13</td><td>5</td></tr>
  <tr data-lattice-row="W-204"><td>W-204</td><td>Retire the v1 webhooks</td><td>Todo</td><td>Chloe Marsh</td><td>Platform</td>
    <td>2026-10-15</td><td>2026-10-22</td><td>8</td></tr>
  <tr data-lattice-row="W-205"><td>W-205</td><td>Load test the export queue</td><td>Doing</td><td>Ana Lopez</td><td>Platform</td>
    <td>2026-10-12</td><td>2026-10-14</td><td>3</td></tr>
  <tr data-lattice-row="W-206"><td>W-206</td><td>Database failover drill</td><td>Todo</td><td>Dev Patel</td><td>Platform</td>
    <td>2026-10-20</td><td>2026-10-21</td><td>2</td></tr>
  <tr data-lattice-row="W-207"><td>W-207</td><td>Upgrade the job runner</td><td>Doing</td><td>Ben Okafor</td><td>Platform</td>
    <td>2026-10-09</td><td>2026-10-15</td><td>8</td></tr>
  <tr data-lattice-row="W-208"><td>W-208</td><td>Audit log retention</td><td>Todo</td><td>Eva Lindqvist</td><td>Platform</td>
    <td>2026-10-22</td><td>2026-10-27</td><td>5</td></tr>
  </tbody>
</table></div>
<div data-lattice-designer data-lattice-bind="items" hx-post="/htmx-demo/designer/save" hx-trigger="lattice:designer-change"
  hx-vals='js:{state: JSON.stringify(event.detail.state), cause: event.detail.cause}'
  hx-target="#save-status" hx-swap="innerHTML" style="height:700px"></div>
<script type="application/json" data-lattice-config>{"mode":"edit","state":{"schemaVersion":1,"selectedPageId":"delivery","panels":{},"pages":[{"id":"delivery","title":"Delivery","spec":{"layout":{"columns":1,"rows":3,"overflowY":"scroll","rowHeight":"270px","windows":[{"id":"board","xPos":0,"yPos":0,"xSize":1,"ySize":1},{"id":"week","xPos":0,"yPos":1,"xSize":1,"ySize":1},{"id":"plan","xPos":0,"yPos":2,"xSize":1,"ySize":1}]},"panels":[{"id":"board","kind":"board","title":"By status","source":"items","options":{"columnProperty":"status","columns":[{"id":"Todo","title":"Todo"},{"id":"Doing","title":"Doing"},{"id":"Done","title":"Done"}],"card":{"title":"title","subtitle":"owner"}}},{"id":"week","kind":"calendar","title":"This fortnight","source":"items","options":{"view":"week","date":"2026-10-12","startProperty":"start","endProperty":"end","card":{"title":"title"}}},{"id":"plan","kind":"gantt","title":"Plan","source":"items","options":{"fields":{"id":"id","name":"title","start":"start","end":"end"},"split":{"zoom":"week"}}}]}}]}}</script></div>

Server template (route handlers)

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

/** The dashboard the author last saved. A swap re-sends it, so a swap never resets the layout. */
let saved: unknown = {
  schemaVersion: 1,
  selectedPageId: 'delivery',
  panels: {},
  pages: [{
    id: 'delivery', title: 'Delivery',
    spec: {
      layout: { columns: 1, rows: 3, overflowY: 'scroll', rowHeight: '270px', windows: [
        { id: 'board', xPos: 0, yPos: 0, xSize: 1, ySize: 1 },
        { id: 'week', xPos: 0, yPos: 1, xSize: 1, ySize: 1 },
        { id: 'plan', xPos: 0, yPos: 2, xSize: 1, ySize: 1 },
      ] },
      panels: [
        { id: 'board', kind: 'board', title: 'By status', source: 'items',
          options: { columnProperty: 'status', columns: statuses.map((s) => ({ id: s, title: s })), card: { title: 'title', subtitle: 'owner' } } },
        { id: 'week', kind: 'calendar', title: 'This fortnight', source: 'items',
          options: { view: 'week', date: '2026-10-12', startProperty: 'start', endProperty: 'end', card: { title: 'title' } } },
        { id: 'plan', kind: 'gantt', title: 'Plan', source: 'items',
          options: { fields: { id: 'id', name: 'title', start: 'start', end: 'end' }, split: { zoom: 'week' } } },
      ],
    },
  }],
};

const itemRow = (r: (typeof items)[number]) =>
  `<tr data-lattice-row="${r.id}"><td>${r.id}</td><td>${esc(r.title)}</td><td>${r.status}</td><td>${esc(r.owner)}</td><td>${r.team}</td>
    <td>${r.start}</td><td>${r.end}</td><td>${r.points}</td></tr>`;

/** The team picker. Its pressed state is in the markup, so a history snapshot carries it. */
const picker = (team: string, attrs = '') =>
  `<div class="filters" id="team-filters" ${attrs}>${['', ...teams]
    .map((t) => `<button hx-get="/htmx-demo/designer/source?team=${t}" hx-target="#source"
      hx-push-url="/htmx/designer/?team=${t}" aria-pressed="${t === team}">${t || 'All teams'}</button>`)
    .join('\n  ')}</div>`;

/** The picker, the source, and the Designer over it, started from the saved state. */
export function designerFragment(team = ''): string {
  const state = JSON.stringify(saved).replace(/</g, '\\u003c');
  return `<div class="view">${picker(team)}
<div id="source">${sourceTable(team ? items.filter((r) => r.team === team) : items)}</div>
<div data-lattice-designer data-lattice-bind="items" hx-post="/htmx-demo/designer/save" hx-trigger="lattice:designer-change"
  hx-vals='js:{state: JSON.stringify(event.detail.state), cause: event.detail.cause}'
  hx-target="#save-status" hx-swap="innerHTML" style="height:700px"></div>
<script type="application/json" data-lattice-config>{"mode":"edit","state":${state}}</script></div>`;
}

/** The source the dashboard reads: a server table with one row per work item. */
const sourceTable = (list: typeof items) =>
  `<table id="items" data-lattice-grid>
  <thead><tr><th data-field="id">Ref</th><th data-field="title">Work item</th><th data-field="status">Status</th>
    <th data-field="owner">Owner</th><th data-field="team">Team</th><th data-field="start" data-type="date">Start</th>
    <th data-field="end" data-type="date">End</th><th data-field="points" data-type="number">Points</th></tr></thead>
  <tbody>
  ${list.map(itemRow).join('\n  ')}
  </tbody>
</table>`;

export const routes: Routes = {
  // hx-get: only the source table is swapped; the designer stays and its widgets refresh.
  // The picker comes back out of band, so its pressed button follows.
  'GET /htmx-demo/designer/source': ({ params }) =>
    sourceTable(params.team ? items.filter((r) => r.team === params.team) : items) + picker(params.team, 'hx-swap-oob="true"'),

  // hx-post from lattice:designer-change: keep the whole state and confirm.
  'POST /htmx-demo/designer/save': ({ params }) => {
    saved = JSON.parse(params.state);
    const page = (saved as any).pages.find((p: any) => p.id === (saved as any).selectedPageId);
    return `Server saved the dashboard (${esc(params.cause)}): ${page.spec.panels.length} widgets on ${esc(page.title)}`;
  },
};

Common questions

How do I put a dashboard designer on an htmx page?

Add a data-lattice-designer element with its saved state in a JSON config and name the data grid it reads with data-lattice-bind. Load the Designer module and the Lattice htmx module, and one htmx:load builds it; the demo above is declared that way. The source is a plain server table marked data-lattice-grid.

How do I save what the author changes with htmx?

The Designer re-raises every author change as a lattice:designer-change event carrying the whole state and the cause, so hx-trigger can post it. Move a widget in the demo and read the server's confirmation.

What happens to the widgets when htmx swaps the source table?

The Designer stays where it is and re-reads the replacement grid, so the Board, Calendar and Gantt widgets all show the new rows. Pick the other team in the demo to watch the three refresh.

Does the Designer keep the page the author was on after Back?

Yes: the state, including the selected page, is written to the snapshot before htmx saves it and applied again when the snapshot comes back. Select a page, swap the source, then press Back.