Intersection Observer APIを活用したスクロール進捗バーとナビゲーションハイライトの実装方法を、実案件の事例を交えながら解説します。
「読んでいる位置がわからない」——ユーザーが離脱する前に
こんな悩みはありませんか?
- ランディングページやブログ記事を作ったものの、ユーザーがどこまで読んだか把握しにくい
- ページ内ナビゲーションはあるが、現在地がわかりにくくてUXが悪い
scrollイベントで似たような実装を試みたら、スクロールのたびに処理が走ってパフォーマンスがガタ落ちした
特に3つ目は、フロントエンド開発をしばらく経験したエンジニアが一度は踏む落とし穴です。スクロール進捗バーは「見た目のわりに実装が難しい」代表格で、Intersection Observer APIを正しく理解しているかどうかで、コードの品質とページのパフォーマンスに大きな差が出ます。
この記事では、実際の案件で培った知見をもとに、スクロール進捗バーとセクションハイライトナビゲーションをパフォーマンスを意識しながら実装する手順を解説します。
なぜscrollイベントではなくIntersection Observerなのか
従来、スクロール連動UIといえばwindow.addEventListener('scroll', handler)が定番でした。しかしこのアプローチには大きな問題があります。スクロールイベントは毎秒数十〜数百回発火します。各発火のたびにscrollTopやgetBoundingClientRect()を呼ぶと、強制的なレイアウト再計算(リフロー)が発生し、ジャンクと呼ばれるカクつきの原因になります。
Intersection Observer APIはブラウザのネイティブ機能として、要素がビューポートに入ったか・出たかを非同期で通知します。メインスレッドをブロックせず、必要なタイミングだけコールバックが走るため、パフォーマンスへの影響が最小限です。
ある企業の採用サイトリニューアル案件では、既存実装のscrollイベントベースのナビゲーションハイライトが原因でモバイル端末でのFPS(フレームレート)が著しく低下していました。Intersection Observerへの置き換えにより、スクロール時のCPU使用率が約60%改善し、体感速度が大幅に向上した事例があります。
実装手順:スクロール進捗バーとセクションハイライト
HTML構造を整える
まずベースとなるHTML構造です。ページ最上部に進捗バー要素を置き、各セクションにはidを付与します。ナビゲーションの<a>タグには対応するhrefとdata-nav属性を設定しておきます。
<!-- スクロール進捗バー -->
<div id="progress-bar"></div>
<!-- ページ内ナビゲーション -->
<nav id="page-nav">
<a href="#section-1" data-nav="section-1">はじめに</a>
<a href="#section-2" data-nav="section-2">背景</a>
<a href="#section-3" data-nav="section-3">解決策</a>
<a href="#section-4" data-nav="section-4">まとめ</a>
</nav>
<!-- コンテンツセクション -->
<section id="section-1">...</section>
<section id="section-2">...</section>
<section id="section-3">...</section>
<section id="section-4">...</section>
CSSでスタイルを定義
#progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
width: 0%;
background: linear-gradient(90deg, #3B82F6, #8B5CF6);
z-index: 9999;
transition: width 0.1s linear;
}
#page-nav a {
color: #6B7280;
transition: color 0.2s, font-weight 0.2s;
}
#page-nav a.active {
color: #3B82F6;
font-weight: 700;
}
JavaScriptで進捗バーを実装
進捗バーはscrollイベントで実装しますが、requestAnimationFrameでスロットリングし、描画タイミングに合わせて処理します。
const progressBar = document.getElementById('progress-bar');
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
progressBar.style.width = `${Math.min(progress, 100)}%`;
ticking = false;
});
ticking = true;
}
});
ポイント:
tickingフラグを使ったこのパターンを「rAFスロットリング」と呼びます。ブラウザの描画フレームに同期するため、無駄な計算をせずに済みます。
Intersection Observerでセクションハイライトを実装
const navLinks = document.querySelectorAll('[data-nav]');
const sections = document.querySelectorAll('section[id]');
const observerOptions = {
root: null, // ビューポートを基準
rootMargin: '-40% 0px -55% 0px', // 画面中央付近を検知
threshold: 0 // 少しでも入ったら発火
};
const sectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const id = entry.target.id;
// 既存のactiveクラスをすべて除去
navLinks.forEach(link => link.classList.remove('active'));
// 対応するナビリンクにactiveを付与
const activeLink = document.querySelector(`[data-nav="${id}"]`);
if (activeLink) activeLink.classList.add('active');
});
}, observerOptions);
sections.forEach(section => sectionObserver.observe(section));
rootMarginの値は要調整ポイントです。-40% 0px -55% 0pxは「ビューポートの上から40%〜下から55%の範囲(つまり画面中央付近5%)に入ったとき」という意味で、自然なタイミングでハイライトが切り替わります。セクションの高さやデザインによってチューニングが必要です。
よくある失敗パターンと対処法
❌ 失敗1:thresholdを1に設定してしまう
threshold: 1は「要素が100%ビューポート内に収まったとき」を意味します。セクションの高さがビューポートより大きい場合、永遠に発火しません。長いセクションにはthreshold: 0か0.1程度が適切です。
❌ 失敗2:Observer を使い捨てせず大量生成する
SPAやNext.jsなどでコンポーネントのアンマウント時にObserverをdisconnect()しないと、メモリリークの原因になります。ReactならuseEffectのクリーンアップ関数、Vue.jsならbeforeUnmountで必ず解放してください。
// React useEffectでのクリーンアップ例
useEffect(() => {
const observer = new IntersectionObserver(callback, options);
sections.forEach(s => observer.observe(s));
return () => observer.disconnect(); // ← 必須
}, []);
❌ 失敗3:rootMarginを固定値(px)で設定する
ビューポートサイズが変わると意図した動作をしなくなります。%指定を基本にし、ウィンドウリサイズ時に再設定する仕組みを用意するか、パーセント指定で吸収するのがベストプラクティスです。
❌ 失敗4:進捗バーのwidthをCSSトランジションなしで動かす
transitionなしだとスクロールのたびにカクカクと動き、ユーザーの視覚的ストレスになります。ただしtransitionの時間を長くしすぎると「遅れて追いかけてくる」違和感が出るため、0.05s〜0.15s linear程度が最適です。
Web制作・UI改善もお任せください
フロントエンド開発
モダンな技術で、使いやすく美しいWebサイトを実現します
※ 通常1営業日以内にご返信します
まとめと次のステップ
Intersection Observer APIを正しく活用することで、パフォーマンスを犠牲にせずにリッチなスクロールUIが実現できます。今回の実装を応用すれば、スクロール連動のフェードインアニメーションや、無限スクロールの読み込みトリガーなど、さまざまなUIパターンにも展開できます。
また、Next.jsやNuxtのようなSSRフレームワーク上では、windowやdocumentへの参照タイミングに注意が必要です。クライアントサイドでのみ実行される保証(useEffectやonMountedなど)を忘れずに組み込んでください。
「実装はできるが、自分のサイトに最適な形にチューニングするのが難しい」「既存サイトのパフォーマンス問題を根本から見直したい」という場合は、お気軽にFivenine Designまでご相談ください。実際のコードを見ながら課題を整理し、最適な実装方針をご提案します。