<%# 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>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
url: String,
margin: { type: String, default: "0px" },
threshold: { type: Number, default: 0.01 }
}
connect() {
if (this.element.src) return
this.observer = new IntersectionObserver(
(entries) => this.onIntersect(entries),
{ rootMargin: this.marginValue, threshold: this.thresholdValue }
)
this.observer.observe(this.element)
}
onIntersect(entries) {
const entry = entries[0]
if (!entry.isIntersecting) return
this.load()
}
load() {
this.element.src = this.urlValue
this.observer.disconnect()
}
disconnect() {
if (this.observer) this.observer.disconnect()
}
}
class DashboardsController < ApplicationController
before_action :authenticate_user!
def show
@panels = current_user.account.panels.ordered
end
def panel
@panel = current_user.account.panels.find(params[:id])
unless turbo_frame_request?
redirect_to dashboard_path(anchor: dom_id(@panel)) and return
end
@report = PanelReport.new(@panel, range: params[:range]).call
render partial: "dashboards/panel_body",
locals: { panel: @panel, report: @report }
end
end
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
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
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
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
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<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)
Share this code
Here's the card — post it anywhere.