Tutorial

Infinite Scroll in Phoenix LiveView

phoenix liveview streams infinite-scroll pagination

Traditional pagination works fine, but sometimes you want something smoother. Infinite scroll keeps the user in the flow — no clicking "next", no page reloads. The interesting part? LiveView can do this with zero custom JavaScript.

LiveView streams combined with the phx-viewport-top and phx-viewport-bottom bindings give us a fully virtualized list out of the box. That means only a small window of DOM elements exists at any time, even if the user scrolls through thousands of items. The browser stays fast, the server stays lean, and we get bidirectional scrolling for free.

In this tutorial, I'll build an infinite scroll feed for a blog-style post listing. The same pattern works for chat messages, activity feeds, notifications — anything where you have a long list of items.

Step 1: The Context Function

First, we need a way to fetch posts with an offset and limit. In the context module, add a function like this:

# lib/my_app/blog.ex
def list_posts(opts \\ []) do
offset = Keyword.get(opts, :offset, 0)
limit = Keyword.get(opts, :limit, 20)
Post
|> order_by(desc: :inserted_at)
|> offset(^offset)
|> limit(^limit)
|> Repo.all()
end

Nothing fancy here. We order by newest first and support offset-based pagination. This is all the database work we need.

Step 2: Mount the LiveView with Streams

Here's where things get interesting. Instead of assigning a list of posts to the socket (which would keep every post in memory on the server), we use stream/3. Streams only track the minimal metadata needed to patch the DOM — the actual data is discarded after rendering.

# lib/my_app_web/live/post_live/index.ex
defmodule MyAppWeb.PostLive.Index do
use MyAppWeb, :live_view
alias MyApp.Blog
@per_page 20
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(page: 1, per_page: @per_page)
|> paginate_posts(1)}
end
end

We track the current page and per_page as assigns. The paginate_posts/2 function does the heavy lifting — let's build that next.

Step 3: The Pagination Logic

This is the core of the solution. The paginate_posts function fetches a page of results and streams them into the socket in the correct direction.

defp paginate_posts(socket, new_page) when new_page >= 1 do
%{per_page: per_page, page: cur_page} = socket.assigns
posts = Blog.list_posts(offset: (new_page - 1) * per_page, limit: per_page)
{posts, at, limit} =
if new_page >= cur_page do
{posts, -1, per_page * 3 * -1}
else
{Enum.reverse(posts), 0, per_page * 3}
end
case posts do
[] ->
assign(socket, end_of_timeline?: at == -1)
[_ | _] = posts ->
socket
|> assign(end_of_timeline?: false)
|> assign(:page, new_page)
|> stream(:posts, posts, at: at, limit: limit)
end
end

Let me break down what's happening:

  • Direction detection: If the new page is greater than or equal to the current page, the user is scrolling down. We append posts to the end of the stream with at: -1. If they're scrolling up, we prepend with at: 0 and reverse the list so the ordering is correct.
  • The limit option: This is the key to virtualization. We set the stream limit to per_page * 3 — that means only 60 items exist in the DOM at any time (with our default of 20 per page). When new items are appended at the bottom, old items at the top are automatically removed, and vice versa. The sign of the limit indicates which end to prune from: negative prunes from the top, positive from the bottom.
  • End of timeline: When we get an empty result set while scrolling down (at == -1), we know we've reached the end.

Step 4: The Template

Now for the template. This is where the viewport bindings come in.

<Layouts.app flash={@flash}>
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold mb-6">Posts</h1>
<ul
id="posts"
phx-update="stream"
phx-viewport-top={@page > 1 && JS.push("prev-page", page_loading: true)}
phx-viewport-bottom={!@end_of_timeline? && JS.push("next-page", page_loading: true)}
class={[
if(@end_of_timeline?, do: "pb-10", else: "pb-[calc(200vh)]"),
if(@page == 1, do: "pt-10", else: "pt-[calc(200vh)]")
]}
>
<li :for={{id, post} <- @streams.posts} id={id} class="py-4 border-b border-base-300">
<h2 class="text-lg font-semibold">{post.title}</h2>
<p class="text-sm text-base-content/60 mt-1">{post.summary}</p>
</li>
</ul>
<div :if={@end_of_timeline?} class="mt-5 text-center text-base-content/50">
You've reached the end.
</div>
</div>
</Layouts.app>

There's a lot packed into that small template, so let me walk through the important parts.

phx-viewport-top and phx-viewport-bottom are the bindings that make this work. When the first child of the <ul> scrolls into view at the top of the viewport, phx-viewport-top fires. When the last child reaches the bottom, phx-viewport-bottom fires. LiveView handles all the scroll detection for us — no IntersectionObserver, no scroll event listeners, no JavaScript at all.

  • We conditionally attach these bindings:
  • phx-viewport-top only fires if we're past page 1 (no point loading "previous" results when we're already at the beginning)
  • phx-viewport-bottom only fires if we haven't reached the end of the timeline

The padding trick is what makes the scrolling feel smooth. When the user is somewhere in the middle of the list, we add 200vh of padding above and below the container. That's twice the viewport height — enough room that the user can keep scrolling while the next batch loads. When they're at page 1, we use a small pt-10 instead (no previous results to scroll to). Same idea for the bottom when we've hit the end.

Step 5: Handle the Events

The event handlers are refreshingly simple:

def handle_event("next-page", _, socket) do
{:noreply, paginate_posts(socket, socket.assigns.page + 1)}
end
def handle_event("prev-page", %{"_overran" => true}, socket) do
{:noreply, paginate_posts(socket, 1)}
end
def handle_event("prev-page", _, socket) do
if socket.assigns.page > 1 do
{:noreply, paginate_posts(socket, socket.assigns.page - 1)}
else
{:noreply, socket}
end
end

The "next-page" handler just increments the page. The "prev-page" handler decrements it, but only if we're past page 1.

There's one subtle thing here: the "_overran" => true clause. The viewport bindings send this parameter when the user has "overran" the boundary — imagine someone grabbing the scrollbar and dragging all the way to the top in one motion. The container blows past the viewport top, and we need to reset to page 1 rather than just going back one page.

Step 6: Adding a Loading Indicator

You might have noticed the page_loading: true option in our JS.push calls. This is a LiveView feature that adds the phx-page-loading class to the body while the event is being processed. You can use this to show a subtle loading indicator:

/* assets/css/app.css */
.phx-page-loading .loading-indicator {
display: block;
}

And in your template, add a fixed loading bar or spinner:

<div class="loading-indicator hidden fixed top-0 left-0 right-0 h-1 bg-primary animate-pulse z-50" />

This gives the user visual feedback that new content is loading, especially on slower connections.

How It All Fits Together

The complete LiveView module:

defmodule MyAppWeb.PostLive.Index do
use MyAppWeb, :live_view
alias MyApp.Blog
@per_page 20
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(page: 1, per_page: @per_page)
|> paginate_posts(1)}
end
def handle_event("next-page", _, socket) do
{:noreply, paginate_posts(socket, socket.assigns.page + 1)}
end
def handle_event("prev-page", %{"_overran" => true}, socket) do
{:noreply, paginate_posts(socket, 1)}
end
def handle_event("prev-page", _, socket) do
if socket.assigns.page > 1 do
{:noreply, paginate_posts(socket, socket.assigns.page - 1)}
else
{:noreply, socket}
end
end
defp paginate_posts(socket, new_page) when new_page >= 1 do
%{per_page: per_page, page: cur_page} = socket.assigns
posts = Blog.list_posts(offset: (new_page - 1) * per_page, limit: per_page)
{posts, at, limit} =
if new_page >= cur_page do
{posts, -1, per_page * 3 * -1}
else
{Enum.reverse(posts), 0, per_page * 3}
end
case posts do
[] ->
assign(socket, end_of_timeline?: at == -1)
[_ | _] = posts ->
socket
|> assign(end_of_timeline?: false)
|> assign(:page, new_page)
|> stream(:posts, posts, at: at, limit: limit)
end
end
end

Wrapping Up

What I like about this approach is how little code it takes. No JavaScript scroll listeners, no IntersectionObserver setup, no debouncing logic. LiveView's viewport bindings handle the detection, streams handle the DOM management, and the limit option handles virtualization.

The result is a list that feels infinite to the user but only keeps a small window of elements in the DOM. The server only holds metadata about what's currently rendered — not the actual post data. And because streams use DOM patching, transitions between pages are smooth with no full re-renders.

If you've been using traditional pagination and want to level up the UX, this is a solid pattern to have in your toolkit. It works well for any long list — posts, messages, logs, activity feeds — and scales nicely since the DOM size stays constant regardless of how many total items exist.

Related Tutorials

Published 28 Jan - 2020
Updated 27 Mar

Pagination with Phoenix LiveView

Let say you have a long table that you want to paginate with Phoenix LiveView. In this tutorial, I have an existing table with 100 entries that I wi..

NEW
Published 27 Mar

Adding Modals to Phoenix 1.8 with DaisyUI

In Phoenix 1.8, the built-in modal component was removed. Instead, Phoenix now encourages developers to use separate LiveView pages for new and edit..