Skip to content

Datagrid infinite scroll

The datagrid-pager sibling for feeds and long lists: the tbody’s last row is a sentinel — scrolled into view it GETs the next batch and outerHTML-replaces itself with the new rows plus the next sentinel, until the end-of-list marker retires it. The cursor is the last row’s id (after=), never a page number: append-only lists shift under offset paging; ids do not. Zero new JavaScript. Covered by the versioning policy.

Scroll inside the grid — each time the loading row enters the container’s view it swaps itself for the next 5 rows (of 15) and a fresh sentinel, and the list closes with the “15 of 15” marker.

This demo is the container-scrolled variant (intersect once root:…, see the carve-out below): a 15-row demo with the default window-viewport revealed would chain-load the whole list before you could scroll, because on a tall screen every fresh sentinel is already in view. A real page-scrolled feed — the shape the markup below shows — keeps revealed. The initial rows are a static-docs-site carve-out too: the demo fetches its first batch with a load trigger, where a real server renders page 1 inline.

IDProductPriceStock

Uncap the grid first: --hc-datagrid-max-height: none on the hc-datagrid root, so the page is the scroller. The default cap (70vh) turns the grid into its own overflow container — and revealed is a window-viewport trigger, so a sentinel that overflows inside the grid never fires again: the feed deadlocks after the first batch. Grids that must keep their own scrollbar use the intersect variant below instead.

<div class="hc-datagrid" style="--hc-datagrid-max-height: none">
<div class="hc-datagrid__scroll">
<table class="hc-datagrid__table">
<thead class="hc-datagrid__head"></thead>
<tbody class="hc-datagrid__body">
<!-- Page 1 server-rendered — the grid is full without JS. -->
<tr class="hc-datagrid__row">
<th class="hc-datagrid__cell" scope="row">item-1</th>
<td class="hc-datagrid__cell">Compact Anvil</td>
<td class="hc-datagrid__cell">$107</td>
<td class="hc-datagrid__cell">12</td>
</tr>
<!-- …rows item-2 … item-5… -->
<!-- The sentinel: the last row, carrying the cursor. -->
<tr class="hc-datagrid__row"
data-hx-get="/items?after=item-5"
data-hx-trigger="revealed"
data-hx-swap="outerHTML">
<td class="hc-datagrid__cell" colspan="4" aria-live="polite">
<span class="hc-spinner" aria-hidden="true"></span> Loading…
</td>
</tr>
</tbody>
</table>
</div>
</div>

The outerHTML swap is the whole trick: each response either renews the sentinel (batch + a fresh sentinel with the next cursor) or retires it (batch + end marker), so exactly one loading row ever exists. The endpoint answers just the rows; a server that can only render full pages adds data-hx-select="tbody > tr" to carve them out.

revealed measures the window viewport. A grid that keeps its own scrollbar (the default --hc-datagrid-max-height: 70vh, or any overflow-y: scroll wrapper) needs the container-aware trigger instead — everything else in the contract is unchanged:

<tr class="hc-datagrid__row"
data-hx-get="/items?after=item-5"
data-hx-trigger="intersect once root:#feed-scroll threshold:0.5"
data-hx-swap="outerHTML">
</tr>

root: takes a selector for the scroll container (.hc-datagrid__scroll usually — give it an id); once retires the observer with the sentinel it belongs to, and threshold: decides how much of the row must show before the next batch loads. The server must echo the same trigger on every renewed sentinel — thread the root through the cursor URL or template it in (the docs demo templates it in).

Two failure modes this avoids:

  • Deadlock — a revealed sentinel that overflows inside the grid never enters the window viewport, so the feed stops after batch one.
  • Chain-load — on a tall window a short list’s fresh sentinel is already visible, so every batch fires immediately and the “infinite” list arrives complete. (This is what the live demo above avoids; it is also why a real feed of hundreds of rows behaves fine with plain revealed.)

GET /items?after=<cursor>:

CaseResponse (200 — always)
more rows existthe next <tr> batch plus a new sentinel row carrying the next cursor
end of listthe batch (possibly empty) with no sentinel, closed by the end-of-list row: <td colspan aria-live="polite">40 of 40</td>
stale cursorthe batch from the nearest stable point — cursors are resumable, never 4xx; scrolling is not an error

Batch rows mirror the initial rows (same classes, scope="row" id header), so swapped rows are indistinguishable from the server-rendered page 1.

Page 1 is server-rendered, so the list is useful as delivered; the sentinel is an inert loading row without JavaScript. Offer a plain “more” link (<a href="/items?after=…">) if the full set must stay reachable — the same endpoint serves the fragment or a full page via the HX-Request branch.

  • The sentinel/end cell is one aria-live="polite" slot: “Loading…” and the final “40 of 40” are announced without stealing focus.
  • The spinner is aria-hidden="true" — the announced text carries the meaning.
  • Rows stay real <tr>s in one <tbody> of a real <table> — assistive tech sees one growing table, never a stack of tables.
  • datagrid-pager — numbered paging, for when users must address a page (jump, share, resume).
  • lazy-panel — the intersect once cousin, and the trigger to reach for inside overflow containers.
  • Datagrid — the component’s CSS API the rows render into.