プログラムによるスクロールの完了後にエフェクトをトリガーする

プログラムでセクションまたは要素にスクロールする場合(特に behavior: 'smooth' を使用する場合)、フォローアップ アクション(ハイライト状態の点滅、エントリ アニメーションの開始、フォーカスの移動など)をすぐにトリガーすると、ターゲットが画面外にあるときやスクロール中にエフェクトが実行されます。

以前は、デベロッパーは setTimeout() を使用してスクロール時間を推測するか(長いスクロールでは早すぎ、短いスクロールでは遅すぎます)、フォールバック タイマーを使用して Promise で scrollend イベント リスナーを手動でラップする必要がありました(ターゲットがすでにビューにあり、スクロールが発生しない場合、scrollend は起動しないため)。

Element(scrollIntoView()、scroll()、scrollTo()、scrollBy())と Window(scroll()、scrollTo()、scrollBy())のプログラムによるスクロール メソッドは、スクロール オペレーションが完了すると解決される Promise を返します。ターゲットがすでにリクエストされたスクロール位置にあり、スクロールが不要な場合、返された Promise はハングすることなくすぐに解決されます。

実装

  1. ハンドラを async としてマークする: スクロールをトリガーするイベント リスナーまたはナビゲーション ハンドラに async 関数を使用します。
  2. スクロール メソッドを直接待機する: await element.scrollIntoView({ behavior: 'smooth', ... })(または await element.scrollTo(...)、await window.scrollTo(...) など)を呼び出します。ブラウザがスクロール アニメーションを完了し、スクロール位置が確定するまで、実行は一時停止します。
  3. スクロール後の効果を適用する: await 式の完了後に、ハイライト クラスを切り替える、アニメーションを開始する、フォーカスを移動するなどの操作を行います。

スクロールの中断とフォーカス管理

  • 高速スクロール / 中断されたスクロールの処理: 進行中のスムーズ スクロールが完了する前に、同じスクロール コンテナで新しいスクロールが開始されると、ブラウザは進行中のスムーズ スクロールを中止し、そのコンテナで保留中のすべてのスクロール Promise を解決(拒否しない)します。ユーザーがすばやくクリックしたときに、置き換えられたスクロールのスクロール後の効果を抑制する必要がある場合は、増分オペレーション ID を追跡し、新しいスクロールが開始された場合は await の後に早期に返します。
let currentScrollId = 0;
const sections = document.querySelectorAll('section');

async function scrollToAndHighlight(targetElement) {
  const scrollId = ++currentScrollId;
  sections.forEach((sec) => sec.classList.remove('highlight'));

  await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });

  // Abort follow-up effect if another scroll was triggered while awaiting
  if (scrollId !== currentScrollId) return;

  targetElement.classList.add('highlight');
}
  • スクロール後にターゲットにフォーカスする: await targetElement.scrollIntoView(...) の後にキーボード フォーカスをターゲット要素(または tabindex="-1" を含むターゲット要素内の見出し)に移動する場合は、{ preventScroll: true } を focus() に渡して、フォーカス呼び出しが 2 回目の競合するスクロールを開始しないようにします。
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetHeading.focus({ preventScroll: true });

制約とベスト プラクティス

  • 必須: setTimeout() でスクロール時間を推測するのではなく、scrollIntoView()、scroll()、scrollTo()、scrollBy() から直接返される Promise を待機します。
  • scrollend を手動の new Promise() でラップして、メインの実装として使用しないでください。scrollend は、ターゲットがすでに表示されていて、スクロール距離が移動していない場合は、トリガーされません。一方、スクロール メソッドの Promise は、その場合に自動的に解決されます。
  • 推奨: スクロール メソッドを待機した後に .focus() を呼び出すときに { preventScroll: true } を渡して、ブラウザが 2 回目にジャンプまたはスクロールしないようにします。
  • 推奨: ユーザーの操作が速いと、同じコンテナでスムーズ スクロールが重複してトリガーされる可能性があるため、スクロール後の効果をリクエスト トークンで保護します。スムーズ スクロールを中断すると、前の Promise が(拒否されるのではなく)解決されるためです。
  • ルート スクロールに関する Chrome の既知のバグ: Chrome には、ドキュメントのルート スクロールがスクロールしているときに、scrollIntoView() によって返される Promise が早すぎるタイミングで解決されるというバグがあります。ネストされたスクロール コンテナで scrollIntoView() を待機すると、想定どおりに動作します。このバグの回避策として、overflow-y: auto を含む div などのスクロール コンテナでコンテンツをラップしないでください。

代替戦略

スクロール メソッドの Promise をサポートしていないブラウザでは、scrollIntoView()、scroll()、scrollTo()、scrollBy() は Promise ではなく undefined を返します。

  • プログレッシブ エンハンスメント(デフォルトの await の動作): await undefined は、次のマイクロタスクですぐに解決される有効な JavaScript であるため、await element.scrollIntoView({ behavior: 'smooth' }) を記述しても、サポートされていないブラウザでエラーはスローされません。代わりに、スクロールが開始されるとすぐにスクロール後のコードを実行することで、適切にパフォーマンスを低下させます。一時的なハイライトなど、重要でない視覚的な装飾の場合は、これで十分なことがよくあります。
  • 信頼性の高いフォールバック(scrollend + タイムアウト): Baseline ターゲットが scroll-promises をサポートしておらず、古いブラウザでスクロールが完了するまでスクロール後のアクションを待機する必要がある場合は、戻り値が Promise(result instanceof Promise)かどうかを確認します。undefined の場合は、要素がすでに表示されていてスクロールが発生しない場合に備えて、短いフォールバック タイムアウトを設定して、スクロール コンテナで scrollend イベントを待機します。
function scrollIntoViewAsync(element, options, scroller = window) {
  const result = element.scrollIntoView(options);

  // Feature detect: in supporting browsers, scrollIntoView() returns a Promise
  if (result instanceof Promise) {
    return result;
  }

  // Fallback for browsers where scrollIntoView() returns undefined
  return new Promise((resolve) => {
    let timeoutId;

    const finish = () => {
      clearTimeout(timeoutId);
      scroller.removeEventListener('scrollend', finish);
      scroller.removeEventListener('scroll', onScroll);
      resolve();
    };

    const onScroll = () => {
      // Restart the idle timer on every scroll event. If scrollend never fires
      // (browsers without scrollend support), this resolves once scrolling goes idle.
      clearTimeout(timeoutId);
      timeoutId = setTimeout(finish, 100);
    };

    scroller.addEventListener('scrollend', finish, { once: true });
    scroller.addEventListener('scroll', onScroll, { passive: true });

    // If no scroll event fires within the idle window (e.g., already in view), resolve.
    // 100ms is an example value; it only needs to exceed the gap between scroll events.
    timeoutId = setTimeout(finish, 100);
  });
}