https://alchemists.io/articles/htmx_infinite_scroll The letter A styled as Alchemists logo. lchemists [ ] * Articles * Projects * Talks * Books * Services * Hire! * Articles * htmx Infinite Scroll * Previous * Next Avatar of Brooke wrapped in a grey scarf and wearing a black jacket with a red zipper against a slightly blurred background of fall colors of mainly orange leaves. Brooke Kuhlmann Published March 1, 2026 Updated March 1, 2026 Cover htmx Infinite Scroll Pagination is always a feature you have to tackle in web application development in one form or another. A common approach is to use a gem, like Pagy, to provide the following capabilities: * Load records in batches (a.k.a. pages). * Previous and next links. * Numbered links to specific pages. * Bookmarkable links complete with browser history. Definitely useful to decrease server load since you're only loading a small subset of records per request. The problem with this approach is: * Most people bail after reading through the first couple of pages and not finding what they want. * Includes a lot visual information, design-wise, that not only consumes real-estate within your UI but is seldom utilized. We can do better. With infinite scrolling, we can provide a better user experience through a killer combination of search coupled with endless scroll. This allows folks to: * Endlessly scroll until they find what they want or bail when coming up empty after a page or two. * Search plus scroll to further refine the results. Let's explore building a gallery of photos for browsing and searching by using an API client to obtain images from a remote resource. We'll do this by using htmx to provide an infinite scrolling experience. Table of Contents * Setup * Implementation + Action + View + Template * Conclusion Setup To implement this functionality, add the htmx library and the HTMX gem to your stack. Both are compatible with whatever Ruby web framework you decide to use. Additionally, the htmx team has an example if you need a visual primer. When you inspect the example's DOM, you'll see the following: Agent Smith void29@null.org A0GG98CEAC This breaks down as follows: * hx-get: Contains the HTTP request for the next page of information. * hx-trigger: Triggers the GET request when the element is revealed in the viewport. * hx-swap: Describes swap behavior. In this case, new content from the HTTP response will be swapped in after the target element (i.e. the tr you see above). That's a lot of power for only a few lines of HTML! Implementation Our implementation will consist of a Hanami action, view, and template. For htmx, you can load either via a CDN, Node package, or an import map. The following sections will focus on the Ruby code. Action Now that you have an idea of the desired functionality on the frontend, let's discuss how to implement this on the backend. For demonstration purposes, we'll use a Hanami Actions index action. If using a different framework, the Ruby code should be trivial to adapt for your own implementation. Here's the entire action: Implementation require "initable" module Demo module Actions module Gallery class Index < Hanami::Action include Deps[:htmx, :client] include Initable[model: Demo::Models::Photo] params do optional(:query).filled :string optional(:page).filled :integer end def handle request, response parameters = request.params load(parameters).either -> photo { render request, photo, response }, -> message { render_error parameters, message, response } end private def load parameters case parameters in query:, page: then client.photos(search: query, page:) in query: then client.photos search: query in page: then client.photos(page:) else client.photos end end def render request, photo, response query, page = request.params.to_h.values_at :query, :page if htmx.request(**request.env).request? htmx.response! response.headers, push_url: routes.path(:gallery, query:, page:) response.render view, photo:, query:, page:, layout: false else response.render view, photo:, query:, page: end end def render_error parameters, message, response response.flash.now[:alert] = message response.render view, photo: model.empty, **parameters.to_h.slice(:query, :page) end end end end end Let's explore each section/method of the action since this is worth explaining further. First, we start by injecting the htmx, API client, and model dependencies. We'll use an API client to scroll through photos which has pagination built-in. We don't need to understand the client implementation, only the interface. Here's our dependencies: include Deps[:htmx, :client] include Initable[model: Demo::Models::Photo] The Initable gem is used to inject the model dependency without having to write an initializer since we only need access to the constant (we'll make use of the model, later, when rendering errors). Due to our index action being a HTTP GET request we can allow the following optional parameters and type casting: params do optional(:query).filled :string optional(:page).filled :integer end The query parameter allows us to search for specific records while the page parameter limits the search results by page. Now we can handle the request and response: def handle request, response parameters = request.params load(parameters).either -> photo { render request, photo, response }, -> message { render_error parameters, message, response } end The API client answers a monad (i.e. Dry Monads), so we'll either have a Success or Failure which is an elegant way to handle the inevitable fork in the road when making a HTTP request that may pass or fail. Next, we can leverage the power of Ruby Pattern Matching to determine what request we'll make of our API client ([?] All methods are private from this point forward): def load parameters case parameters in query:, page: then client.photos(search: query, page:) in query: then client.photos search: query in page: then client.photos(page:) else client.photos end end Notice how pattern matching makes short work of how we construct our API request based on what parameters are supplied. The result of any of these requests will answer a monad that is either a Success or Failure. If you refer to the #handle method again, this means we'll either render the HTML response or an error as implemented via these private methods. Example: def render request, photo, response query, page = request.params.to_h.values_at :query, :page if htmx.request(**request.env).request? htmx.response! response.headers, push_url: routes.path(:gallery, query:, page:) response.render view, photo:, query:, page:, layout: false else response.render view, photo:, query:, page: end end def render_error parameters, message, response response.flash.now[:alert] = message response.render view, photo: model.empty, **parameters.to_h.slice(:query, :page) end This flow breaks down as follows: * Success + If a htmx request, add the appropriate headers (especially the push_url so browser history is preserved) and render without any layout information because you only want the HTML fragment -- not the entire page -- in order to keep your response as small as possible. + Otherwise, when not a htmx request, render the full page as you'd normally do. * Failure + Render the page with a flash message as to what went wrong along with an empty photo (i.e. no results found). This is made possible by the model dependency we injected earlier by sending the .empty message which answers an empty record which leverages the power of the Null Object Pattern. View For the view, we only need to expose the photo, query, and page to the template. This is done as follows: module Demo module Views module Gallery class Index < Hanami::View expose :photo expose :query, decorate: false expose :page, decorate: false end end end end By default, Hanami will decorate all parts but for the query and page we only need the primitives. Template For the template, we only need to iterate over the photos and render each. Only when there are no photos do we need to display that none were found as shown below: <% if photo.data.any? %> <% else %>
<%= render "shared/empty", message: "No photos found." %>
<% end %> With the above we leverage the HTMX gem to build the htmx attributes for us. Additionally, use of the htmx select, swap attributes allow you to only select card element in the HTTP response then immediately swap the current element (i.e. this) with the new element. This not only keeps your DOM clean but ensures you are always using list items because the HTML specification doesn't allow any other HTML element within a ordered or unordered list. To illustrate further, here's what the resulting HTML looks like: This means you can scroll down the page -- and when the li element with the htmx attributes is revealed -- a new HTTP request is made to replace that element and pull in new data ending with another li element with a hx-get request for the next page. You also get a nice "Loading... " message while waiting for the HTTP request to complete. For bonus points, you could swap out the text message with a CSS loader class to enhance the user experience further. Conclusion Reducing the use of JavaScript within your application is a good in terms speed, reduced page size, and reduced complexity in your application. With htmx you are able to focus on HTML which is much easier to write and test. More importantly, you get all of the benefits of pagination, browser history, and a cleaner user interface. Enjoy! * (c) 2026 Alchemists LLC + A white and orange syndication icon. Syndication + A white and blue Mastodon icon. Social Media + A white and blue email icon. Email * Culture + Oath + Rigor + Values + Mission + Community * Policies + Code of Conduct + Contributions + Developer Certificate of Origin + License + Security * About + Team + Stack + Location + History