スケルトン切り替え制御
stimeo--skeleton
プレースホルダを実コンテンツに差し替え、aria-busy とちらつき防止の最小表示時間を担う。
stimeo--skeleton コントローラは aria-busy +表示/非表示の慣行に従います(専用 APG パターンはなし)。初期はローディング状態(スケルトン表示・実コンテンツ非表示・領域 aria-busy="true")で、 ready で実コンテンツへ差し替えて aria-busy を解除します。スケルトンは装飾扱いの aria-hidden で支援技術から隠し、minDuration はコンテンツがほぼ即時に届いた場合のちらつきを防ぎます。reset でローディングに戻ります。stimeo--skeleton:ready を発火し、タイマーは disconnect(Turbo 遷移含む)で破棄されます。ライブラリは挙動のみを提供し、スケルトン図形はこの Playground が持ちます。
記事タイトル
準備が整うと実コンテンツがスケルトンを置き換えます。
<%# 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"));
});
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--skeleton"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
placeholder
|
読み込み中に表示し、表示切替で隠す aria-hidden のスケルトン。 |
data-stimeo--skeleton-target="placeholder" |
content
|
実コンテンツ。読み込み中は隠し、準備完了で表示する。 | data-stimeo--skeleton-target="content" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
minDuration
|
ちらつき防止のためプレースホルダーを表示し続ける最小ミリ秒(既定 0)。 | data-stimeo--skeleton-min-duration-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
ready
|
実コンテンツへ切り替える。表示前に minDuration を尊重する。 |
stimeo--skeleton#ready |
reset
|
読み込み状態へ戻し、保留中の表示を取り消す。 | stimeo--skeleton#reset |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
ready
|
コンテンツが表示され busy が解除されたときに発火。 | stimeo--skeleton:ready |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
aria-busy |
ルート要素 | 読み込み中は "true"、完了で "false"。 |
hidden |
placeholder / content | スケルトンとコンテンツの表示を切り替える。 |
data-state |
ルート要素 | "loading" / "ready"。 |