# 読み上げられないスケルトンスクリーン

Source: https://easeweb.dev/learn/skeleton-screens
Topics: ライブ更新 > スケルトン表示で、読み込み中であることが伝わる (WCAG 4.1.3)
The fix: 灰色のプレースホルダーには aria-hidden="true" を付け、role="status" の領域を最初からページに置きます。リクエストの開始時に読み込み中の文言を書き込み、項目がそろったら件数を、失敗したらエラーを書き込みます。読み込み中のセクションに付ける aria-busy は役に立つこともありますが、それだけでは何も伝わりません。
Test with: screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F103 https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 https://www.w3.org/TR/wai-aria-1.2/#aria-busy https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-busy https://getbootstrap.com/docs/5.3/components/placeholders/

よくある問題を再現した学習用のサンプルです。実在のクライアントの診断結果ではありません。コードは出発点となる実装例のため、実際の製品でも確認してください。ここでは支援技術でのテスト結果は報告していません。

## 場面

生活雑貨の店のトップページに「新着アイテム」のセクションがあります。商品はページの表示後に別のリクエストで取得され、届くまでの間は灰色のカードが3枚並びます。写真の場所に四角が一つ、商品名と価格の場所に棒が2本あります。これがスケルトンスクリーンで、MUIのSkeleton、shadcn/uiのSkeleton、Bootstrapのプレースホルダーなど、多くのコンポーネントライブラリーに用意されています。1、2秒で商品が届き、灰色のカードと入れ替わります。画面が見える人には、まずレイアウトが見え、そこに中身が入っていく様子が分かります。

商品を取得している間に、スクリーンリーダーの利用者がページを上から読んだとします。聞こえるのは「新着アイテム、見出しレベル2」と、セクションの後ろにある「30日以内なら返品無料です。」です。灰色のカードは中身のない `div` なので、読むものがありません。商品を読み込んでいることは伝わらず、商品が届いても何も伝わりません。利用者はもうセクションを空だと思っているため、たまたま見出しまで戻らない限り、新着アイテムには出会えません。

もう一つの例では、すべてのプレースホルダーに `aria-busy="true"` と `aria-live="polite"` が付いています。React Loading Skeletonも、プレースホルダーを1つずつ包む `span` にこの2つを付けています。コードだけ見ると、待っていることを伝えていそうです。しかし伝わりません。プレースホルダーには相変わらず読むものがなく、商品が届くと、ライブリージョンはプレースホルダーごと取り除かれます。中身が変わったライブリージョンがないため、伝えるものがありません。

## 確認できる問題

「新着アイテムを読み込み中…」や「新着アイテムを3件読み込みました」は、WCAGのいうステータスメッセージです。待っている状態や処理の結果を伝え、フォーカスは移しません。達成基準4.1.3は、こうしたメッセージをロールやプロパティで判別できるようにし、フォーカスを移さずに支援技術が伝えられることを求めています。スケルトンは待っている状態を灰色の形だけで示し、メッセージだと示すロールもプロパティもないため、見えない人には何も伝わりません。これはW3CがF103で示している失敗です。入れ替わりに表示される商品そのものは、達成基準の解説書が検索結果について述べているとおり、ステータスメッセージではありません。ステータスメッセージにあたるのは「新着アイテムを3件読み込みました」のような一文で、修正前にはそれがないため、待ち時間が終わったことも言葉では伝わりません。

2つ目の修正前は惜しいところまで来ていますが、どちらの属性も付ける場所が違います。`aria-live` を付けた要素はライブリージョンになり、ページにある間に中身が変われば、スクリーンリーダーが読み上げます。ところがスクリプトは、この領域の中身を変えずに、領域そのものを取り除きます。ライブリージョンは、初期設定では中身が取り除かれたことを伝えません。`aria-busy="true"` は、要素が更新中であり、更新が終わるまで伝えるのを待ってもよいと支援技術に知らせる属性です。メッセージではないため「読み込み中」とは言いません。しかもここでは、更新されずに捨てられるプレースホルダーに付いています。

## 利用者への影響

スケルトンスクリーンは、商品一覧、フィード、ダッシュボード、検索結果など、ページの表示後に読み込まれる部分に使われます。後から読み込まれる部分こそ、スクリーンリーダーの利用者が見落としやすいところです。

## 自動チェックとAIの限界

ページの読み込みが終わるのを待つスキャンは、そろった一覧を見て合格にします。調べるころにはスケルトンは消えていますし、中身のない `div` はどのルールにも違反しません。スケルトンを探すaxeのルールもありません。EaseWebの診断は、読み込み中のページも見ています。スケルトンのクラスを持つプレースホルダー（MUIやReact Loading Skeletonが使う「skeleton」を含むクラス、shadcn/uiの `data-slot="skeleton"`、Bootstrapの `placeholder-glow` と `placeholder-wave`）が現れ、その外にライブリージョンがなければ、人が確認すべき項目として報告します。待っていることと届いたことが実際に読み上げられるかは、読み込み中にスクリーンリーダーで聞いて確かめるしかありません。スケルトンを使いやすくするようAIに頼むと、プレースホルダーそのものに `aria-busy` や `aria-live` を付けることがあります。これが2つ目の修正前です。

## 修正前：灰色の仮のカード

```html
<h2>新着アイテム</h2>
<div id="arrivals" class="cards">
  <div class="card">
    <div class="skeleton photo"></div>
    <div class="skeleton line"></div>
    <div class="skeleton line short"></div>
  </div>
  …
</div>
<p>30日以内なら返品無料です。</p>
```

```js
const products = await fetchArrivals();
arrivals.innerHTML = products.map(card).join('');
```

- 灰色のカードは中身のない `div` なので、スクリーンリーダーは見出しからすぐ「30日以内なら返品無料です。」へ進み、商品を読み込んでいることは伝わりません。
- 商品はライブリージョンではない `div` に書き込まれるため、届いたことが読み上げられません。

## 修正前：aria-busy と aria-live を付けたプレースホルダー

```html
<h2>新着アイテム</h2>
<div id="arrivals" class="cards">
  <div class="card">
    <div class="skeleton photo" aria-busy="true" aria-live="polite"></div>
    <div class="skeleton line" aria-busy="true" aria-live="polite"></div>
    <div class="skeleton line short" aria-busy="true" aria-live="polite"></div>
  </div>
  …
</div>
<p>30日以内なら返品無料です。</p>
```

```js
const products = await fetchArrivals();
arrivals.innerHTML = products.map(card).join('');
```

- プレースホルダーには相変わらず読むものがなく、読み込み中のセクションは空のように聞こえます。`aria-busy` はメッセージではありません。
- ライブリージョンはプレースホルダーと一緒に取り除かれるため、商品が届いても中身が変わったライブリージョンがなく、何も読み上げられません。

## 修正後：プレースホルダーを隠し、ステータスの行で伝える

```html
<h2>新着アイテム</h2>
<p id="arrivals-status" role="status" class="visually-hidden"></p>
<div id="arrivals" class="cards" aria-busy="true">
  <div class="card" aria-hidden="true">
    <div class="skeleton photo"></div>
    <div class="skeleton line"></div>
    <div class="skeleton line short"></div>
  </div>
  …
</div>
<p>30日以内なら返品無料です。</p>
```

```js
status.textContent = '新着アイテムを読み込み中…';
try {
  const products = await fetchArrivals();
  arrivals.innerHTML = products.map(card).join('');
  status.textContent = `新着アイテムを${products.length}件読み込みました。`;
} catch {
  arrivals.innerHTML = '';
  status.textContent = '新着アイテムを読み込めませんでした。';
} finally {
  arrivals.setAttribute('aria-busy', 'false');
}
```

1. 見出しの下、一覧の外に、`role="status"` の空の `p` を置きます。こうすれば、スクリプトが書き込む時点で、ブラウザーはすでにこの領域を監視しています。`visually-hidden` クラスで画面からは隠し、デザインは変えません。画面では、灰色のカードがすでに待っていることを示しています。
2. 仮のカードそれぞれに `aria-hidden="true"` を付けます。カードには伝えることがなく、ライブラリーがプレースホルダーに付けるライブリージョンや `aria-busy` も、一緒に黙ります。
3. 一覧には、中身が入るまで `aria-busy="true"` を付けます。この属性に従うスクリーンリーダーは、作りかけの一覧を伝えるのを待てます。属性そのものは何も読み上げません。
4. リクエストを始めるときに、ステータスの行へ「新着アイテムを読み込み中…」と書き込みます。スクリーンリーダーは、読み上げ中の内容を終えてからこの文言を読みます。この時点でセクションを読むと、見出しの次にこの行が読まれます。
5. すべての商品を一覧に入れたら、「新着アイテムを3件読み込みました。」と件数を書き込みます。リクエストが失敗したときは、灰色のカードを消し、代わりに「新着アイテムを読み込めませんでした。」と書き込みます。
6. どちらの場合も、`aria-busy` を `false` に戻します。

これで2つの修正前の問題はどちらも直り、4.1.3を満たします。待っていることも届いたことも、フォーカスを動かさずにステータスの領域で言葉にして伝えます。メッセージはカードごとではなく、一覧がそろったときに一度だけ書き込みます。

## 実装の判断

**プレースホルダーには名前を付けず、隠します。** 灰色のカードそれぞれに `role="img"` と「読み込み中」の `aria-label` を付けたり、カードごとに `role="status"` を付けたりすると、同じ言葉がカードの数だけ読まれ、12枚の一覧なら12回聞こえます。メッセージはセクション全体についてのものなので、一か所にまとめます。Bootstrap自身のプレースホルダーの例も、灰色のカードに `aria-hidden="true"` を付けており、プレースホルダーを中身と差し替え、更新を支援技術に知らせるには、JavaScriptが必要になる場合があると説明しています。

**メッセージは、最後の項目がそろってから一度だけ。** 複数のリクエストやストリーミングで、カードが1枚ずつ届く場合も、件数は最後の1枚の後に書き込みます。一覧そのものに `aria-live` を付けてはいけません。カードが追加されるたびに商品名と価格が読まれ、一つのメッセージではなく細切れの読み上げになります。

**`aria-busy` はなくても構いません。** ARIAは、要素が更新中の間、支援技術が待つことを認めていますが、すべてのスクリーンリーダーが従うわけではありません。そのため、この修正は `aria-busy` がなくても成り立ちます。使う場合は、失敗も含めて、どの終わり方でも `false` に戻してください。この属性に従うスクリーンリーダーは、更新中のままの一覧を利用者に伝えないことがあります。

**メッセージを画面に見せても構いません。** 同じステータスの行から `visually-hidden` を外し、見出しの下に「新着アイテムを読み込み中…」、続いて「新着アイテム3件」と出せば、何が起きているかが誰にでも分かります。読み込みが遅いときに特に役立ちます。灰色のカードは、あと1秒で届くときも、止まってしまったときも、同じに見えるからです。

**灰色のカードを残しません。** 失敗したリクエストや、とても時間がかかるリクエストは、残ったプレースホルダーではなく、画面上の言葉で終わらせます。明滅やきらめきは合計5秒未満で数回にとどめ、その後はカードを止めます。動き続けるアニメーションは別の問題で、[止まらずに動き続けるボタンやバッジのアニメーション](/ja/learn/endless-animations)で扱っています。

**領域は、差し替える部分の外に置きます。** コンポーネントのフレームワークでは、スケルトンと中身が一つの条件の2つの分岐になっていることがよくあります（`loading ? <Skeleton /> : <Grid />`）。ステータスの行はどちらの分岐にも入れず、ページと一緒に表示します。メッセージと一緒に現れた領域は、読み上げられないことがよくあります。

**フォーカスは動かしません。** 商品が届くとき、利用者はほかの場所を読んでいるかもしれません。一覧へフォーカスを移すと、読んでいた場所から引き離してしまいます。絞り込みのボタンのように、ボタンを押して読み込みが始まる場合は、メッセージをそのボタンのそばで伝えます。[読み上げられない読み込み中の表示](/ja/learn/loading-states)で扱っています。

## 修正の確認

1. 開発者ツールで通信を遅くし、灰色のカードが表示されている間にマークアップを確認する。プレースホルダー、またはそれをまとめる要素に `aria-hidden="true"` が付き、その外に `role="status"` の空の要素がある。
2. スクリーンリーダーを有効にしてページを再読み込みし、灰色のカードが表示されている間にセクションを読む。見出しと「新着アイテムを読み込み中…」が読まれ、空の項目や名前のない項目は読まれない。
3. その場で待つ。「新着アイテムを3件読み込みました。」が一度だけ読まれ、フォーカスは動かない。もう一度セクションを読むと、商品が読まれる。
4. 通信をさらに遅くし、次にリクエストを止める。待っている間に読み込み中の文言が繰り返されず、失敗すると灰色のカードが消えてエラーが読まれ、その後 `aria-busy` が `false` になっている。

OS、ブラウザーと支援技術のバージョン、ビルド、実施日、各手順の実際の結果を記録してください。読み込み表示のコンポーネントやデータの取得処理を変更した際は、再度確認します。

## 制限

ライブリージョンと `aria-busy` の動きは、スクリーンリーダーやブラウザーによって異なります。ページそのものの読み込み中に書き込んだメッセージは、スクリーンリーダーが同時にページを読み始めると、遅れて読まれるか、読まれないことがあります。ステータスの行は読み上げ順の中に残るため、後からセクションに来た人にも「新着アイテムを読み込み中…」か件数が伝わります。このガイドでは、ページ内のあるセクションが届くまで、その代わりに表示するプレースホルダーを扱っています。ボタンを押した後のスピナーや読み込み中の文言は[読み上げられない読み込み中の表示](/ja/learn/loading-states)で、スクロールで読み込み続ける一覧は[Tabで近づくとフッターが遠ざかる無限スクロール](/ja/learn/infinite-scroll)で、新しい画面全体は[読み上げられないSPAの画面遷移](/ja/learn/route-changes)で扱っています。利用者が使うスクリーンリーダーで、実際のページを確認してください。

## スクリーンリーダーでの動き（EaseWebが実機で確認）

`role="status"`: polite のライブリージョンです。変化は、スクリーンリーダーが読み終えるまで待ちます。

| スクリーンリーダー | ブラウザーとOS | 下矢印キー | 連続読み上げ |
| --- | --- | --- | --- |
| NVDA | Chrome, Windows 11 | 未確認 | 未確認 |
| JAWS | Chrome, Windows 11 | 未確認 | 未確認 |
| VoiceOver | Safari, iOS | 未確認 | 未確認 |
| TalkBack | Chrome, Android | 未確認 | 未確認 |

`aria-busy="true"`: ページの一部が更新中だと示し、支援技術が更新の終わりを待てるようにします。

| スクリーンリーダー | ブラウザーとOS | 下矢印キー |
| --- | --- | --- |
| NVDA | Chrome, Windows 11 | 未確認 |
| JAWS | Chrome, Windows 11 | 未確認 |
| VoiceOver | Safari, iOS | 未確認 |
| TalkBack | Chrome, Android | 未確認 |

https://easeweb.dev/ja/learn/screen-reader-support

---

このガイドをコーディングエージェントで使う: MCP https://easeweb.dev/mcp（設定は https://easeweb.dev/ja/services/developers）
