# Tabで近づくとフッターが遠ざかる無限スクロール

Source: https://easeweb.dev/learn/infinite-scroll
Topics: 複雑なウィジェット > 無限スクロールでもフッターに届き、追加された項目が読み上げられる (WCAG 2.1.1, 2.4.3, 4.1.3)
The fix: 次の項目は、一覧の後ろの「さらに表示」ボタンが押されたときにだけ読み込み、最初の新しい項目へフォーカスを移して、新しい件数を role="status" の行に書き込みます。スクロールで読み込み続ける必要がある一覧は、Ctrl + Endでその後ろへフォーカスを移せる role="feed" にし、そのことをページ上に書き、読み込み中のメッセージと件数を同じようなステータスの行に入れます。どちらの場合も、一覧を作り直さず、新しい項目を今ある項目の後ろに追加します。
Test with: keyboard, screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html 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/WAI/ARIA/apg/patterns/feed/ https://www.w3.org/WAI/ARIA/apg/patterns/feed/examples/feed/ https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/feed_role

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

## 場面

あるショップのカテゴリーページには48個のマグがあり、12個ずつ表示されます。ページ番号のリンクもボタンもありません。一覧の終わりが見えてくると、最後の行の下に「マグを読み込んでいます…」と表示され、少しすると12個のマグが加わって、その表示は消えます。マウスや指でスクロールしている限り、一覧はどこまでも続き、それがこのデザインのねらいです。一覧の下のフッターには、「お問い合わせ」と「返品」のリンクがあります。

マウスを使わずに、Tabキーで「お問い合わせ」へ向かってみます。フォーカスはマグからマグへ移ります。最後の数行に来るたびに、フォーカスのあるマグを見せるためにページがスクロールし、一覧の終わりが近づいて、フォーカスとフッターの間に12個のマグが追加されます。次にTabキーを押すと、その新しいマグに入ります。「お問い合わせ」に届くのは、48個すべてが読み込まれ、Tabキーでそのすべてを通り過ぎた後です。終わりのない一覧では、いつまでも届きません。その間、スクリーンリーダーは何も言いません。「マグを読み込んでいます…」はただの `div` に出ては消え、新しいマグは音もなく加わります。

もう1つの版では、追加のたびに `list.innerHTML += html` を使っています。この1行は一覧のマグをすべて作り直すため、フォーカスのあったマグもほかのマグと一緒に消え、フォーカスはページそのものに移ります。Chromeでは、次にTabキーを押すと最初のマグに戻ります。12個読み込むたびに、キーボードの利用者は一覧の先頭へ戻され、スクリーンリーダーの利用者は読んでいた位置を見失います。

## 確認できる問題

達成基準2.1.1「キーボード」は、ページでできることをすべてキーボードでもできることを求めています。「お問い合わせ」と「返品」は普通のリンクですが、Tabキーで届くのは、読み込むものがなくなったときだけです。フォーカスが近づくたびに、その手前で一覧が伸びるからです。このページでは48個すべてのマグを通る必要があり、終わりのない一覧では、フッターのリンクにキーボードからはまったく届きません。フッターは最初からページにあるため、スクリーンリーダーならランドマークのキーで直接移動できますが、Tabキーにはそうしたキーがありません。

達成基準2.4.3「フォーカス順序」は、ページの意味と操作性を保つ順序でフォーカスが移ることを求めています。一覧が作り直されると、フォーカスのあったマグはもう存在せず、ブラウザーはフォーカスをページそのものに置くしかありません。次にTabキーを押したときの移動先は、ページではなくブラウザーが決めます。Chromeでは最初のマグです。マグの並びは変わっていなくても、読み込むたびに、その中で自分がどこにいたかが分からなくなります。

達成基準4.1.3「ステータスメッセージ」は、さらに読み込むのを待っているといったページの状態を知らせるメッセージが、フォーカスを受けずにスクリーンリーダーへ届くことを求めています。「マグを読み込んでいます…」はそうしたメッセージですが、役割のないただの `div` に表示されるため、スクリーンリーダーには伝わりません。これは失敗例F103にあたります。マグそのものはステータスメッセージではなくコンテンツで、4.1.3は1つずつ読み上げることまでは求めていません。一方、表示中の件数をページに出す場合、その件数はステータスメッセージです。

## 利用者への影響

無限スクロールは、ショップのカテゴリーページや検索結果、ニュースサイト、SNSのフィードでよく見られ、スマートフォン向けのレイアウトでは特に多く使われます。1つの共通の一覧部品から作られていることが多いため、同じ問題がサイト内のすべての一覧で繰り返されます。遠ざかるフッターには、問い合わせ、配送、返品、アクセシビリティ方針といった、困ったときに探すリンクが置かれていることがよくあります。

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

どのマグも名前のあるリンクで、フッターも `footer` 要素です。マークアップとして不正な箇所はないため、axeが報告することはありません。フッターに届くか、読み込みの後もフォーカスが残るかは、ページを実際に操作して初めて分かります。

EaseWebの診断ツールは、ページを最後までスクロールしてフッターより前のリンクを数え、Tabキーで進んだときのように、その最後のリンクにフォーカスを置きます。自動で伸びる一覧は、人が確認する項目として渡します。一覧がすぐに終わるか、一覧を飛ばすキーがあるかは、人にしか判断できないからです。読み込みでフォーカスのあるリンクが消え、フォーカスがページに残る場合は、コードだけで証明できるため、2.4.3の問題として報告します。ライブリージョンがないページに読み込み中の表示があれば、それも人が確認する項目として渡します。読み上げられる内容が聞く人にとって筋が通っているかは、どの確認でも分かりません。

AIによる修正は、ここでARIAに頼りがちです。一覧全体に `aria-live="polite"` を付けると、スクリーンリーダーは読み込むたびに12個のマグの名前をすべて読み上げます。伝わるのは件数ではなく、雑音です。`role="feed"` を付けるだけでは、Tabキーを使う人には何も変わりません。フィードを飛ばすキーは、ページのスクリプトが用意するものだからです。

## 修正前：スクロールで読み込むマグの一覧

```html
<main>
  <h1>マグ</h1>
  <ul class="products" data-total="48">
    <li>
      <a href="/mugs/enamel-white">
        <img src="/images/enamel-white.jpg" alt="">
        ホーローのマグ（白）
      </a>
    </li>
    …
  </ul>
  <div class="loading" hidden>マグを読み込んでいます…</div>
  <div class="sentinel"></div>
</main>
<footer>
  <a href="/contact">お問い合わせ</a>
  <a href="/returns">返品</a>
</footer>
```

```js
const list = document.querySelector('.products');
const loading = document.querySelector('.loading');
const sentinel = document.querySelector('.sentinel');
const total = Number(list.dataset.total);
let page = 1;
let busy = false;
const observer = new IntersectionObserver(async ([entry]) => {
  if (!entry.isIntersecting || busy) return;
  busy = true;
  loading.hidden = false;
  const response = await fetch(`/mugs?page=${page + 1}&partial=cards`);
  list.insertAdjacentHTML('beforeend', await response.text());
  page += 1;
  loading.hidden = true;
  busy = false;
  if (list.children.length >= total) observer.disconnect();
}, { rootMargin: '0px 0px 200px 0px' });
observer.observe(sentinel);
```

一覧の後ろにある空の `div` を監視し、ウィンドウから200ピクセル以内に入ったところで、次の12個のマグを取得してフッターの手前に追加します。最後の数個のマグにフォーカスを置くと、ページがそこまでスクロールして読み込みが始まるため、Tabキーはフッターの前で必ず新しいマグに行き当たります。読み込み中の表示はただの `div` で出し入れされ、スクリーンリーダーには伝わりません。

## 修正前：同じ一覧を、追加のたびに作り直す

```html
<main>
  <h1>マグ</h1>
  <ul class="products" data-total="48">
    <li>
      <a href="/mugs/enamel-white">
        <img src="/images/enamel-white.jpg" alt="">
        ホーローのマグ（白）
      </a>
    </li>
    …
  </ul>
  <div class="loading" hidden>マグを読み込んでいます…</div>
  <div class="sentinel"></div>
</main>
<footer>
  <a href="/contact">お問い合わせ</a>
  <a href="/returns">返品</a>
</footer>
```

```js
const list = document.querySelector('.products');
const loading = document.querySelector('.loading');
const sentinel = document.querySelector('.sentinel');
const total = Number(list.dataset.total);
let page = 1;
let busy = false;
const observer = new IntersectionObserver(async ([entry]) => {
  if (!entry.isIntersecting || busy) return;
  busy = true;
  loading.hidden = false;
  const response = await fetch(`/mugs?page=${page + 1}&partial=cards`);
  list.innerHTML += await response.text();
  page += 1;
  loading.hidden = true;
  busy = false;
  if (list.children.length >= total) observer.disconnect();
}, { rootMargin: '0px 0px 200px 0px' });
observer.observe(sentinel);
```

違いは1行だけです。`innerHTML +=` は一覧のマークアップを文字列として読み、新しいマグを足して、その結果からすべてのマグを作り直します。フォーカスのあったマグも捨てられるため、フォーカスはページに落ち、次にTabキーを押すと、ブラウザーが決めた位置から移動が始まります。フッターに届かない問題と、読み上げられない読み込み中の表示は、1つ目の版と変わりません。

## 修正後：「さらに表示」ボタンとステータスの行

```html
<main>
  <h1>マグ</h1>
  <ul class="products" data-total="48">
    <li>
      <a href="/mugs/enamel-white">
        <img src="/images/enamel-white.jpg" alt="">
        ホーローのマグ（白）
      </a>
    </li>
    …
  </ul>
  <p class="count" role="status">48個中12個を表示</p>
  <button type="button" class="load-more">さらに12個表示</button>
</main>
<footer>
  <a href="/contact">お問い合わせ</a>
  <a href="/returns">返品</a>
</footer>
```

```js
const list = document.querySelector('.products');
const count = document.querySelector('.count');
const button = document.querySelector('.load-more');
const total = Number(list.dataset.total);
let page = 1;
let busy = false;
button.addEventListener('click', async () => {
  if (busy) return;
  busy = true;
  count.textContent = 'マグを読み込んでいます…';
  const response = await fetch(`/mugs?page=${page + 1}&partial=cards`);
  const first = list.children.length;
  list.insertAdjacentHTML('beforeend', await response.text());
  page += 1;
  // フォーカスを最初の新しいマグへ移し、件数はその後に書く。
  list.children[first].querySelector('a').focus();
  count.textContent = `${total}個中${list.children.length}個を表示`;
  button.hidden = list.children.length >= total;
  busy = false;
});
```

ボタンが押されるまで何も読み込まないため、フッターは動かず、最後のマグ、ボタンと進めば、次のTabキーで届きます。HTML標準のボタンなので、キー操作の処理を書かなくても、EnterキーとSpaceキーで押せます。新しいマグは今あるマグの後ろに追加されるため、フォーカスのあった要素は消えず、フォーカスは最初の新しいマグへ移り、利用者はそこから続きを読めます。ステータスの行は最初からページにあるため、「マグを読み込んでいます…」、続いて「48個中24個を表示」と書き込まれるたびに、スクリーンリーダーはフォーカスを移さずに読み上げます。この行は、内容を変えるボタンのすぐそば、利用者が目を向ける場所に置いています。すべてのマグを表示すると、ボタンはなくなり、行は「48個中48個を表示」になります。

## 修正後：スクロールで読み込み続けるフィード

```html
<main>
  <h1 id="mugs-title">マグ</h1>
  <p class="feed-keys">一覧では、Page DownとPage Upでマグからマグへ移動し、Ctrl+Endで一覧の後ろへ移動します。</p>
  <div class="products" role="feed" aria-labelledby="mugs-title" aria-busy="false" data-total="48">
    <article aria-posinset="1" aria-setsize="48" aria-labelledby="mug-1" tabindex="-1">
      <a href="/mugs/enamel-white" id="mug-1">
        <img src="/images/enamel-white.jpg" alt="">
        ホーローのマグ（白）
      </a>
    </article>
    …
  </div>
  <p class="count" role="status">48個中12個を表示</p>
  <div class="sentinel"></div>
</main>
<footer>
  <a href="/contact">お問い合わせ</a>
  <a href="/returns">返品</a>
</footer>
```

```js
const feed = document.querySelector('[role="feed"]');
const count = document.querySelector('.count');
const sentinel = document.querySelector('.sentinel');
const total = Number(feed.dataset.total);
let page = 1;
let busy = false;
// フォーカスがフィードより後ろにある間は読み込まず、Ctrl+Endで移ったフッターをその場に保つ。
const past = (el) => !feed.contains(el) && feed.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING;
const observer = new IntersectionObserver(async ([entry]) => {
  if (!entry.isIntersecting || busy || past(document.activeElement)) return;
  busy = true;
  feed.setAttribute('aria-busy', 'true');
  count.textContent = 'マグを読み込んでいます…';
  const response = await fetch(`/mugs?page=${page + 1}&partial=articles`);
  feed.insertAdjacentHTML('beforeend', await response.text());
  page += 1;
  feed.setAttribute('aria-busy', 'false');
  count.textContent = `${total}個中${feed.children.length}個を表示`;
  busy = false;
  if (feed.children.length >= total) observer.disconnect();
}, { rootMargin: '0px 0px 200px 0px' });
observer.observe(sentinel);

// ARIA Authoring Practicesが勧める、フィードのキー。
const FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])';
const outside = (el) => !feed.contains(el) && !el.contains(feed);
feed.addEventListener('keydown', (event) => {
  const article = event.target.closest('article');
  if (!article) return;
  const all = [...document.querySelectorAll(FOCUSABLE)].filter(outside);
  if (event.key === 'PageDown' && article.nextElementSibling) {
    article.nextElementSibling.focus();
  } else if (event.key === 'PageUp' && article.previousElementSibling) {
    article.previousElementSibling.focus();
  } else if (event.ctrlKey && event.key === 'End') {
    all.find((el) => feed.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)?.focus();
  } else if (event.ctrlKey && event.key === 'Home') {
    all.findLast((el) => feed.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)?.focus();
  } else {
    return;
  }
  event.preventDefault();
});
```

一覧はスクロールで読み込み続けますが、今度はフィードです。フィードは、読み進めるにつれて記事が追加されていく領域を表すARIAのパターンで、まさにこうした一覧に向いています。キーは、ARIA Authoring Practicesが勧めるとおり、ページのスクリプトが用意します。Page DownとPage Upでマグからマグへフォーカスが移り、Ctrl + Endでフィードの後ろの最初のリンク「お問い合わせ」へ移るため、フッターにキーボードで届きます。フォーカスがフィードより後ろにある間は、何も読み込みません。この確認がないと、フッターが見えたことでさらに12個のマグが読み込まれ、フォーカスのあるリンクが画面の外へ押し出されます。これらのキーは一覧の上の行に書いてあります。画面を見ながらキーボードを使う人には、ほかに知る手段がないからです。各マグは `aria-posinset` と `aria-setsize` を持つ `article` で、これらの属性に対応したスクリーンリーダーなら、そのマグが48個中の何番目かを伝えられます。`aria-busy` は、マグを追加している間だけtrueになり、読み込み中のメッセージと件数は、もう1つの修正と同じようなステータスの行に入ります。`article` には `tabindex="-1"` を付けているため、これらのキーではフォーカスが移りますが、Tabキーでは止まりません。各マグのリンクが、すでにTabキーの移動先になっているからです。新しいマグは今あるマグの後ろに追加されるため、フォーカスが落ちることはありません。

これで2.1.1は満たしますが、そのキーは一覧の上の行を読んで覚えてもらうしかありません。Tabキーは今も新しいマグに毎回入り、フィードの扱いはスクリーンリーダーによって異なります。Authoring Practicesも、特にスマートフォンなどで対応に差がある場合があると注意しています。

## どの方法を選ぶか

どちらも基準を満たしますが、「さらに表示」ボタンを使ってください。HTML標準のボタンと数行のスクリプトですべてを満たし、マウス、キーボード、スクリーンリーダー、音声操作のどれでも同じように動きます。マグをいつ増やすかは利用者が決めるため、フッターには常にボタンの次のTabキーで届きます。フィードなら無限スクロールを残せますが、利用者が覚えなければならないキーと、対応にばらつきのあるパターンを抱えることになります。そのため、それだけでは不十分としています。デザイン上どうしてもスクロールで読み込む必要があるときにだけ選び、訪問者が使うスクリーンリーダーでテストしてください。ページ番号のリンクも基準を満たし、戻ったり、ブックマークしたり、共有したりできる結果のページは、多くの場合さらに使いやすくなります。行き先を伝えるページ番号の作り方は、[現在のページも行き先も伝えないページネーション](/ja/learn/pagination)で扱っています。

## 実装の判断

フレームワークでも、`innerHTML +=` と同じようにフォーカスが落ちることがあります。項目に安定したキーがない一覧や、読み込むたびに新しい一覧に差し替える一覧は、一から作り直され、フォーカスのある項目も一緒に消えます。各項目のキーには、商品IDのような、その項目に固有の値を使い、次のページは同じ一覧の最後に追加します。

「さらに表示」の後は、ボタンではなく最初の新しいマグへフォーカスを移します。ボタンに残すと、新しいマグはフォーカスより前に来るため、次のTabキーはそのすべてを飛ばしてフッターへ進み、利用者は自分で読み込んだマグを探しに戻らなければなりません。件数はフォーカスを移した後に書き込みます。こうすると、まず新しいマグの名前が読み上げられ、politeの通知である件数はその後に続きます。

読み込みが遅いときや失敗したときも、伝える必要があります。同じステータスの行に「マグを読み込めませんでした。もう一度お試しください。」と書き、ボタンはそのまま残します。読み込み中に何を、いつ伝えるかは、[読み上げられない読み込み中の表示](/ja/learn/loading-states)で扱っています。絞り込みや並べ替えで一覧が入れ替わるときは、結果の件数を伝えます。[件数が読み上げられない検索・絞り込み結果](/ja/learn/search-results)を参照してください。

ボタンを最初は `/mugs?page=2` のような次のページへのリンクにしておき、スクリプトでボタンに変えることもできます。こうすると、スクリプトの読み込み前や失敗したときにも動きます。表示中のマグの数を `history.replaceState()` でアドレスに書いておくと、「戻る」や共有したリンクで一覧の同じ位置に戻れます。

最初の1、2回はスクロールで読み込み、その後に「さらに表示」ボタンを出す一覧もあります。ボタンが決まった回数の読み込みの後に出て、フッターまでに通るマグの数が決まっている限り、基準を満たします。全体の数が分からないフィードは `aria-setsize="-1"` とします。新しい記事を読み込むときに古い記事を取り除くフィードは、すべての記事の `aria-posinset` を正しく保ち、フォーカスのある記事を取り除かないようにします。Macのキーボードの多くには、End、Page Up、Page Downのキーがなく、Fnキーと矢印キーで代わりに入力します。Macでもフィードのキーをテストしてください。

## 修正の確認

1. マウスを使わずに、ページの先頭から「お問い合わせ」にフォーカスが届くまでTabキーを押す。ボタンの場合は、表示中のマグとボタンを経てフッターに届く。フィードの場合は、どのマグからでもCtrl + Endで届く。
2. マグをさらに読み込む。「さらに12個表示」を押すか、フィードではさらに読み込まれるまでTabキーで進む。ボタンの場合は、フォーカスは最初の新しいマグにある。フィードの場合は、元のマグに残っている。どちらでも、フォーカスがページそのものに残ることはない。
3. スクリーンリーダーで、マグをさらに読み込む。「マグを読み込んでいます…」、続いて「48個中24個を表示」がそれぞれ1回読み上げられ、フォーカスも読んでいる位置も、その行へは移らない。
4. フィードのキーを確かめる。Page DownとPage Upでマグを1つずつ移動し、Ctrl + Homeで一覧の前のリンクへ戻る。対応したスクリーンリーダーでは、「48個中13個目」のように各マグの位置が読み上げられる。
5. 48個すべてのマグが表示されるまで読み込む。行は「48個中48個を表示」になり、ボタンはなくなり、どこまでスクロールしても、それ以上は読み込まれない。

OS、ブラウザーと支援技術のバージョン、ビルド、実施日、各手順の実際の結果を記録してください。一覧、その部品、ライブラリーを変更した際は、再度確認します。

## 制限

このサンプルは、どの一覧でも同じ結果になることを保証するものではありません。スクリプトは最小限の例で、エラー、絞り込み、並べ替え、ブラウザーの「戻る」ボタンを扱っていません。画面外にスクロールした行を取り除く仮想化ライブラリーで描く一覧には、別の確認が必要です。リンクと「かごに入れる」ボタンのように、1枚の商品カードに複数のコントロールがあると、一覧を通り過ぎるための操作が倍以上になり、「さらに表示」ボタンはいっそう重要になります。ページの中のスクロール領域がスクロールで読み込み続ける場合の届き方は、[キーボードでスクロールできないスクロール領域](/ja/learn/scrollable-regions)で扱っています。訪問者が使う支援技術で、一連の操作全体をテストしてください。

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

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

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

`role="feed"`: 読み進めると記事が追加されていく領域です。ARIAのパターンは、記事の間や領域の外へ移るキーを、スクリーンリーダーが用意することを想定しています。

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

`aria-posinset, aria-setsize`: すべての項目がページにないときに、項目の位置と、全体の数を示します。

| スクリーンリーダー | ブラウザーと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）
