The phx-viewport-top and phx-viewport-bottom bindings allow you to detect when a container's first child reaches the top of the viewport, or the last child reaches the bottom of the viewport. This is useful for infinite scrolling where you want to send paging events for the next results set or previous results set as the user is scrolling up and down and reaches the top or bottom of the viewport.

Generally, applications will add padding above and below a container when performing infinite scrolling to allow smooth scrolling as results are loaded. Combined with Phoenix.LiveView.stream/3, the phx-viewport-top and phx-viewport-bottom allow for infinite virtualized list that only keeps a small set of actual elements in the DOM. For example:

def mount(_, _, socket) do
  {:ok,
    socket
    |> assign(page: 1, per_page: 20)
    |> paginate_posts(1)}
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

Our paginate_posts function fetches a page of posts, and determines if the user is paging to a previous page or next page. Based on the direction of paging, the stream is either prepended to, or appended to with at of 0 or -1 respectively. We also set the limit of the stream to three times the per_page to allow enough posts in the UI to appear as an infinite list, but small enough to maintain UI performance. We also set an @end_of_timeline? assign to track whether the user is at the end of results or not. Finally, we update the @page assign and posts stream. We can then wire up our container to support the viewport events:

<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}>
    <.post_card post={post} />
  </li>
</ul>
<div :if={@end_of_timeline?} class="mt-5 text-[50px] text-center">
  🎉 You made it to the beginning of time 🎉
</div>

There's not much here, but that's the point! This little snippet of UI is driving a fully virtualized list with bidirectional infinite scrolling. We use the phx-viewport-top binding to send the "prev-page" event to the LiveView, but only if the user is beyond the first page. It doesn't make sense to load negative page results, so we remove the binding entirely in those cases. Next, we wire up phx-viewport-bottom to send the "next-page" event, but only if we've yet to reach the end of the timeline. Finally, we conditionally apply some CSS classes which sets a large top and bottom padding to twice the viewport height based on the current pagination for smooth scrolling.

To complete our solution, we only need to handle the "prev-page" and "next-page" events in the LiveView:

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

This code simply calls the paginate_posts function we defined as our first step, using the current or next page to drive the results. Notice that we match on a special "_overran" => true parameter in our "prev-page" event. The viewport events send this parameter when the user has "overran" the viewport top or bottom. Imagine the case where the user is scrolling back up through many pages of results, but grabs the scrollbar and returns immediately to the top of the page. This means our <ul id="posts"> container was overrun by the top of the viewport, and we need to reset the the UI to page the first page.

When testing, you can use Phoenix.LiveViewTest.render_hook/3 to test the viewport events:

view
|> element("#posts")
|> render_hook("next-page")

source