Frame Loading State
stimeo--frame-loading
Shows a skeleton and sets aria-busy while a turbo-frame loads, blocking interaction and retreating focus.
The stimeo--frame-loading controller manages a <turbo-frame>'s loading state. It subscribes on the frame to Turbo's own fetch lifecycle — turbo:before-fetch-request (which bubbles up from the frame's links and forms, or the frame itself) starts the state and turbo:frame-load ends it, with turbo:fetch-request-error as a safety net so it never sticks. While loading it sets aria-busy and data-frame-loading, reveals the optional skeleton / overlay targets, marks the content target inert to block double-submits, and retreats focus out of the stale content, restoring it on completion when restoreFocus is set. minDuration keeps the skeleton up long enough to avoid a flicker. Behavior only — it ships no skeleton markup or styling (pair with Skeleton / CSS); loading lives purely in aria-busy / data-frame-loading and the targets' hidden. Listeners and the min-duration timer are torn down on disconnect (Turbo navigation included), which also tidies the hooks so a cached frame is never left busy.
Loaded content.
<%# Frame-loading demo: the controller subscribes to a turbo-frame's fetch lifecycle
and toggles aria-busy / data-frame-loading, the skeleton, content inert, and focus.
This catalog has no Turbo backend, so demo.js fires the turbo:before-fetch-request →
turbo:frame-load pair to reproduce a frame fetch. The library only toggles state and
hidden; demo.css owns the skeleton bars and the dimmed content. %>
<div class="frame-demo">
<button type="button" class="demo-trigger" data-frame-demo-reload>
<%= t("components.frame_loading.demo.reload") %>
</button>
<div
class="frame-demo__frame"
data-controller="stimeo--frame-loading"
data-stimeo--frame-loading-min-duration-value="600">
<div class="frame-demo__skeleton" data-stimeo--frame-loading-target="skeleton" hidden>
<span class="visually-hidden"><%= t("components.frame_loading.demo.loading") %></span>
<span class="frame-demo__bar"></span>
<span class="frame-demo__bar"></span>
<span class="frame-demo__bar"></span>
</div>
<div class="frame-demo__content" data-stimeo--frame-loading-target="content">
<p><%= t("components.frame_loading.demo.content") %></p>
<button
type="button"
class="demo-trigger"
data-frame-demo-action
data-result-template="<%= t("components.frame_loading.demo.action_result") %>">
<%= t("components.frame_loading.demo.action") %>
</button>
<span class="frame-demo__result" data-frame-demo-result role="status"></span>
</div>
</div>
</div>
/*
* Presentation-only styles for the frame-loading demo. The library toggles aria-busy /
* data-frame-loading, the skeleton's hidden, and inert on the content; this CSS owns the
* skeleton bars and dims the (inert) content while loading.
*/
.frame-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 28rem;
align-items: flex-start;
}
.frame-demo__frame {
position: relative;
width: 100%;
padding: 1rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.frame-demo__skeleton {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.frame-demo__bar {
height: 0.75rem;
border-radius: 0.25rem;
background: linear-gradient(
90deg,
var(--surface-subtle) 25%,
var(--surface-subtle) 50%,
var(--surface-subtle) 75%
);
background-size: 200% 100%;
animation: frame-demo-shimmer 1.2s ease-in-out infinite;
}
.frame-demo__bar:nth-child(3) {
width: 80%;
}
.frame-demo__bar:nth-child(4) {
width: 60%;
}
/* While loading, dim the stale (inert) content so the skeleton reads as the live state. */
.frame-demo__frame[data-frame-loading] .frame-demo__content {
opacity: 0.4;
}
.frame-demo__result {
margin-left: 0.5rem;
font-size: 0.85rem;
color: var(--color-text-muted);
}
@keyframes frame-demo-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.frame-demo__bar {
animation: none;
}
}
// Frame-loading demo (consumer-side JS).
//
// The catalog has no Turbo backend, so the reload button fires the same fetch events a
// real <turbo-frame> would (turbo:before-fetch-request to start, turbo:frame-load to
// finish ~1.2s later). The controller reacts exactly as it would in production: it sets
// aria-busy, shows the skeleton, inerts the content, and retreats/restores focus.
document.querySelectorAll(".frame-demo").forEach((root) => {
const frame = root.querySelector('[data-controller="stimeo--frame-loading"]');
const reload = root.querySelector("[data-frame-demo-reload]");
if (!frame || !reload) return;
// Idempotent: Turbo can re-run this inline module on navigation; wire each root once.
if (root.dataset.demoWired) return;
root.dataset.demoWired = "1";
reload.addEventListener("click", () => {
frame.dispatchEvent(new Event("turbo:before-fetch-request", { bubbles: true }));
setTimeout(() => {
frame.dispatchEvent(new Event("turbo:frame-load", { bubbles: true }));
}, 1200);
});
// The in-content action button gives visible feedback so its inert-while-loading
// state is observable: it responds when the frame is idle, but not while loading
// (the controller marks the content inert, which swallows the click).
const action = root.querySelector("[data-frame-demo-action]");
const result = root.querySelector("[data-frame-demo-result]");
if (action && result) {
const template = action.dataset.resultTemplate ?? "Ran the action ({n}×)";
let runs = 0;
action.addEventListener("click", () => {
runs += 1;
result.textContent = template.replace("{n}", String(runs));
});
}
});
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--frame-loading"
Targets
| Name | Description | Attribute |
|---|---|---|
content
|
The current content; marked inert while loading and the focus-restore anchor. |
data-stimeo--frame-loading-target="content" |
skeleton
|
Optional placeholder shown (hidden toggled) while loading. |
data-stimeo--frame-loading-target="skeleton" |
overlay
|
Optional overlay shown (hidden toggled) while loading. |
data-stimeo--frame-loading-target="overlay" |
Values
| Name | Description | Attribute |
|---|---|---|
minDuration
|
Minimum milliseconds to keep the skeleton up, to avoid a flicker (default 0). | data-stimeo--frame-loading-min-duration-value |
restoreFocus
|
Restore focus to where it was after loading completes (default true). |
data-stimeo--frame-loading-restore-focus-value |
Events
| Name | Description | Event |
|---|---|---|
start
|
Fires when loading begins. | stimeo--frame-loading:start |
end
|
Fires when loading completes. | stimeo--frame-loading:end |
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 |
|---|---|---|
aria-busy |
The frame (root) | true while the frame is loading. |
data-frame-loading |
The frame (root) | true while loading, for CSS to drive the skeleton / overlay. |