Lattice Grid Buy a licence

htmx and the calendar

htmx Calendar from a Server-Rendered Table

An htmx calendar is a data-lattice-calendar element, bound to a table grid or wrapped around the plain table your server already renders. The module builds it on load, re-raises each dragged event as lattice:calendar-change for hx-post, and rebuilds it with the same view 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.

Bookings: a calendar bound to a grid

BookingTeam StartEnd
Release freezePlatform2026-10-052026-10-07
Design reviewDesign2026-10-062026-10-06
On-call handoverSupport2026-10-082026-10-08
Load test windowPlatform2026-10-122026-10-14
Brand workshopDesign2026-10-132026-10-13
Support rota changeSupport2026-10-152026-10-16
Database migrationPlatform2026-10-192026-10-20
Usability sessionsDesign2026-10-202026-10-22
Incident reviewSupport2026-10-212026-10-21
Quarterly planningPlatform2026-10-262026-10-27
Style guide handoffDesign2026-10-282026-10-28
Customer webinarSupport2026-10-292026-10-29
Capacity reviewPlatform2026-10-022026-10-02
Icon auditDesign2026-10-012026-10-02
Escalation drillSupport2026-10-272026-10-27

On-call rota: a calendar built from a table

On callFromTo
Ana Lopez2026-10-122026-10-14
Ben Okafor2026-10-152026-10-18
Chloe Marsh2026-10-122026-10-13
Dev Patel2026-10-142026-10-18
Eva Lindqvist2026-10-122026-10-15
Femi Adeyemi2026-10-162026-10-18

An htmx calendar from a plain table

The element wraps a real <table>: each th data-field names a field, each row is one event, and data-lattice-row on the row gives the event its id. With JavaScript off it is still a readable table. The calendar draws multi-day events as bars across the days they cover, on a month or a week view.

One fragment, both calendars

The team and the month or week view are buttons whose pressed state is in the fragment the server sends. A history snapshot therefore carries them, and Back shows the calendar you left. Read more about the calendar view of a grid, or the grid engine under it.

Dates keep their day

The calendar is configured with a timeZone, so a date-only card dragged to another day arrives as that day's ISO date and the server stores it as it is.

Posting a dragged event with hx-post

Drag a card and the calendar fires lattice:calendar-change on its element. The hx-vals turns the event into form fields, the stand-in server stores them, and its one-line reply is swapped in under the calendars. The same two attributes sit on both calendars.

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/calendar/bookings?team=&amp;view=month" hx-target="#calendar-view"
      hx-push-url="/htmx/calendar/?team=&amp;view=month" aria-pressed="true">All teams</button>
  <button hx-get="/htmx-demo/calendar/bookings?team=Platform&amp;view=month" hx-target="#calendar-view"
      hx-push-url="/htmx/calendar/?team=Platform&amp;view=month" aria-pressed="false">Platform</button>
  <button hx-get="/htmx-demo/calendar/bookings?team=Design&amp;view=month" hx-target="#calendar-view"
      hx-push-url="/htmx/calendar/?team=Design&amp;view=month" aria-pressed="false">Design</button>
  <button hx-get="/htmx-demo/calendar/bookings?team=Support&amp;view=month" hx-target="#calendar-view"
      hx-push-url="/htmx/calendar/?team=Support&amp;view=month" aria-pressed="false">Support</button>
  <button hx-get="/htmx-demo/calendar/bookings?team=&amp;view=month" hx-target="#calendar-view"
      hx-push-url="/htmx/calendar/?team=&amp;view=month" aria-pressed="true">Month view</button>
  <button hx-get="/htmx-demo/calendar/bookings?team=&amp;view=week" hx-target="#calendar-view"
      hx-push-url="/htmx/calendar/?team=&amp;view=week" aria-pressed="false">Week view</button></div>
<h3>Bookings: a calendar bound to a grid <button class="action" hx-post="/htmx-demo/calendar/shift" hx-vals='{"kind": "booking", "team": ""}' hx-swap="none">Server moves one booking</button></h3>
<div data-lattice-calendar data-lattice-bind="bookings" hx-post="/htmx-demo/calendar/save" hx-trigger="lattice:calendar-change"
  hx-vals='js:{id: event.detail.id, start: event.detail.start, end: event.detail.end}'
  hx-target="#save-status" hx-swap="innerHTML" style="height:500px;--lattice-calendar-cell-min-height:72px"><i id="booking-B1" data-lattice-row="B1"></i><i id="booking-B2" data-lattice-row="B2"></i><i id="booking-B3" data-lattice-row="B3"></i><i id="booking-B4" data-lattice-row="B4"></i><i id="booking-B5" data-lattice-row="B5"></i><i id="booking-B6" data-lattice-row="B6"></i><i id="booking-B7" data-lattice-row="B7"></i><i id="booking-B8" data-lattice-row="B8"></i><i id="booking-B9" data-lattice-row="B9"></i><i id="booking-B10" data-lattice-row="B10"></i><i id="booking-B11" data-lattice-row="B11"></i><i id="booking-B12" data-lattice-row="B12"></i><i id="booking-B13" data-lattice-row="B13"></i><i id="booking-B14" data-lattice-row="B14"></i><i id="booking-B15" data-lattice-row="B15"></i></div>
<script type="application/json" data-lattice-config>{"view":"month","date":"2026-10-12","firstDay":1,"timeZone":"UTC","startProperty":"start","endProperty":"end","card":{"title":"title"}}</script>
<table id="bookings" data-lattice-grid>
  <thead><tr><th data-field="title">Booking</th><th data-field="team">Team</th>
    <th data-field="start" data-type="date" data-edit>Start</th><th data-field="end" data-type="date" data-edit>End</th></tr></thead>
  <tbody>
  <tr data-lattice-row="B1" ><td>Release freeze</td><td>Platform</td><td>2026-10-05</td><td>2026-10-07</td></tr>
  <tr data-lattice-row="B2" ><td>Design review</td><td>Design</td><td>2026-10-06</td><td>2026-10-06</td></tr>
  <tr data-lattice-row="B3" ><td>On-call handover</td><td>Support</td><td>2026-10-08</td><td>2026-10-08</td></tr>
  <tr data-lattice-row="B4" ><td>Load test window</td><td>Platform</td><td>2026-10-12</td><td>2026-10-14</td></tr>
  <tr data-lattice-row="B5" ><td>Brand workshop</td><td>Design</td><td>2026-10-13</td><td>2026-10-13</td></tr>
  <tr data-lattice-row="B6" ><td>Support rota change</td><td>Support</td><td>2026-10-15</td><td>2026-10-16</td></tr>
  <tr data-lattice-row="B7" ><td>Database migration</td><td>Platform</td><td>2026-10-19</td><td>2026-10-20</td></tr>
  <tr data-lattice-row="B8" ><td>Usability sessions</td><td>Design</td><td>2026-10-20</td><td>2026-10-22</td></tr>
  <tr data-lattice-row="B9" ><td>Incident review</td><td>Support</td><td>2026-10-21</td><td>2026-10-21</td></tr>
  <tr data-lattice-row="B10" ><td>Quarterly planning</td><td>Platform</td><td>2026-10-26</td><td>2026-10-27</td></tr>
  <tr data-lattice-row="B11" ><td>Style guide handoff</td><td>Design</td><td>2026-10-28</td><td>2026-10-28</td></tr>
  <tr data-lattice-row="B12" ><td>Customer webinar</td><td>Support</td><td>2026-10-29</td><td>2026-10-29</td></tr>
  <tr data-lattice-row="B13" ><td>Capacity review</td><td>Platform</td><td>2026-10-02</td><td>2026-10-02</td></tr>
  <tr data-lattice-row="B14" ><td>Icon audit</td><td>Design</td><td>2026-10-01</td><td>2026-10-02</td></tr>
  <tr data-lattice-row="B15" ><td>Escalation drill</td><td>Support</td><td>2026-10-27</td><td>2026-10-27</td></tr>
  </tbody>
</table>
<h3>On-call rota: a calendar built from a table <button class="action" hx-post="/htmx-demo/calendar/shift" hx-vals='{"kind": "shift", "team": ""}' hx-swap="none">Server moves one shift</button></h3>
<div data-lattice-calendar hx-post="/htmx-demo/calendar/save" hx-trigger="lattice:calendar-change"
  hx-vals='js:{id: event.detail.id, start: event.detail.start, end: event.detail.end}'
  hx-target="#save-status" hx-swap="innerHTML" style="height:230px">
  <table>
    <thead><tr><th data-field="person">On call</th><th data-field="start" data-type="date">From</th><th data-field="end" data-type="date">To</th></tr></thead>
    <tbody>
  <tr data-lattice-row="R1" ><td>Ana Lopez</td><td>2026-10-12</td><td>2026-10-14</td></tr>
  <tr data-lattice-row="R2" ><td>Ben Okafor</td><td>2026-10-15</td><td>2026-10-18</td></tr>
  <tr data-lattice-row="R3" ><td>Chloe Marsh</td><td>2026-10-12</td><td>2026-10-13</td></tr>
  <tr data-lattice-row="R4" ><td>Dev Patel</td><td>2026-10-14</td><td>2026-10-18</td></tr>
  <tr data-lattice-row="R5" ><td>Eva Lindqvist</td><td>2026-10-12</td><td>2026-10-15</td></tr>
  <tr data-lattice-row="R6" ><td>Femi Adeyemi</td><td>2026-10-16</td><td>2026-10-18</td></tr>
    </tbody>
  </table>
  <i id="shift-R1" data-lattice-row="R1"></i><i id="shift-R2" data-lattice-row="R2"></i><i id="shift-R3" data-lattice-row="R3"></i><i id="shift-R4" data-lattice-row="R4"></i><i id="shift-R5" data-lattice-row="R5"></i><i id="shift-R6" data-lattice-row="R6"></i>
</div>
<script type="application/json" data-lattice-config>{"view":"week","date":"2026-10-12","firstDay":1,"timeZone":"UTC","startProperty":"start","endProperty":"end","card":{"title":"person"}}</script></div>

Server template (route handlers)

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

const bookingRow = (r: (typeof bookings)[number], attrs = '') =>
  `<tr data-lattice-row="${r.id}" ${attrs}><td>${esc(r.title)}</td><td>${r.team}</td><td>${r.start}</td><td>${r.end}</td></tr>`;
const rotaRow = (r: (typeof rota)[number], attrs = '') =>
  `<tr data-lattice-row="${r.id}" ${attrs}><td>${esc(r.person)}</td><td>${r.start}</td><td>${r.end}</td></tr>`;

/** The whole view for one team: team and view buttons, a calendar bound to a table grid, and one built straight from a table. */
export function calendarFragment(team = '', view = 'month'): string {
  const list = team ? bookings.filter((r) => r.team === team) : bookings;
  const shifts = team ? rota.filter((r) => r.team === team) : rota;
  // Team and view are server state: each button names the pair it asks for and
  // the pressed ones are in the markup, so a history snapshot carries them.
  const button = (label: string, t: string, v: string, pressed: boolean) =>
    `<button hx-get="/htmx-demo/calendar/bookings?team=${t}&amp;view=${v}" hx-target="#calendar-view"
      hx-push-url="/htmx/calendar/?team=${t}&amp;view=${v}" aria-pressed="${pressed}">${label}</button>`;
  const filters = [
    ...['', ...teams].map((t) => button(t || 'All teams', t, view, t === team)),
    ...['month', 'week'].map((v) => button(v === 'month' ? 'Month view' : 'Week view', team, v, v === view)),
  ].join('\n  ');
  const action = (kind: string, label: string) =>
    `<button class="action" hx-post="/htmx-demo/calendar/shift" hx-vals='{"kind": "${kind}", "team": "${team}"}' hx-swap="none">${label}</button>`;
  // Both calendars post a move the same way: the event carries the id and the new dates.
  const save = `hx-post="/htmx-demo/calendar/save" hx-trigger="lattice:calendar-change"
  hx-vals='js:{id: event.detail.id, start: event.detail.start, end: event.detail.end}'
  hx-target="#save-status" hx-swap="innerHTML"`;
  const options = (v: string, title: string) =>
    `{"view":"${v}","date":"2026-10-12","firstDay":1,"timeZone":"UTC","startProperty":"start","endProperty":"end","card":{"title":"${title}"}}`;
  const anchors = (prefix: string, ids: string[]) => ids.map((id) => `<i id="${prefix}-${id}" data-lattice-row="${id}"></i>`).join('');
  return `<div class="view"><div class="filters">${filters}</div>
<h3>Bookings: a calendar bound to a grid ${action('booking', 'Server moves one booking')}</h3>
<div data-lattice-calendar data-lattice-bind="bookings" ${save} style="height:${view === 'month' ? 500 : 300}px;--lattice-calendar-cell-min-height:72px">${anchors('booking', list.map((r) => r.id))}</div>
<script type="application/json" data-lattice-config>${options(view, 'title')}</script>
<table id="bookings" data-lattice-grid>
  <thead><tr><th data-field="title">Booking</th><th data-field="team">Team</th>
    <th data-field="start" data-type="date" data-edit>Start</th><th data-field="end" data-type="date" data-edit>End</th></tr></thead>
  <tbody>
  ${list.map((r) => bookingRow(r)).join('\n  ')}
  </tbody>
</table>
<h3>On-call rota: a calendar built from a table ${action('shift', 'Server moves one shift')}</h3>
<div data-lattice-calendar ${save} style="height:230px">
  <table>
    <thead><tr><th data-field="person">On call</th><th data-field="start" data-type="date">From</th><th data-field="end" data-type="date">To</th></tr></thead>
    <tbody>
  ${shifts.map((r) => rotaRow(r)).join('\n  ')}
    </tbody>
  </table>
  ${anchors('shift', shifts.map((r) => r.id))}
</div>
<script type="application/json" data-lattice-config>${options('week', 'person')}</script></div>`;
}

export const routes: Routes = {
  // hx-get: the whole view for one team; both calendars are rebuilt.
  'GET /htmx-demo/calendar/bookings': ({ params }) => calendarFragment(params.team, params.view),

  // hx-post from lattice:calendar-change: the new dates arrive as ISO strings in the calendar's timeZone.
  'POST /htmx-demo/calendar/save': ({ params }) => {
    const row = [...bookings, ...rota].find((r) => r.id === params.id);
    if (!row) return { html: 'Unknown event', status: 404 };
    Object.assign(row, { start: params.start.slice(0, 10), end: params.end.slice(0, 10) });
    return `Server saved ${esc('title' in row ? row.title : row.person)}: ${esc(row.start)} to ${esc(row.end)}`;
  },

  // hx-post with hx-swap="none": one event comes back out of band and its card moves a day.
  'POST /htmx-demo/calendar/shift': ({ params }) => {
    const booking = params.kind === 'booking';
    const all = (booking ? bookings : rota) as { id: string; team: string; start: string; end: string }[];
    const list = params.team ? all.filter((r) => r.team === params.team) : all;
    const row = list[Math.floor(Math.random() * list.length)];
    const day = (iso: string) => new Date(Date.parse(iso) + 864e5).toISOString().slice(0, 10);
    row.start = day(row.start);
    row.end = day(row.end);
    const attrs = `id="${booking ? 'booking' : 'shift'}-${row.id}" hx-swap-oob="true"`;
    return booking ? bookingRow(row as (typeof bookings)[number], attrs) : rotaRow(row as (typeof rota)[number], attrs);
  },
};

Common questions

How do I show an htmx calendar from server data?

Either wrap a server-rendered table in a data-lattice-calendar element, or bind the calendar to a table grid with data-lattice-bind. Each row becomes an event and each th data-field a field. The demo above shows both forms, and its markup is printed under it.

How do I save a dragged event with htmx?

The calendar re-raises every move or resize as a lattice:calendar-change event with the event id and the new start and end, so hx-trigger can post it. Drag a card in the demo and read the server's one-line confirmation.

Can the server move one event without redrawing the calendar?

Yes: an out-of-band tr whose data-lattice-row names the event moves its card in place, in a bound calendar and in one built from a table. The "Server moves one booking" and "Server moves one shift" buttons in the demo do exactly that.

What happens to the calendar when htmx swaps its content?

The module tears the calendar down before htmx detaches it and builds the new one from the fragment that arrives. Pick a team in the demo to see both calendars rebuilt.

Does the htmx calendar keep its view when I press Back?

Yes: the team and the month or week view are in the markup the server sends, so the history snapshot carries them and both calendars are rebuilt on Back, the bound one still reading its grid. Change the team and the view in the demo, then press Back.