Skip to content

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.

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

Loading when visible…

On <details> open

Advanced settings

On tab activation

Loading…

Error branch (503)

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 once uses IntersectionObserver under the hood.
  • Add intersect once threshold:0.25 to wait until the panel is 25% visible.

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 details listens for the event on the ancestor <details> element.
  • once ensures 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.

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.

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.

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>
RequestResponse
GET /reports/revenue (first reveal)200 + the panel’s inner HTML (Cache-Control as usual)
Failure, keep the placeholder4xx/5xx — not swapped; the placeholder stays
Failure, show an error in the slotnon-2xx + HX-Reswap: innerHTML + the alert fragment (or 200 + the alert, as above)
Non-fatal warning200 + content + HX-Trigger: {"hc:toast":{…}}
  • 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-labelledby linked 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.
  • 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 Ns inside a lazy panel unless the user is actively looking at it. Combine intersect once for 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=60 saves work.