Skip to main content
Technology & EngineeringHtmx207 lines

Htmx Infinite Scroll

Infinite scroll and lazy loading patterns using HTMX revealed and intersect triggers

Quick Summary15 lines
You are an expert in implementing infinite scroll, pagination, and lazy loading patterns using HTMX's event-driven attributes.

## Key Points

- `intersect once` — fires only the first time the element enters the viewport
- `intersect threshold:0.5` — fires when 50% of the element is visible
- `intersect root:#scroll-container` — observes within a specific scrollable container
- **Always provide an end condition.** When the server has no more data, return the final batch without a sentinel element. This cleanly terminates the infinite scroll.
- **Include loading indicators.** Use `hx-indicator` to show a spinner or skeleton below the content while the next batch loads. This provides clear feedback to the user.
- **Keep page sizes reasonable.** 20-50 items per page balances network efficiency with rendering performance. Too many items per request slow down DOM insertion.
- **Use `hx-select` when the response wrapper differs from the target structure.** For table rows, `hx-select="tbody > tr"` strips the wrapping `<tbody>` the server may include.
- **Support direct URL access.** Combine infinite scroll with `?page=N` query parameters so users can bookmark or share specific positions. On initial load, render all items up to the requested page.
- **Accessibility concerns.** Screen readers may not announce dynamically loaded content. Use `aria-live="polite"` on the container or announce new content via an ARIA live region.
skilldb get htmx-skills/Htmx Infinite ScrollFull skill: 207 lines
Paste into your CLAUDE.md or agent config

Infinite Scroll and Lazy Loading — HTMX

You are an expert in implementing infinite scroll, pagination, and lazy loading patterns using HTMX's event-driven attributes.

Overview

HTMX makes infinite scrolling and lazy loading straightforward by leveraging the revealed and intersect triggers combined with standard swap strategies. The server returns the next batch of content as an HTML fragment, and the last item in each batch contains the trigger for loading the next page. No JavaScript scroll listeners or Intersection Observer boilerplate is needed.

Core Concepts

The revealed Trigger

hx-trigger="revealed" fires when an element first scrolls into the viewport. HTMX uses an Intersection Observer internally. This is the simplest way to implement lazy loading.

The intersect Trigger

hx-trigger="intersect" provides more control via modifiers:

  • intersect once — fires only the first time the element enters the viewport
  • intersect threshold:0.5 — fires when 50% of the element is visible
  • intersect root:#scroll-container — observes within a specific scrollable container

Sentinel Pattern

The "sentinel" or "trigger row" pattern places the loading trigger on the last element of the current batch. When that element scrolls into view, HTMX requests the next page and appends the results. The new batch includes its own sentinel, creating a self-sustaining chain.

Implementation Patterns

Basic Infinite Scroll

Initial page load renders the first batch plus a sentinel:

<div id="item-list">
  <div class="item">Item 1</div>
  <div class="item">Item 2</div>
  <!-- ... items 3-19 ... -->
  <div class="item"
       hx-get="/items?page=2"
       hx-trigger="revealed"
       hx-swap="afterend"
       hx-indicator="#load-more-spinner">
    Item 20
  </div>
</div>
<span id="load-more-spinner" class="htmx-indicator">Loading more...</span>

Server response for /items?page=2:

<div class="item">Item 21</div>
<div class="item">Item 22</div>
<!-- ... items 23-39 ... -->
<div class="item"
     hx-get="/items?page=3"
     hx-trigger="revealed"
     hx-swap="afterend">
  Item 40
</div>

When there are no more items, the server returns the final batch without a sentinel, naturally ending the scroll chain.

Table-Based Infinite Scroll

<table>
  <thead>
    <tr><th>Name</th><th>Email</th></tr>
  </thead>
  <tbody id="user-rows">
    <tr><td>Alice</td><td>alice@example.com</td></tr>
    <!-- ... more rows ... -->
    <tr hx-get="/users?page=2"
        hx-trigger="revealed"
        hx-swap="afterend"
        hx-select="tbody > tr">
      <td>Bob</td><td>bob@example.com</td>
    </tr>
  </tbody>
</table>

Lazy-Loaded Content Sections

<div hx-get="/dashboard/chart"
     hx-trigger="revealed"
     hx-swap="innerHTML">
  <div class="skeleton-placeholder" aria-busy="true">
    Loading chart...
  </div>
</div>

<div hx-get="/dashboard/activity-feed"
     hx-trigger="revealed"
     hx-swap="innerHTML">
  <div class="skeleton-placeholder" aria-busy="true">
    Loading activity...
  </div>
</div>

Lazy-Loaded Images

<div hx-get="/images/42/full"
     hx-trigger="intersect once threshold:0.1"
     hx-swap="innerHTML">
  <img src="/images/42/thumbnail-blur.jpg" alt="Photo 42"
       style="filter: blur(10px); width: 400px; height: 300px;">
</div>

The server returns the full-resolution <img> tag, replacing the blurred thumbnail.

Click-to-Load (Manual Pagination)

For users who prefer explicit control over loading:

<div id="item-list">
  <div class="item">Item 1</div>
  <!-- ... items ... -->
  <div class="item">Item 20</div>
</div>
<button hx-get="/items?page=2"
        hx-target="#item-list"
        hx-swap="beforeend"
        hx-indicator="#load-spinner">
  Load More
</button>
<span id="load-spinner" class="htmx-indicator">Loading...</span>

To update the "Load More" button with the next page link, use out-of-band swaps:

Server response:

<div class="item">Item 21</div>
<!-- ... more items ... -->
<div class="item">Item 40</div>
<button hx-get="/items?page=3"
        hx-target="#item-list"
        hx-swap="beforeend"
        hx-swap-oob="true"
        id="load-more-btn">
  Load More
</button>

Infinite Scroll in a Scrollable Container

<div id="chat-messages" style="height: 400px; overflow-y: auto;">
  <div hx-get="/messages?before=oldest-id"
       hx-trigger="intersect once root:#chat-messages threshold:0"
       hx-swap="afterend">
    <span class="htmx-indicator">Loading older messages...</span>
  </div>
  <!-- messages rendered here -->
</div>

Core Philosophy

Infinite scroll with HTMX is built on the sentinel pattern: a self-sustaining chain where each batch of content includes a trigger for loading the next batch. This eliminates the need for client-side scroll listeners, Intersection Observer boilerplate, or state management for pagination. The server drives the sequence by including or omitting the next sentinel, making the scroll behavior fully controllable from the backend.

The design treats the server as the authority on pagination state. The client does not track page numbers, offsets, or cursors. Instead, each HTMX request carries the next page URL as a simple attribute, and the server returns the appropriate batch. When there is no more data, the server simply omits the sentinel element, and the scroll chain terminates naturally. This stateless client model means there is no pagination state to get out of sync.

Lazy loading follows the same principle but at a content-section level rather than a list level. Sections of a page that are expensive to render (charts, feeds, recommendation engines) load only when scrolled into view. The server returns the rendered HTML when the element becomes visible, and the placeholder is replaced. This gives you a fast initial page load without sacrificing content richness, and the implementation is a single HTMX attribute rather than a JavaScript loading framework.

Anti-Patterns

  • Accumulating thousands of DOM nodes without cleanup. Infinite scroll adds content endlessly, and the browser eventually struggles with memory and rendering performance. For very long lists, implement virtualization or periodically remove off-screen content.

  • Not providing an end condition. If the server always returns a sentinel, the scroll chain loops forever, eventually requesting empty pages. The server must omit the sentinel when no more data exists.

  • Using intersect without once for the sentinel. If the sentinel rapidly enters and leaves the viewport during fast scrolling, multiple duplicate requests fire. Use revealed (which inherently fires once) or intersect once to prevent this.

  • Breaking the back button by not updating the URL. Infinite scroll does not update the URL by default. Users who navigate away lose their position. Use hx-push-url or session storage to preserve scroll state.

  • Loading content above the current scroll position without preserving scroll position. When loading older messages (scrolling up in a chat), inserting content above shifts the viewport. You must manually preserve the scroll position using an htmx:afterSwap handler.

Best Practices

  • Always provide an end condition. When the server has no more data, return the final batch without a sentinel element. This cleanly terminates the infinite scroll.
  • Use revealed for simple cases, intersect for fine control. The revealed trigger is sufficient for most infinite scroll implementations. Use intersect when you need threshold control or a custom scroll root.
  • Include loading indicators. Use hx-indicator to show a spinner or skeleton below the content while the next batch loads. This provides clear feedback to the user.
  • Keep page sizes reasonable. 20-50 items per page balances network efficiency with rendering performance. Too many items per request slow down DOM insertion.
  • Use hx-select when the response wrapper differs from the target structure. For table rows, hx-select="tbody > tr" strips the wrapping <tbody> the server may include.
  • Support direct URL access. Combine infinite scroll with ?page=N query parameters so users can bookmark or share specific positions. On initial load, render all items up to the requested page.

Common Pitfalls

  • Duplicate requests on fast scroll. If the sentinel enters and leaves the viewport rapidly, multiple requests can fire. Use intersect once or revealed (which inherently fires once) to prevent this.
  • Scroll position jumps after content insertion. When inserting content above the current scroll position (e.g., loading older chat messages), the browser may jump. Manually preserve scroll position with an htmx:afterSwap event listener.
  • Memory exhaustion on very long lists. Infinite scroll accumulates DOM nodes. For extremely long lists (thousands of items), consider a virtualized approach or periodically removing off-screen content.
  • Broken back-button behavior. Infinite scroll does not update the URL by default. Users who navigate away lose their scroll position. Consider hx-push-url or storing the scroll offset in session storage.
  • Accessibility concerns. Screen readers may not announce dynamically loaded content. Use aria-live="polite" on the container or announce new content via an ARIA live region.

Install this skill directly: skilldb add htmx-skills

Get CLI access →