Frame Loading State
stimeo--frame-loading
Covers a Turbo frame while it loads, so nothing is pressed twice by mistake.
Handles how a Turbo frame looks while it loads. When a load starts the waiting state appears and is announced, and the content is made untouchable so the same action cannot be triggered twice. Focus sitting in content that is about to be replaced is moved out, and can be brought back when the load finishes. Even a very quick load shows the waiting state for a minimum time, so it never flickers. A failed request does not leave it stuck in the waiting state. The waiting markup and its look are yours.
Loaded content.
Keyboard
This component has no keyboard interactions of its own.
<%# 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.
The skeleton is revealed at the moment of the change, which is not reliably read, so
the loading / ready wording goes to the shared stimeo--announcer your app seats once,
in its layout. 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"
data-stimeo--frame-loading-announce-text-value="<%=
t("components.frame_loading.demo.announce_loading") %>"
data-stimeo--frame-loading-announce-ready-text-value="<%=
t("components.frame_loading.demo.announce_ready") %>">
<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(--border-strong) 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. 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;
reload.addEventListener("click", () => {
// before-fetch-request must carry a detail (fetchOptions/url): Turbo's prefetch
// observer reads event.detail.fetchOptions.method in the capture phase, so a bare
// Event (detail === undefined) would throw there. GET with no prefetch cache is a
// no-op for that observer.
frame.dispatchEvent(
new CustomEvent("turbo:before-fetch-request", {
bubbles: true,
cancelable: true,
detail: {
fetchOptions: { method: "GET", headers: {} },
url: new URL(window.location.href),
resume: () => {},
},
}),
);
setTimeout(() => {
frame.dispatchEvent(new CustomEvent("turbo:frame-load", { bubbles: true, detail: {} }));
}, 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 |
|---|---|---|
announceText
|
Wording read out when loading starts; empty announces nothing. | data-stimeo--frame-loading-announce-text-value |
announceReadyText
|
Wording read out when loading finishes; empty announces nothing. | data-stimeo--frame-loading-announce-ready-text-value |
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 |
reconcile
|
Fires when the Turbo cache rewind ends a load the frame never finished; detail is {}. |
stimeo--frame-loading:reconcile |
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. |