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.
Live demo
Section titled “Live demo”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/.
| ID | Name | Unit price |
|---|
Loading…
Basic usage
Section titled “Basic usage”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>Why innerHTML, not outerHTML
Section titled “Why innerHTML, not outerHTML”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.
Server response
Section titled “Server response”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".
| Request | Response |
|---|---|
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 |
On failure
Section titled “On failure”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).
Progressive enhancement
Section titled “Progressive enhancement”- 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-Requestheader: 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.
Related
Section titled “Related”- Datagrid — the grid itself.
- Pagination — the pager component.
- Data region — the general server-rendered-region pattern.