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.
| Ref | Work item | Status | Owner | Team | Start | End | Points |
|---|---|---|---|---|---|---|---|
| W-101 | Audit the checkout funnel | Done | Ana Lopez | Growth | 2026-10-05 | 2026-10-08 | 5 |
| W-102 | Redesign the pricing table | Doing | Dev Patel | Growth | 2026-10-07 | 2026-10-14 | 8 |
| W-103 | Write the launch email | Doing | Chloe Marsh | Growth | 2026-10-12 | 2026-10-15 | 3 |
| W-104 | Set up the A/B test | Todo | Ben Okafor | Growth | 2026-10-14 | 2026-10-20 | 5 |
| W-105 | Landing page for the webinar | Doing | Eva Lindqvist | Growth | 2026-10-13 | 2026-10-19 | 8 |
| W-106 | Referral programme brief | Todo | Femi Adeyemi | Growth | 2026-10-19 | 2026-10-23 | 3 |
| W-107 | Review the attribution model | Doing | Ana Lopez | Growth | 2026-10-09 | 2026-10-13 | 5 |
| W-108 | Quarterly results page | Todo | Dev Patel | Growth | 2026-10-21 | 2026-10-28 | 8 |
| W-201 | Move billing to the new ledger | Doing | Ben Okafor | Platform | 2026-10-06 | 2026-10-16 | 13 |
| W-202 | Rotate the API keys | Done | Eva Lindqvist | Platform | 2026-10-05 | 2026-10-06 | 2 |
| W-203 | Cache the pricing lookups | Doing | Femi Adeyemi | Platform | 2026-10-08 | 2026-10-13 | 5 |
| W-204 | Retire the v1 webhooks | Todo | Chloe Marsh | Platform | 2026-10-15 | 2026-10-22 | 8 |
| W-205 | Load test the export queue | Doing | Ana Lopez | Platform | 2026-10-12 | 2026-10-14 | 3 |
| W-206 | Database failover drill | Todo | Dev Patel | Platform | 2026-10-20 | 2026-10-21 | 2 |
| W-207 | Upgrade the job runner | Doing | Ben Okafor | Platform | 2026-10-09 | 2026-10-15 | 8 |
| W-208 | Audit log retention | Todo | Eva Lindqvist | Platform | 2026-10-22 | 2026-10-27 | 5 |
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.
The dashboard Designer The grid engine The htmx guide $1,000 per domain