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.
Live demo
Section titled “Live demo”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.
| ID | Product | Price | Stock |
|---|
The markup
Section titled “The markup”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.
Container-scrolled grids
Section titled “Container-scrolled grids”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
revealedsentinel 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.)
Server response contract
Section titled “Server response contract”GET /items?after=<cursor>:
| Case | Response (200 — always) |
|---|---|
| more rows exist | the next <tr> batch plus a new sentinel row carrying the next cursor |
| end of list | the batch (possibly empty) with no sentinel, closed by the end-of-list row: <td colspan aria-live="polite">40 of 40</td> |
| stale cursor | the 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.
Progressive enhancement
Section titled “Progressive enhancement”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.
Accessibility
Section titled “Accessibility”- 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.
Related
Section titled “Related”- datagrid-pager — numbered paging, for when users must address a page (jump, share, resume).
- lazy-panel — the
intersect oncecousin, and the trigger to reach for inside overflow containers. - Datagrid — the component’s CSS API the rows render into.