Skeleton Manager
stimeo--skeleton
Swaps a placeholder for real content, with aria-busy and an anti-flicker floor.
The stimeo--skeleton controller follows the aria-busy + show/hide practice (no dedicated APG pattern). It starts in the loading state — placeholder shown, real content hidden, region aria-busy="true" — and ready swaps to the content and clears busy. The placeholder is aria-hidden (decorative) so assistive tech never reads the skeleton, and minDuration keeps it up long enough to avoid a flash when content arrives almost immediately. reset returns to loading. It dispatches stimeo--skeleton:ready, and the timer is torn down on disconnect (Turbo included). Behavior only — skeleton shapes are owned by this Playground.
Article title
The real content replaces the skeleton once it is ready.
<%# Markup for the skeleton (skeleton toggle control) demo.
Initially the skeleton is shown, the real content hidden, and aria-busy="true". On
ready it swaps and sets aria-busy="false". The skeleton is decorative
(aria-hidden="true"). A minimum display time prevents flicker.
The Load / Reset buttons sit outside the busy card (so aria-busy stays scoped to the
loading region, not the controls), so they drive it through events the controller
listens for (see demo.js): Load fires content:ready — the same signal a consumer
dispatches when their real content arrives — and Reset returns it to loading. A
Stimulus data-action on the buttons would not bind, since actions only wire up
within the controller's own element. %>
<div class="skeleton-demo">
<div class="skeleton-demo__controls">
<button
class="demo-trigger"
type="button"
data-skeleton-load>
<%= t("components.skeleton.demo.load") %>
</button>
<button
class="demo-trigger"
type="button"
data-skeleton-reset>
<%= t("components.skeleton.demo.reset") %>
</button>
</div>
<div
class="skeleton-card"
data-controller="stimeo--skeleton"
aria-busy="true"
data-stimeo--skeleton-min-duration-value="400"
data-action="content:ready->stimeo--skeleton#ready skeleton:reset->stimeo--skeleton#reset">
<div
class="skeleton"
aria-hidden="true"
data-stimeo--skeleton-target="placeholder">
<span class="skeleton__line skeleton__line--title"></span>
<span class="skeleton__line"></span>
<span class="skeleton__line skeleton__line--short"></span>
</div>
<div class="skeleton-content" hidden data-stimeo--skeleton-target="content">
<h3 class="skeleton-content__title"><%= t("components.skeleton.demo.title") %></h3>
<p><%= t("components.skeleton.demo.body") %></p>
</div>
</div>
</div>
/*
* Presentation-only styles for the skeleton demo.
* This CSS owns the skeleton shapes and shimmer; the library only toggles
* hidden / aria-busy / aria-hidden / data-state (loading / ready).
*/
.skeleton-demo {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 32rem;
}
.skeleton-demo__controls {
display: flex;
gap: 0.5rem;
}
.skeleton-card {
padding: 1rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.skeleton {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.skeleton__line {
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: skeleton-shimmer 1.4s ease-in-out infinite;
}
.skeleton__line--title {
height: 1.1rem;
width: 55%;
}
.skeleton__line--short {
width: 70%;
}
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.skeleton-content__title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.skeleton-content p {
margin: 0;
color: var(--fg);
}
@media (prefers-reduced-motion: reduce) {
.skeleton__line {
animation: none;
}
}
// skeleton external-control demo (consumer-side JS).
//
// The core controller (stimeo--skeleton) swaps the placeholder for the real
// content on ready and returns to loading on reset, syncing aria-busy on the card.
// The Load / Reset buttons sit outside that busy card (so aria-busy stays scoped to
// the loading region), where a Stimulus data-action would never bind — actions only
// wire up within the controller's own element. The card listens for content:ready
// (the signal a consumer fires when their real content has arrived) and skeleton:reset,
// so here Load dispatches content:ready and Reset dispatches skeleton:reset.
document.querySelectorAll(".skeleton-demo").forEach((root) => {
const card = root.querySelector('.skeleton-card[data-controller~="stimeo--skeleton"]');
const controls = root.querySelector(".skeleton-demo__controls");
if (!card || !controls) return;
controls.querySelector("[data-skeleton-load]")?.addEventListener("click", () => {
card.dispatchEvent(new CustomEvent("content:ready"));
});
controls.querySelector("[data-skeleton-reset]")?.addEventListener("click", () => {
card.dispatchEvent(new CustomEvent("skeleton:reset"));
});
});
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--skeleton"
Targets
| Name | Description | Attribute |
|---|---|---|
placeholder
|
The aria-hidden skeleton shown while loading and hidden on reveal. |
data-stimeo--skeleton-target="placeholder" |
content
|
The real content, hidden while loading and shown once ready. | data-stimeo--skeleton-target="content" |
Values
| Name | Description | Attribute |
|---|---|---|
minDuration
|
Minimum milliseconds the placeholder stays up to avoid a flash (default 0). | data-stimeo--skeleton-min-duration-value |
Actions
| Name | Description | Action |
|---|---|---|
ready
|
Swaps to the real content, honoring minDuration before revealing. |
stimeo--skeleton#ready |
reset
|
Returns to the loading state, cancelling any pending reveal. | stimeo--skeleton#reset |
Events
| Name | Description | Event |
|---|---|---|
ready
|
Fires when the content is revealed and busy is cleared. | stimeo--skeleton:ready |
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 |
Root element | "true" while loading, "false" once ready. |
hidden |
Placeholder / Content | Toggled to swap the skeleton for the content. |
data-state |
Root element | "loading" / "ready". |