Lazy Frame
stimeo--lazy-frame
Defers a turbo-frame's load until it nears the viewport — or focus reaches it.
The stimeo--lazy-frame controller defers a <turbo-frame>'s load until it nears the viewport, to keep the initial render light. Turbo's own loading="lazy" fires on render, not on viewport entry, so this drives an explicit IntersectionObserver with a configurable rootMargin for early loading, plus a focus fallback so keyboard / assistive-tech users trigger the load too. The URL is held in the url value (not on src) so Turbo does not load it eagerly; when the frame intersects (within rootMargin) or focus enters it, the controller writes url to src — which starts the Turbo load — marks data-lazy-loaded, and emits load. With once (default) it then stops observing; otherwise re-entry asks Turbo to reload the frame. Behavior only — the load and the frame's content are Turbo's / the server's job, and the loading UI belongs to Frame Loading State. The trigger is idempotent (data-lazy-loaded guards a double load and is honored on a Turbo cache restore), and the observer and focus listener are released once loaded (when once) and on disconnect (Turbo navigation included).
Scroll down — the frame below holds its URL and only loads once it enters the viewport.
<%# Lazy-frame demo: in production the controller goes on a <turbo-frame> and writes its
held url to src to start the Turbo load. The catalog runs Turbo, so to avoid a real
fetch this demo puts the controller on a plain <div> (writing src to a div is inert)
and demo.js swaps the placeholder on the load event — demonstrating the lazy *trigger*
(it fires only once the frame scrolls into view, thanks to the spacer above it). The
library only writes src and reflects data-lazy-loaded. %>
<div class="lazy-frame-demo">
<p class="lazy-frame-demo__hint"><%= t("components.lazy_frame.demo.hint") %></p>
<div class="lazy-frame-demo__spacer" aria-hidden="true"></div>
<div
class="lazy-frame-demo__frame"
data-controller="stimeo--lazy-frame"
data-stimeo--lazy-frame-url-value="/posts/1/comments"
data-stimeo--lazy-frame-root-margin-value="0px"
data-loaded-label="<%= t("components.lazy_frame.demo.loaded") %>"
data-loading-label="<%= t("components.lazy_frame.demo.loading") %>">
<%= t("components.lazy_frame.demo.placeholder") %>
</div>
</div>
/*
* Presentation-only styles for the lazy-frame demo. The library writes src and reflects
* data-lazy-loaded; this CSS lays out the placeholder frame, pushes it below the fold with
* a spacer so the lazy trigger is visible on scroll, and tints it once loaded.
*/
.lazy-frame-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 28rem;
}
.lazy-frame-demo__hint {
margin: 0;
color: var(--color-text-muted);
}
/* Pushes the frame below the fold so it loads on scroll, not on initial render. */
.lazy-frame-demo__spacer {
height: 60vh;
}
.lazy-frame-demo__frame {
padding: 1.5rem;
border: 1px dashed var(--border);
border-radius: 0.5rem;
text-align: center;
color: var(--color-text-muted);
}
/*
* The visible state is driven by a demo-owned `data-demo-state` (set by demo.js),
* not the library's `data-lazy-loaded`: the library marks the load *triggered* the
* instant the frame enters the viewport, but the demo simulates Turbo's fetch
* latency so the transition is perceptible — loading (amber) then loaded (green).
*/
.lazy-frame-demo__frame[data-demo-state="loading"] {
border-style: solid;
border-color: var(--amber-500);
background: var(--amber-50);
color: var(--amber-500);
}
.lazy-frame-demo__frame[data-demo-state="loaded"] {
border-style: solid;
border-color: var(--leaf-500);
background: var(--leaf-50);
color: var(--leaf-500);
}
// Lazy-frame demo (consumer-side JS).
//
// In production the lazy-frame controller writes its held url to a <turbo-frame>'s src and
// Turbo fetches it. The catalog has no backend for that, so the demo runs the controller
// on a <div> (writing src to a div does nothing) and reflects the load event into the
// placeholder — proving the lazy *trigger* fired only once the frame scrolled into view.
//
// The library marks the load *triggered* the instant the frame enters the viewport, so a
// naive swap would flip to "loaded" before it can be perceived. To mirror real-world fetch
// latency (and keep the lazy transition visible), show a brief "loading" state first and
// only then the "loaded" content.
document.querySelectorAll(".lazy-frame-demo").forEach((root) => {
const frame = root.querySelector('[data-controller~="stimeo--lazy-frame"]');
if (!frame) return;
// Idempotent: Turbo can re-run this inline module on navigation; wire each root once.
if (root.dataset.demoWired) return;
root.dataset.demoWired = "1";
const loadedLabel = frame.dataset.loadedLabel ?? "Loaded:";
const loadingLabel = frame.dataset.loadingLabel ?? "Loading…";
frame.addEventListener("stimeo--lazy-frame:load", (event) => {
// Trigger fired (the library wrote src + set data-lazy-loaded). Simulate Turbo's
// network fetch so the placeholder -> loaded transition is perceptible on scroll.
frame.dataset.demoState = "loading";
frame.textContent = loadingLabel;
window.setTimeout(() => {
frame.dataset.demoState = "loaded";
frame.textContent = `${loadedLabel} ${event.detail.url}`;
}, 800);
});
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--lazy-frame"
Values
| Name | Description | Attribute |
|---|---|---|
url
|
The URL to load; written to the frame's src when triggered (held here, not on src). |
data-stimeo--lazy-frame-url-value |
rootMargin
|
IntersectionObserver margin, e.g. 200px, to load before the frame is fully visible. | data-stimeo--lazy-frame-root-margin-value |
once
|
Stop observing after the first load (default true); false reloads on re-entry. |
data-stimeo--lazy-frame-once-value |
Events
| Name | Description | Event |
|---|---|---|
load
|
Fires when the lazy load is triggered, with detail.url. |
stimeo--lazy-frame:load |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-lazy-loaded |
The frame (controller element) | Present (true) once the load has been triggered. |