Lazy panel
A lazy panel is a region whose content is not fetched at initial
page render. It loads the first time the panel becomes relevant —
when an <details> opens, when a tab activates, or when the panel
scrolls into view. The intersection and <details> variants are pure
htmx attributes; the tab variant pairs with the
Tabs behavior’s
hc:tabactivated event.
Live demo
Section titled “Live demo”All four variants against a real demo endpoint under
api/recipes/lazy-panel/ (your app would use its own URLs). Each card
reports the UTC time it loaded — proof the fetch was deferred, and
once means re-collapsing or re-activating never refetches. The last
button exercises the contract’s 503 error branch: htmx 2 does not swap
5xx responses by default (and HX-Reswap alone does not override
that), so on this page the visible reaction is the HX-Trigger error
toast; configure htmx.config.responseHandling if you want the alert
body swapped into the slot.
On intersection
On <details> open
Advanced settings
On tab activation
Error branch (503)
On intersection (in-viewport)
Section titled “On intersection (in-viewport)”The simplest trigger: load when the user scrolls the panel into view.
<section data-hx-get="/dashboards/usage" data-hx-trigger="intersect once" data-hx-swap="innerHTML"> <p class="hc-field__message">Loading when visible…</p></section>intersect onceuses IntersectionObserver under the hood.- Add
intersect once threshold:0.25to wait until the panel is 25% visible.
On <details> open (accordion)
Section titled “On <details> open (accordion)”Inside a native <details> element, listen for the toggle event:
<details> <summary>Advanced settings</summary> <div data-hx-get="/settings/advanced" data-hx-trigger="toggle from:closest details once" data-hx-swap="innerHTML"> <p class="hc-field__message">Loading…</p> </div></details>from:closest detailslistens for the event on the ancestor<details>element.onceensures the panel never re-fetches after the first open.- The user can collapse and re-expand without further requests.
For an explicit refresh button inside the panel, add a separate button with its own htmx trigger.
On tab activation
Section titled “On tab activation”Use hc-tabs — its behavior
dispatches hc:tabactivated on a panel each time it becomes active, so
the lazy trigger is just that event, once. Apply the lazy pattern to
the tab panel, not the tab control:
<div class="hc-tabs"> <div class="hc-tabs__list" role="tablist" aria-label="Reports"> <button type="button" class="hc-tabs__tab" role="tab" id="tab-overview" aria-controls="panel-overview" aria-selected="true" tabindex="0">Overview</button> <button type="button" class="hc-tabs__tab" role="tab" id="tab-revenue" aria-controls="panel-revenue" aria-selected="false" tabindex="-1">Revenue</button> </div>
<div class="hc-tabs__panel" role="tabpanel" id="panel-overview" aria-labelledby="tab-overview" tabindex="0" data-hx-get="/reports/overview" data-hx-trigger="load" data-hx-swap="innerHTML"> <p class="hc-field__message">Loading…</p> </div> <div class="hc-tabs__panel" role="tabpanel" id="panel-revenue" aria-labelledby="tab-revenue" tabindex="0" hidden="until-found" data-hx-get="/reports/revenue" data-hx-trigger="hc:tabactivated once" data-hx-swap="innerHTML"> <p class="hc-field__message">Loading…</p> </div></div>The visible panel loads on load; each hidden panel waits for its
first hc:tabactivated. The tabs behavior (installTabs(), in the
auto-init bundle) owns the aria-selected / hidden flipping and the
keyboard navigation — see
Tabs → htmx usage
for the same pattern from the component side.
Indicators
Section titled “Indicators”Lazy panels almost always benefit from a visible loading indicator because the user actively triggered the fetch.
<section data-hx-get="/reports/revenue" data-hx-trigger="intersect once" data-hx-swap="innerHTML" data-hx-indicator="this"> <div class="hc-action"> <p class="hc-field__message">Loading…</p> <span class="hc-spinner htmx-indicator" aria-hidden="true"></span> </div></section>The data-hx-indicator="this" self-target keeps the spinner visible
until the swap settles. Once the response lands, the placeholder
content (including the spinner) is replaced.
Server response contract
Section titled “Server response contract”The endpoint returns the inner HTML of the panel:
<!-- GET /reports/revenue --><dl class="hc-stack"> <div><dt>This month</dt><dd>$12,400</dd></div> <div><dt>Last month</dt><dd>$10,900</dd></div></dl>For an error state, return a 200 with an alert fragment (htmx swaps 2xx by default):
<div class="hc-alert" data-variant="error" role="alert"> <strong class="hc-alert__title">Could not load</strong> <p class="hc-alert__body">Try again in a moment.</p></div>| Request | Response |
|---|---|
GET /reports/revenue (first reveal) | 200 + the panel’s inner HTML (Cache-Control as usual) |
| Failure, keep the placeholder | 4xx/5xx — not swapped; the placeholder stays |
| Failure, show an error in the slot | non-2xx + HX-Reswap: innerHTML + the alert fragment (or 200 + the alert, as above) |
| Non-fatal warning | 200 + content + HX-Trigger: {"hc:toast":{…}} |
Accessibility
Section titled “Accessibility”- Always render a meaningful placeholder so the panel is announced
even before the fetch lands. An empty
<section>is invisible to AT. - For tab panels, keep
role="tabpanel"+aria-labelledbylinked to the tab even when the panel is empty. - For
<details>-based panels, the native<summary>already exposes the open/closed state. No ARIA needed. - Polling inside a hidden lazy panel is wasteful — gate refresh on
visibility (
from:closest details,intersect, etc.) to avoid fetching content the user never sees.
Progressive enhancement
Section titled “Progressive enhancement”- Without JavaScript, the panel renders only its placeholder. Pick a placeholder that is genuinely useful (a meaningful initial value, or a link to a full-page version).
<details>-based panels degrade especially well: the native open/close still works without htmx.
- Avoid
every Nsinside a lazy panel unless the user is actively looking at it. Combineintersect oncefor the first load with a separate event-driven refresh. - Don’t lazy-load above the fold. If the panel is visible at page load, the deferred fetch round-trip just adds latency.
- Cache responses. Lazy panel endpoints often return content that
changes slowly;
Cache-Control: max-age=60saves work.
Related
Section titled “Related”- Data region recipe — eager + auto-refreshing sibling.
- Card component — the usual visual wrapper for a lazy panel.