Infinitely Scroll Images in LiveView

Image by Annie Ruygt

This is a post about how to build an infinite scrolling LiveView page that shows a list of photos. It can be used for lots more than photos, but pretty pictures are more fun to look at than a list of products. If you want to deploy your Phoenix LiveView app right now, then check out how to get started. You could be up and running in minutes.

We see plenty of examples around the web of infinite scrolling content. Phoenix LiveView gives us some nifty abilities to do this elegantly and smoothly without needing any frontend frameworks.

In this post we’re going to build a very simple infinite scroll page with LiveView, using TailwindCSS for the grid layout. Check out the Demo app and the final code named complete branch. The repo’s main branch is the starting point for this post if you want to follow along.

Special thanks to for the demo app as well as Pexels for our test photos.


You have a project that needs to show a grid layout of images. It should allow continuous scrolling and fetch more data as the user scrolls down.

It should look something like this:

Browsing around the web, you’ve seen lots of sites that use infinite scrolling. You know it can be done. Someone suggested using a JavaScript frontend library to build this feature. While that would work, you’d have to build out a paginating REST API, define how to serialize the data, and more to just to support the component.

You’re already using Phoenix and you think, “I’ll bet LiveView could do this well. I might even get it done faster while still delivering a smooth experience.”

Okay, you’re up for it. But now the question is…

How do we create an infinite scrolling page of images in a grid layout using LiveView?


There are at least 2 important parts in our solution:

  • We’ll use the browser’s Intersection Observer API for observing scroll events and phx-hook to link it up. We put an element that acts as a target at the bottom of the page, then it’ll trigger events.
  • DOM patches are done using phx-update="append". This adds images to the end instead of replacing items.

Following Along

If you’d like to follow along, clone the starter files and follow the instructions in the README and join me back here.

Route for our page

Using the starter project, we’ll replace the default route that’s being used by PageController:

# router.ex
  scope "/", InfiniteScrollWeb do
    pipe_through :browser

    live "/", HomeLive.Index, :index
    # get "/", PageController, :index

Create files for our LiveView and LiveComponent

Here’s what we’ll add inside infinite_scroll_web/ folder:

├── ...
├── live/
│   ├── components/
│   │   └── gallery_component.ex
│   └── home_live/
│       ├── index.ex
│       └── index.html.heex
└── ...

That’s 3 folders and 3 files. Based on what we see here:

  • live/ is where we save our LiveView/LiveComponent files. We instructed our router to point us to a LiveView page instead of a regular template.
  • components/ is where we save stackable elements, e.g. reusable modal.
  • home_live/ is where where we save our parent layout for the homepage (/), logic for loading the images as well as our state.

Code to add

Parent layout where our component lives:

<!-- infinite_scroll_web/live/home_live/index.html.heex -->
<section class="my-4">

Our data comes through the images={@images} attribute, while the @page assigns serves as our page number after every page load. That’s 2 states that our component needs.

This is a Live component. That means it’s a reusable function that contains our HEEx (html) template.

# infinite_scroll_web/live/components/gallery_component.ex
defmodule InfiniteScrollWeb.Components.GalleryComponent do
  use InfiniteScrollWeb, :live_component

  defp random_id, do: Enum.random(1..1_000_000)

  def render(assigns) do
        class="grid grid-cols-3 gap-2"
        <%= for image <- @images do %>
          <img id={"image-#{random_id()}"} src={image} />
        <% end %>
      <div id="infinite-scroll-marker" phx-hook="InfiniteScroll" data-page={@page}></div>

I want to draw special attention to the following parts:

  • phx-update="append" used for adding new images
  • phx-hook="InfiniteScroll" for detecting when we are at the bottom of the page and loading another set of images.
  • Notice that the div phx-hook="InfiniteScroll" is at the bottom, it acts as a target when we scroll at the bottom.
  • phx-update and phx-hook attributes needs an id attribute as well as each child elements that’s using state (e.g. @title, @image, etc.).

Initial state and event handle

The code below is where we have our initial state (since we don’t use Ecto) and a function (load-more) getting the next set of images. We’ll explain what they do below:

# infinite_scroll_web/live/home_live/index.ex
defmodule InfiniteScrollWeb.HomeLive.Index do
  use InfiniteScrollWeb, :live_view

  alias InfiniteScrollWeb.Components.GalleryComponent

  @impl true
  def mount(_params, _session, socket) do
      |> assign(page: 1),
      temporary_assigns: [images: []]

  @impl true
  def handle_event("load-more", _, %{assigns: assigns} = socket) do
    {:noreply, assign(socket, page: + 1) |> get_images()}

  defp get_images(%{assigns: %{page: page}} = socket) do
    |> assign(page: page)
    |> assign(images: images())

  defp images do
    url = ""
    query = "?auto=compress&cs=tinysrgbg&w=600"
      2880507 13046522 13076228 13350109 13302244 12883181
      12977343 13180599 12059441 6431576 10651558 5507243
      13386712 13290875 13392891 13156418 8581056 13330222
      10060916 8064098
    |> Enum.shuffle()

There are a few important points we should pay attention to here:

  • The mount/3 function is called for initial page load and to establish the live socket. The temporary_assigns: [images: []] tells us that the value will reset after every render.
  • handle_event/3 function is called from the JS file (hooks, client-side) that we will create later. The purpose of this function is to load our images.
  • Our list of images (data) lives in images/0 function.

Both mount/3 and handle_event/3 are callback functions that’s needed for our LiveView (and hook) to work.

If you wondered by our temporary_assigns didn’t call get_images/1, there’s a reason for that:

  • Assigns are stateful by default. Having to resend full list on every update is expensive!
  • Images that are saved in assigns are stored in memory (in our server) and holds it in the entire session. As your state grows, the performance of your app might be of concern here.
  • Also note that using temporary assigns reverts to the default value every update.
  • Based in the docs, mount/3 is invoked twice: once to do the initial page load and again to establish the live socket. If we add get_images/1 inside mount, you’ll notice it’ll render twice (for lack of a better term: double mounting). One way to mitigate this is to use connected?/1 to check initial socket load, something like this:
def mount(_params, _session, socket) do
  socket = assign(socket, page: 1)

  # on initial load it'll return false,
  # then true on the next.
  if connected?(socket) do

  {:ok, socket, temporary_assigns: [images: []]}

Prepare the hook

In order to load images, we need client hooks. Let’s add our infinite_scroll.js file:

// assets/js/infinite_scroll.js
export default InfiniteScroll = {
  page() {return;},
  loadMore(entries) {
    const target = entries[0];
    if (target.isIntersecting && this.pending == {
      this.pending = + 1;
      this.pushEvent("load-more", {});
  mounted() {
    this.pending =; = new IntersectionObserver(
      (entries) => this.loadMore(entries),
        root: null, // window by default
        rootMargin: "400px",
        threshold: 0.1,
  destroyed() {;
  updated() {
    this.pending =;

There are at least 5 interesting things to note:

  • page() function gets the value from data-page={@page} attribute from gallery_component.ex.
  • this.pushEvent("load-more", {}); calls our handle_event/3 function in index.ex.
  • The root, rootMargin, threshold options inside mounted() function tells us where to find the target viewport, margin and the percentage of the target’s visibility before we load the next set of images.
  • destroyed() detaches from the observed element and move on to the next one.
  • updated() updates the new value of the page number.

And now we’re [hook]ing

Finally, let’s import infinite_scroll.js file in assets/js/app.js and attach it in our live socket.

//  assets/js/app.js

import topbar from "../vendor/topbar"
import InfiniteScroll from "./infinite_scroll" // <-- import

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
  hooks: {InfiniteScroll}, // <-- add the hook!
  params: {_csrf_token: csrfToken}

We did it! We created an infinite scrolling LiveView! The JavaScript hook linked a browser feature to our LiveView in fewer than 30 lines of JS code.

Our page should now look something like this:

Animated gif showing images being continuously loaded when scrolling down the page

Be sure to check out the demo and the complete source code!

Optionally Deploy it to

You can deploy it yourself by following this guide, with the exception of adding Postgres (assuming you’re using CLI with fly launch).

“To Infinity and Beyond!”

This solution works well for rendering uniformly shaped content. Aside from displaying images, this same feature could be used to show things like:

  • Contacts
  • Products
  • Product reviews
  • Client testimonials
  • …and more!

What will you build with your infinitely scrolling LiveView?

Fly ❤️ Elixir

Fly is an awesome place to run your Elixir apps. Deploying, clustering, and more is supported!

Deploy your Elixir app today!