Skip to content

Datagrid pager

hc-datagrid is built for paged data: the server owns the data window, htmx swaps one page of rows, and installDatagrid() re-initialises the swapped rows. This recipe wires it to an hc-pagination pager.

Page through 5,000 fake products — each pager link swaps just its page’s rows into the <tbody> (innerHTML), while the pager and the status line arrive out-of-band in the same response. The endpoint is a namespaced demo implementation of the server response contract under api/recipes/datagrid-pager/.

IDNameUnit price

Loading…

Render page 1 server-side, in the initial page — the grid is full before any JavaScript runs. Make the grid’s <tbody> the swap target and swap innerHTML (the rows inside it): each pager link loads its page into the same <tbody>, and its href="?page=N" stays the no-JS path:

<div class="hc-datagrid">
<div class="hc-datagrid__scroll">
<table class="hc-datagrid__table">
<thead class="hc-datagrid__head">
<tr>
<th class="hc-datagrid__headcell" data-frozen data-frozen-edge scope="col">ID</th>
<th class="hc-datagrid__headcell" scope="col">Name</th>
<th class="hc-datagrid__headcell" scope="col">Price</th>
</tr>
</thead>
<tbody class="hc-datagrid__body" id="rows">
<!-- Page 1's rows, rendered by the server: -->
<tr class="hc-datagrid__row">
<th class="hc-datagrid__cell" data-frozen data-frozen-edge scope="row">1</th>
<td class="hc-datagrid__cell">Chai</td>
<td class="hc-datagrid__cell">$18.00</td>
</tr>
<!-- … -->
</tbody>
</table>
</div>
<nav class="hc-pagination" id="pager" aria-label="Pagination">
<a class="hc-pagination__item" aria-current="page"
data-hx-get="/products?page=1" data-hx-target="#rows" data-hx-swap="innerHTML" href="?page=1">1</a>
<a class="hc-pagination__item"
data-hx-get="/products?page=2" data-hx-target="#rows" data-hx-swap="innerHTML" href="?page=2">2</a>
</nav>
</div>

installDatagrid() watches the <tbody> element for child changes. Swapping the rows inside it (innerHTML) keeps that element, so the observer fires and the grid re-applies its ARIA roles, sticky-offset measurement, and any resized column widths to the freshly-loaded rows. Replacing the whole <tbody> (outerHTML) would discard the observed node — so target #rows and swap innerHTML.

You therefore don’t recompute layout on the server: render each row with the same column structure as the header (data-frozen / data-frozen-edge on frozen cells, data-col on resizable / editable columns) and the frozen --hc-datagrid-left offsets and resized widths are re-applied automatically.

GET /products?page=N&size=100 returns only the page’s rows:

<tr class="hc-datagrid__row">
<th class="hc-datagrid__cell" data-frozen data-frozen-edge scope="row">101</th>
<td class="hc-datagrid__cell">Chai</td>
<td class="hc-datagrid__cell">$18.00</td>
</tr>
<!-- …one <tr> per row in the page… -->

Update the pager and a status line in the same response, out-of-band, so they refresh without a second request:

<nav class="hc-pagination" id="pager" data-hx-swap-oob="true" aria-label="Pagination">
…items, with aria-current="page" on the active page…
</nav>
<p id="rows-status" data-hx-swap-oob="true" aria-live="polite">101–200 / 5,000</p>

Mark the current page with aria-current="page"; disable Prev / Next at the ends with aria-disabled="true".

RequestResponse
GET /products?page=2&size=100 (htmx, HX-Request: true)200 + the page’s <tr> rows + OOB pager / status fragments
GET /products?page=2 (no HX-Request — the href fallback)200 + the full page with page 2’s rows server-rendered
GET /products?page=2 (failure)non-2xx — not swapped; the current rows and pager stay

htmx ≥ 2 does not swap non-2xx responses, so a failed page load leaves the current rows and pager in place. Surface the failure with an HX-Trigger: {"hc:toast":{…}} header on the error response (see the toast recipe).

  • Page 1 is server-rendered, so the grid is complete without any JavaScript — there is no client fetch for the initial rows.
  • Every pager link keeps a real href="?page=N". Without htmx it performs a normal full-page navigation to that page.
  • The server branches on the HX-Request header: with it, return the rows fragment plus the OOB pager / status; without it, render the full page with that page’s rows in the <tbody>.
  • Focus. Swapping rows removes the previously active cell; the grid keeps a tabbable cell but does not move focus. Restore focus from the server (e.g. an out-of-band focus target) if your flow needs it.
  • Selection is per page unless the server re-renders selected rows with aria-selected="true" — server-track the selection to keep it across pages.
  • Multi-row records. This targets the standard one-<tbody> rows layout. For multi-row records swap a wrapping region instead and let the document observer re-initialise.