Skeleton Manager
stimeo--skeleton
Swaps a loading placeholder for the real content, without flickering when it arrives instantly.
Shows a placeholder while content loads, then swaps it for the real thing. That it is loading is announced, while the placeholder itself is kept out of the reading order so its shapes are never read out. A minimum showing time stops the placeholder flashing up and vanishing when the content arrives immediately. It can be put back into the loading state as well. The placeholder's shapes are drawn by this demo's CSS.
Article title
The real content replaces the skeleton once it is ready.
Keyboard
This component has no keyboard interactions of its own.
<%# 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 arrival of the content is handed to the shared stimeo--announcer your app seats
once, in its layout — aria-busy going false is state, not a notification, so it is
read out from a region that stands in the page independently of the card.
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-stimeo--skeleton-announce-ready-text-value="<%=
t("components.skeleton.demo.announce_ready") %>"
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(--border-strong) 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 |
|---|---|---|
announceReadyText
|
Wording read out once the content has arrived; empty announces nothing. | data-stimeo--skeleton-announce-ready-text-value |
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". |