プログラムでセクションまたは要素にスクロールする場合(特に behavior: 'smooth' を使用する場合)、フォローアップ アクション(ハイライト状態の点滅、エントリ アニメーションの開始、フォーカスの移動など)をすぐにトリガーすると、ターゲットが画面外にあるときやスクロール中にエフェクトが実行されます。
以前は、デベロッパーは setTimeout() を使用してスクロール時間を推測するか(長いスクロールでは早すぎ、短いスクロールでは遅すぎます)、フォールバック タイマーを使用して Promise で scrollend イベント リスナーを手動でラップする必要がありました(ターゲットがすでにビューにあり、スクロールが発生しない場合、scrollend は起動しないため)。
Element(scrollIntoView()、scroll()、scrollTo()、scrollBy())と Window(scroll()、scrollTo()、scrollBy())のプログラムによるスクロール メソッドは、スクロール オペレーションが完了すると解決される Promise を返します。ターゲットがすでにリクエストされたスクロール位置にあり、スクロールが不要な場合、返された Promise はハングすることなくすぐに解決されます。
実装
- ハンドラを
asyncとしてマークする: スクロールをトリガーするイベント リスナーまたはナビゲーション ハンドラにasync関数を使用します。 - スクロール メソッドを直接待機する:
await element.scrollIntoView({ behavior: 'smooth', ... })(またはawait element.scrollTo(...)、await window.scrollTo(...)など)を呼び出します。ブラウザがスクロール アニメーションを完了し、スクロール位置が確定するまで、実行は一時停止します。 - スクロール後の効果を適用する:
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);
});
}