Back

Building Infinite Scroll with HTMX

Building Infinite Scroll with HTMX

Loading content as users scroll eliminates pagination clicks and creates smoother browsing experiences. But implementing infinite scroll traditionally requires managing intersection observers, tracking state, and writing considerable JavaScript. HTMX offers a simpler approach: server-driven infinite scroll using HTML attributes.

This guide covers the canonical HTMX infinite scroll pattern, explains when to use revealed versus intersect, and shows how to build a progressively enhanced implementation that works without JavaScript.

Key Takeaways

  • HTMX infinite scroll uses a self-perpetuating loader element pattern where each server response includes the next loader
  • Use revealed for full-page scrolling and intersect once for scrollable containers with overflow
  • Build on standard pagination links for progressive enhancement that works without JavaScript
  • Server controls all pagination state through the loader element in each response

How Server-Driven Infinite Scroll Works

The core pattern places HTMX attributes on a “loader” element—typically the last item in your list. When this element becomes visible, HTMX requests the next page from your server. The server returns HTML containing both the new items and a new loader element pointing to the subsequent page.

Here’s the basic structure:

<div id="items">
  <div class="item">First item</div>
  <div class="item">Second item</div>

  <!-- Last rendered item doubles as the loader -->
  <div class="item"
       hx-get="/items?page=2"
       hx-trigger="revealed"
       hx-swap="afterend">
    Last item of page 1
  </div>
</div>

The server response for page 2 includes items and a new loader:

<div class="item">First item of page 2</div>
<div class="item">Second item of page 2</div>

<div class="item"
     hx-get="/items?page=3"
     hx-trigger="revealed"
     hx-swap="afterend">
  Last item of page 2
</div>

This self-perpetuating pattern continues until the server returns content without a loader, signaling the end of available data.

HTMX Intersect vs Revealed: Choosing the Right Trigger

HTMX provides two visibility-based triggers, and selecting the wrong one causes common bugs.

Use revealed for full-page scrolling where the document itself scrolls. This trigger fires when an element enters the browser viewport.

Use intersect once when content lives inside a scrollable container with CSS like overflow-y: scroll. The revealed trigger watches the document viewport, not individual scrollable elements, so it won’t fire correctly in overflow containers. The intersect trigger relies on the modern Intersection Observer API, which is supported in all major browsers.

<!-- Full-page scroll: use revealed -->
<div hx-trigger="revealed" hx-get="/more">...</div>

<!-- Scrollable container: use intersect once -->
<div class="scroll-container" style="overflow-y: scroll; height: 400px;">
  <div hx-trigger="intersect once" hx-get="/more">...</div>
</div>

The once modifier prevents duplicate requests if the element repeatedly enters and exits the intersection threshold.

Swap Strategies: afterend vs beforeend

Your swap strategy determines where new content appears and how to prevent duplicate loads.

afterend inserts content immediately after the loader element. Use this when the loader is the last rendered item—the response appears below it.

beforeend appends content inside a target container. Combine this with hx-target to specify where items go:

<div hx-get="/items?page=2"
     hx-trigger="revealed"
     hx-target="#items"
     hx-swap="beforeend">
  Loading indicator...
</div>

With beforeend, your loader typically sits outside the item container and gets replaced or removed after the final page loads.

Progressive Enhancement with Standard Pagination

Infinite scrolling with HTMX should build upon existing pagination, not replace it entirely. Start with working pagination links:

<div id="items">
  {% for item in items %}
    <div class="item">{{ item.name }}</div>
  {% endfor %}
</div>

<a href="/items?page={{ next_page }}">Load more</a>

Then upgrade the pagination link with HTMX attributes:

<a href="/items?page={{ next_page }}"
   hx-get="/items?page={{ next_page }}"
   hx-trigger="revealed"
   hx-target="#items"
   hx-swap="beforeend"
   hx-select="#items > *">
  Load more
</a>

Users without JavaScript see standard pagination. Users with HTMX get infinite scroll. The server endpoint remains identical for both.

Updating UI Elements with Out-of-Band Swaps

Sometimes you need to update elements outside the main content area—like item counters or loading states. HTMX out-of-band swaps handle this:

<!-- In server response -->
<div class="item">New item</div>
<span id="item-count" hx-swap-oob="outerHTML">Showing 20 of 100</span>

The hx-swap-oob="outerHTML" attribute tells HTMX to find and replace the element with matching ID anywhere on the page, regardless of the main swap target.

Server-Side Pagination Strategies

Your server controls pagination state entirely. Two common approaches:

Offset pagination uses page numbers or offset values: /items?page=3 or /items?offset=20. Simple to implement but can show duplicates if items are added during browsing.

Cursor pagination uses a pointer to the last seen item: /items?after=item_xyz. More reliable for dynamic content but requires stable identifiers.

Either approach works with HTMX—the client simply passes whatever parameter the server provides in the next loader element.

Conclusion

HTMX infinite scroll moves complexity to the server where pagination logic already exists. Choose revealed for document scrolling and intersect once for overflow containers. Build on standard pagination so your feature degrades gracefully. Let the server drive state through each response’s loader element rather than tracking pages client-side.

FAQs

This usually happens when using intersect without the once modifier. The element may repeatedly cross the intersection threshold as content loads and shifts layout. Add once to your trigger like hx-trigger intersect once to ensure only a single request fires per loader element.

Add an hx-indicator attribute pointing to your loading element. For example hx-indicator with a value of hash loading on your loader element and a separate span with id loading containing your spinner. HTMX automatically shows this element during requests and hides it when complete.

HTMX helps prevent overlapping requests, and each loader element is typically configured to trigger once. The server response determines the next page. If you need stricter coordination you can use hx-sync to control request behavior.

Yes. When filters change reset your content container and update the loader URL to include filter parameters. The server handles filtering logic and returns the appropriate first page. Each subsequent loader includes the same filter parameters to maintain consistency across pages.

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay