Lazy Frame
stimeo--lazy-frame
Holds a Turbo frame's load until it comes close, and loads it when the keyboard reaches it too.
Holds a Turbo frame's load until it comes near the screen, keeping the first render light. How far ahead it starts is up to you, so fast scrolling still arrives in time. Reaching the frame with the keyboard loads it as well, so someone who never scrolls is not left with an empty box. A finished load is remembered, so a page Turbo has cached does not fetch it twice. It can load once and stop, or load again each time you come back. How the wait looks is the frame loading state part's job.
Scroll down: the frame below holds its URL and loads only once it comes into view.
Keyboard
This component has no keyboard interactions of its own.
<%# 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. The
// marker is a property, not an attribute: Turbo copies attributes into its page snapshot,
// so an attribute one comes back set on a restored page whose elements carry no listeners.
if (root.demoWired) return;
root.demoWired = true;
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. |