erb javascript ruby 72 lines · 3 tabs

Lazy-load heavy panels with IntersectionObserver + Turbo frame

Shared by codesnips Jan 2026
3 tabs
<%# renders a lazy placeholder; real content arrives when scrolled into view %>
<div class="panel-card">
  <h3 class="panel-card__title"><%= title %></h3>

  <%= turbo_frame_tag dom_id(panel, :frame),
        class: "panel-card__body",
        data: {
          controller: "lazy-frame",
          "lazy-frame-url-value": panel_dashboard_path(panel),
          "lazy-frame-margin-value": "200px"
        } do %>
    <div class="panel-card__skeleton" aria-busy="true">
      <span class="skeleton skeleton--line"></span>
      <span class="skeleton skeleton--line"></span>
      <span class="skeleton skeleton--chart"></span>
    </div>
  <% end %>
</div>
3 files · erb, javascript, ruby Explain with highlit

This snippet shows how to defer rendering of expensive dashboard panels until they scroll into view, combining a Turbo Frame with a small Stimulus controller driven by IntersectionObserver. Turbo Frames already support loading="lazy", which defers the fetch until the frame is visible in the viewport, but the default behavior fires as soon as the frame enters the visible area. When several heavy panels sit just below the fold, all of them can request at once during a fast scroll, defeating the purpose. The pattern here adds an explicit observer with a rootMargin and a threshold so each frame only loads when it is genuinely about to matter, and it can control the timing rather than leaving it to Turbo alone.

In _analytics_panel.html.erb, each panel is a turbo_frame_tag with src intentionally left off and the real endpoint stashed in data-lazy-frame-url-value. Because the frame has no src it renders only its placeholder skeleton, keeping the initial HTML small and the first paint fast. The frame is wired to the Stimulus controller via data-controller="lazy-frame", so the DOM node itself becomes the observed element.

In lazy_frame_controller.js, connect constructs an IntersectionObserver with a rootMargin of 200px so the fetch starts slightly before the panel is on screen, hiding latency. When intersectionRatio crosses the threshold, load sets the frame's src to the stored URL, which triggers Turbo to fetch and swap the content. The observer is immediately disconnected after firing so each panel loads exactly once, and disconnect cleans up if the element leaves the DOM before it ever appears, avoiding leaked observers on Turbo navigations.

In dashboards_controller.rb, the panel action responds to the frame request and renders only the fragment, guarded by turbo_frame_request? so a direct visit still returns a full page. This division keeps the expensive query out of the initial render entirely. The main trade-off is that content below the fold is not present for crawlers or no-JS clients, so this suits authenticated dashboards rather than public SEO pages. A subtle pitfall is forgetting to disconnect the observer, which under Turbo's persistent sessions would accumulate stale callbacks.


Related snips

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
class Post < ApplicationRecord
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy

  scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
  scope :draft, -> { where(published_at: nil) }

ActiveRecord scopes for reusable query logic

rails activerecord patterns
by Alex Kumar 1 tab
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
class PostsController < ApplicationController
  def index
    @posts = Post.includes(:author)
                 .order(created_at: :desc)
                 .page(params[:page])
                 .per(10)

Turbo Frames: infinite scroll with lazy-loading frame

rails turbo hotwire
by codesnips 4 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
erb
<form data-controller="query-sync" data-action="change->query-sync#apply">
  <select name="status" class="rounded border p-2">
    <option value="">Any</option>
    <option value="open">Open</option>
    <option value="closed">Closed</option>
  </select>

Filter UI that syncs query params via Stimulus (no front-end router)

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

Lazy-load heavy panels with IntersectionObserver + Turbo frame — share card
Link copied