Infinite scroll with Turbo Frames and Intersection Observer

Jordan Lee Jan 2026
3 tabs
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["sentinel"]
  static values = {
    url: String
  }

  connect() {
    this.observer = new IntersectionObserver(
      entries => this.handleIntersection(entries),
      { rootMargin: "100px" }
    )

    if (this.hasSentinelTarget) {
      this.observer.observe(this.sentinelTarget)
    }
  }

  disconnect() {
    this.observer.disconnect()
  }

  handleIntersection(entries) {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        // Trigger the frame to load
        this.sentinelTarget.src = this.urlValue
        this.observer.unobserve(this.sentinelTarget)
      }
    })
  }
}
3 files · javascript, erb, ruby Explain with highlit

Infinite scroll improves perceived performance by loading content as users reach the bottom of the page. I combine Turbo Frames with the Intersection Observer API via a Stimulus controller. The last item in each page has a sentinel element that, when visible, triggers a frame load for the next page. The server returns the next page wrapped in the same frame ID, which replaces the sentinel with new content plus a new sentinel. This approach is more efficient than scroll event listeners and works seamlessly with Turbo's caching. I also provide a fallback 'Load More' button for users with JavaScript disabled or those who prefer manual control.