My JS hook stopped working after i added phx-viewport-top

Hello, i’m currently implementing infinite scroll feature in my chat app and i have two functionalities which don’t work together. Those are:

  • phx-hook="ScrollDown"
    responsible for scrolling down every time a message is sent and when liveview is mounted.
  • phx-viewport-top="load_more"
    responsible for loading 20 messages older than currently loaded oldest one

ScrollDown hook works well by itself but after i added phx-view-top my app acts as if the js hook did not exist.

This is my whole heex, but the relevant part is inside <ul> tags.

<div class="flex flex-col mx-auto w-full max-w-2xl h-[80vh] mt-16 my-auto">
        <h1 class="text-4xl font-medium">Talk to the founder</h1>
        <!-- Box for messages list -->
          <!-- MESSAGES LIST -->
          class="mt-3 border border-neutral-400 p-2 rounded text-sm h-full overflow-y-auto shadow"
          <div :for={{dom_id, message} <- @streams.messages} id={dom_id}>
            <li class="p-1 hover:bg-neutral-300 rounded relative pb-4 break-words">
              <b><%= %></b>
              <br /> <%= message.content %>
              <span class="font-light font-mono text-xs text-neutral-500 absolute bottom-0 right-2">
                <%= message.inserted_at %>
        <!-- END MESSAGES LIST -->
        <!-- END Box for messages list -->
        <!-- Message box -->
        <.form for={@message_form} phx-submit="send_message" class="flex">
            class="peer block w-full appearance-none border-0 border-b border-gray-500 bg-transparent py-2.5 px-0 text-sm text-gray-900 focus:border-violet-600 focus:outline-none focus:ring-0 mr-2"
            placeholder="Your message"
          <.button class="mt-5 rounded-md bg-neutral-800 hover:bg-neutral-700 px-6 py-2 text-sm text-white h-12">
            <.icon name="hero-paper-airplane" class="w-5 w-5" />
        <!-- END Message box -->

I’m not sure what can i say about my code as it seems pretty self-explanatory, questions are welcome :smiley:

JS Hook:

export default ScrollDown = {
  mounted() {
    this.el.scrollTop = this.el.scrollHeight
    window.addEventListener('phx:scroll_down', () => {
      this.el.scrollTop = this.el.scrollHeight

  updated() {
    this.el.scrollTop = this.el.scrollHeight

I would be very thankful for some help as i’m currently out of ideas on why such behaviour occurs :grinning:

phx-viewport-top uses a hook internally, there’s a conflict between yor hook and that one. See the code.

Maybe you could wrap your ul in a parent div, assign the hook to that, and modify its child when phx:scroll-down happens

1 Like

Another option is to make a window-level listener for your scroll-down, and then let the phx-viewport-bottom/phx-viewport-top do it’s own thing.

# app.js

  // Scroll to target selector from event
  window.addEventListener("js:scroll-into-view", e => {{ behavior: "smooth", block: "end", inline: "nearest" });

# my_component.js

  def js_jump_to_top(js \\ %JS{}) do
    |> JS.dispatch("js:scroll-into-view", to: "#my-search")
    |> JS.push_focus(to: "#my-combobox-input")

  def js_reset(js \\ %JS{}) do
    |> JS.push("reset-search")
    |> js_jump_to_top()

I use placeholders, either “You’ve reached the end”, or just empty divs, at the beginning or ends of streams or scroll-able sections, and fire a JS.dispatch to them when I need to jump to the bottom/top.

I like this flexibility because the event doesn’t need to care where the item is. It could be a list of items and a graph, and when you click a dot on the graph, it scrolls the list to the corresponding list item, regardless of where it is


Because the above is only half the answer if you’re using <table> and <tbody> tags.

If someone searches this later and has issues with them, note that <tbody> tags have no height, so your phx-viewport-top/phx-viewport-bottom will mess up, or just not work at all.
If you can, switch to <ul> or <ol>, but if not easily possible, you can follow the IntersectionObserver examples from the docs, and write a custom hook. Here’s mine that uses keyset pagination aka cursor pagination:

# js/hooks/intersection_observing_dispatch.js

export const IntersectionObservingDispatch = {
  maybeDispatch(entries) {
    const target = entries[0];
    if (target.isIntersecting && this.el.dataset.intersect) {
      this.liveSocket.execJS(this.el, this.el.dataset.intersect)
  mounted() { = new IntersectionObserver(
      (entries) => this.maybeDispatch(entries),
        root: null, // window by default
        rootMargin: "0px",
        threshold: 1.0,
  beforeDestroy() {;
# after the `<table>`
# where `metadata` is from using `Paginator` for keyset-pagination

<div :if={@metadata} class="flex flex-col items-center m-6">
    <span :if={@end_of_timeline?} class="font-bold text-md md:text-lg">Looks like you've reached the end 🎉</span>
    <.button :if={!@end_of_timeline?} phx-click="load-more" phx-target={@myself}>
      Load More
      data-intersect={!@end_of_timeline? && JS.push("load-more", target: @myself)}

I don’t use the data-intersect-key anymore, but if your “load_more” is particularly slow, you might want to pass it so you don’t waste time on in-flight queries (especially if using async assigns, or start_async)