JavaScriptでスクロール位置を取得するには window.scrollY(ページ全体)または 要素.scrollTop(スクロールできる要素)を読み、移動するには scrollTo()・scrollBy()・scrollIntoView() を使います。 取得と移動のプロパティ・メソッドは名前が似ていて、「どこからの距離なのか」「どこを基準に動くのか」がそれぞれ違うため、ここを整理すると迷わなくなります。

この記事では、scrollY・scrollTop・getBoundingClientRect・offsetTop が返す値の早見表と、scrollTo・scrollBy・scrollIntoView の引数一覧を最初に示し、固定ヘッダーで位置がずれる問題を scroll-margin-top で直す例、scrollイベントの passive 指定と間引き、IntersectionObserver や scrollend への置き換え、ページ最下部の判定・トップへ戻るボタン・スクロール率バーといった定番コードをコピペできる形でまとめます。数値と挙動は Chromium 141.0.7390.37(Playwright のヘッドレス実行、表示領域 1000×600px)で測ったものです。

スクロール位置の取得・移動の早見表

取得:どこからの距離を返すか

書き方 返す値(どこからどこまでの距離か) スクロールで変わるか
window.scrollY ページ先頭から、いま見えている範囲の上端まで 変わる
window.scrollX ページ左端から、いま見えている範囲の左端まで 変わる
document.documentElement.scrollTop scrollY と同じ(ページ全体の縦スクロール量) 変わる
要素.scrollTop その要素の中身が、上にどれだけスクロールされているか 変わる(その要素のスクロールで)
要素.getBoundingClientRect().top 表示領域(ビューポート)の上端から、要素の上端まで 変わる
要素.getBoundingClientRect().top + window.scrollY ページ先頭から、要素の上端まで 変わらない
要素.offsetTop offsetParent(位置指定された直近の祖先)の上端から、要素の上端まで 変わらない
要素.scrollHeight 要素の中身全体の高さ(隠れている部分を含む) 変わらない
要素.clientHeight 要素の見えている部分の高さ(枠線・スクロールバーを除く) 変わらない
window.innerHeight 表示領域の高さ(横スクロールバーがあれば含む) 変わらない

移動:何を基準に動くか

書き方 動き 基準
window.scrollTo(0, 500) ページ先頭から 500px の位置へ 絶対位置
window.scrollTo({ top: 500, behavior: 'smooth' }) 同じ位置へ、なめらかに 絶対位置
window.scrollBy(0, 100) いまの位置から 100px 下へ 相対位置
要素.scrollIntoView() その要素が見える位置へ 要素
要素.scrollIntoView({ behavior: 'smooth', block: 'center' }) 要素を表示領域の中央へ、なめらかに 要素
要素.scrollTop = 0 / 要素.scrollTo(0, 0) スクロールできる要素の中身を先頭へ その要素の中
CSS scroll-margin-top: 64px 移動先を 64px 手前で止める 固定ヘッダーの高さ分の調整
CSS html { scroll-behavior: smooth; } ページ内リンクなどの移動をなめらかにする ページ全体

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「JavaScriptスクロール位置の取得と移動|scrollTo・イベント早見表」

スクロール位置を取得する

ページ全体のスクロール量:scrollY

ページがどれだけスクロールされているかは window.scrollY で取得します(window. は省略できます)。

console.log(window.scrollY); // 縦方向のスクロール量(px)
console.log(window.scrollX); // 横方向のスクロール量(px)

同じ値を返す書き方がいくつかあります。Chromium 141 で scrollTo(0, 500) を実行した直後の値を測りました。

書き方 測った値 補足
window.scrollY 500 いま使うならこれ
window.pageYOffset 500 scrollY の別名。以前からあるコードで見かける
document.documentElement.scrollTop 500 html 要素のスクロール量
document.scrollingElement.scrollTop 500 「ページをスクロールしている要素」を自動で指す
document.body.scrollTop 0 標準モードでは body ではなく html がスクロールする

document.body.scrollTop は、今回の測定(<!doctype html> を書いた標準モードのページ)では常に 0 でした。ページ全体の位置は window.scrollY で読むと覚えておけば十分です。

値は小数になることがあります。表示倍率を変えている環境などでは scrollY が 499.5 のような値になるので、比較するときは Math.round() や Math.ceil() で丸めるか、1px 程度の余裕を持たせます。

要素の中のスクロール量:scrollTop

overflow: auto や overflow: scroll を指定した要素は、その要素の中だけがスクロールします。この場合は要素の scrollTop を読みます。

<div id="box" style="height: 200px; overflow: auto; border: 1px solid #999;">
  <div style="height: 1000px;">長い中身</div>
</div>
const box = document.getElementById('box');
box.scrollTop = 300; // 代入すると、その位置へ移動する

console.log(box.scrollTop);    // 300
console.log(box.scrollHeight); // 1000(中身全体の高さ)
console.log(box.clientHeight); // 198(見えている高さ。枠線 上下1pxずつを除く)
console.log(box.offsetHeight); // 200(枠線を含む高さ)
console.log(box.scrollHeight - box.clientHeight); // 802(scrollTop の最大値)

コメントの数値は Chromium 141 で測った値です。scrollTop の最大値は scrollHeight - clientHeight で、これが最下部の判定の基礎になります。

プロパティ 意味 上の例の値
scrollTop 上に隠れている分の高さ 300
clientHeight 見えている高さ(padding を含み、枠線とスクロールバーを除く) 198
offsetHeight 枠線まで含めた要素の高さ 200
scrollHeight 中身全体の高さ 1000

要素の位置:getBoundingClientRect と offsetTop の違い

「ある要素がページのどこにあるか」を知る方法は2つあり、基準が違います。 次のHTMLで測りました(固定ヘッダー 64px、各セクションの高さ 800px)。

<section id="s2">
  <div id="wrap" style="position: relative; margin-top: 40px; padding-top: 30px;">
    <div id="inner" style="height: 100px;">inner</div>
  </div>
</section>
const el = document.getElementById('inner');
const rect = el.getBoundingClientRect();

console.log(window.scrollY);            // ページのスクロール量
console.log(rect.top);                  // 表示領域の上端からの距離
console.log(rect.top + window.scrollY); // ページ先頭からの距離
console.log(el.offsetTop);              // offsetParent の上端からの距離
console.log(el.offsetParent.id);        // 基準になっている要素

スクロール量を変えながら測った結果です。

scrollY rect.top rect.top + scrollY el.offsetTop el.offsetParent
0 959 959 30 wrap
500 459 959 30 wrap
1200 -241 959 30 wrap

読み取れることは次のとおりです。

  • getBoundingClientRect().top は表示領域の上端からの距離なので、スクロールすると減っていきます。要素が表示領域より上に出るとマイナスになります。
  • rect.top + scrollY はページ先頭からの距離で、スクロールしても 959 のまま変わりません。「この要素の位置までスクロールしたい」ときに使う値です。
  • offsetTop は offsetParent からの距離です。この例では position: relative を付けた #wrap が offsetParent になるため、ページ先頭からの距離(959)ではなく、#wrap の中での位置(30)が返っています。

offsetTop は、祖先に position: relative などがあるかどうかで基準が変わります。ページ先頭からの距離が欲しいときは getBoundingClientRect().top + window.scrollY を使うと、祖先のCSSに左右されません。

なお、display: none の要素は位置を持たないため、getBoundingClientRect().top は 0、offsetTop は 0、offsetParent は null になり、scrollIntoView() を呼んでもスクロール位置は変わりませんでした(Chromium 141 で確認)。非表示の要素へ移動したいときは、先に表示してから呼び出します。

スクロール位置を移動する

scrollTo・scrollBy・scrollIntoView の引数一覧

メソッド 引数の形 意味
scrollTo(x, y) 数値2つ 横 x・縦 y の絶対位置へ移動
scrollTo({ top, left, behavior }) オブジェクト top・left は片方だけでも指定できる
scrollBy(x, y) 数値2つ いまの位置から横 x・縦 y だけ移動
scrollBy({ top, left, behavior }) オブジェクト 同上。マイナスの値で上・左へ
scroll(...) scrollTo と同じ scrollTo の別名
scrollIntoView() なし 要素の上端を表示領域の上端に合わせる
scrollIntoView(true / false) 真偽値 true は上端合わせ、false は下端合わせ
scrollIntoView({ behavior, block, inline }) オブジェクト 縦・横それぞれの合わせ方を指定

scrollTo と scrollBy は、window だけでなく、スクロールできる要素(box.scrollTo(0, 0))にも使えます。

オプションの値は次のとおりです(scrollIntoView のオプションは MDN の Element.scrollIntoView() のページで確認しました)。

オプション 指定できる値 既定値
behavior 'auto'(CSSの scroll-behavior に従う)/'smooth'(なめらか)/'instant'(一瞬で) 'auto'
block(縦方向。scrollIntoView のみ) 'start'/'center'/'end'/'nearest' 'start'
inline(横方向。scrollIntoView のみ) 'start'/'center'/'end'/'nearest' 'nearest'
container(scrollIntoView のみ) 'all'(すべての祖先をスクロール)/'nearest'(直近のスクロール要素だけ) 'all'

container は MDN に記載がある比較的新しいオプションで、今回は動作を確かめていません(未実行)。

scrollTo:絶対位置へ移動する

// ページの先頭へ
window.scrollTo(0, 0);

// 先頭から 500px の位置へ、なめらかに
window.scrollTo({ top: 500, behavior: 'smooth' });

// 縦だけ指定(横の位置はそのまま)
window.scrollTo({ top: 1200 });

範囲外の値を渡したときの動きも測りました(ページ全体の高さ 4064px、表示領域 600px)。

実行したコード 実行後の scrollY 補足
scrollTo(0, 500) 500 指定どおり
scrollBy(0, 100) 600 500 から 100 進む
scrollTo(0, 999999) 3464 最大値(4064 − 600)で止まる
scrollTo(0, -50) 0 0 より上には行かない

範囲を超えた値は、端の位置に丸められます。 「いちばん下へ」は scrollTo(0, document.documentElement.scrollHeight) と書けば、最大値で止まります。

scrollBy:いまの位置から相対的に移動する

// 1画面分だけ下へ
window.scrollBy({ top: window.innerHeight, behavior: 'smooth' });

// 100px 上へ
window.scrollBy(0, -100);

「次へ」「前へ」ボタンや、横に並んだカードを1枚分ずつ送る操作に向いています。

scrollIntoView:要素が見える位置へ移動する

移動先が「要素」で決まっているなら、位置を計算せずに scrollIntoView() を呼ぶのがいちばん短い書き方です。

document.getElementById('s3').scrollIntoView({ behavior: 'smooth' });

block の値によって止まる位置が変わります。高さ 800px のセクション #s3(ページ先頭から 1664px)に対して実行し、実行後の位置を測りました。

実行したコード 実行後の scrollY 要素上端の rect.top 止まる位置
scrollIntoView() 1664 0 要素の上端が表示領域の上端
scrollIntoView({ block: 'start' }) 1664 0 同上(既定値)
scrollIntoView({ block: 'center' }) 1764 -100 要素の中央が表示領域の中央
scrollIntoView({ block: 'end' }) 1864 -200 要素の下端が表示領域の下端
scrollIntoView({ block: 'nearest' }) 1664 0 近いほうの端に、最小限の移動
scrollIntoView(false) 1864 -200 block: 'end' と同じ

表示領域(600px)より大きい要素(800px)なので、center や end では上端がはみ出してマイナスになっています。小さな要素を画面の中央に見せたいときは block: 'center'、すでに見えているなら動かしたくないときは block: 'nearest' が便利です。

なお、MDN には scrollIntoView() が Promise を返すという記載がありますが、Chromium 141 で戻り値を調べたところ undefined でした。移動の完了を待ちたい場合は、後述の scrollend イベントを使うのが確実です。

なめらかに動かす:behavior と scroll-behavior

なめらかなスクロールは、JavaScriptの behavior: 'smooth' と、CSSの scroll-behavior: smooth のどちらでも指定できます。

/* ページ内リンク(<a href="#s3">)や、behavior を指定しない scrollTo がなめらかになる */
html {
  scroll-behavior: smooth;
}

/* 動きを減らす設定の利用者には、一瞬で移動させる */
@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
}

OSで「視差効果を減らす」などを設定している利用者には、アニメーションを控えるのが親切です。上のCSSは、その設定のときに scroll-behavior を auto に戻します。Chromium 141 で動きを減らす設定を再現して測ったところ、次の結果になりました。

呼び出し方 scrollイベントの回数 動き
scrollTo({ top: 1000 })(behavior なし) 1回 一瞬で移動(CSSの auto に従う)
scrollTo({ top: 0, behavior: 'smooth' }) 32回 なめらかに移動

JavaScriptで behavior: 'smooth' を明示すると、CSSのメディアクエリに関係なくなめらかに動きます。 利用者の設定を尊重するには、JavaScript側でも次のように切り替えます。

const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
window.scrollTo({ top: 0, behavior: reduce ? 'instant' : 'smooth' });

固定ヘッダーで位置がずれる問題を直す

何が起きるか

position: fixed や position: sticky のヘッダーがあるページで、ページ内リンクや scrollIntoView() を使うと、移動先の見出しがヘッダーの下に隠れます。 移動先は「要素の上端=表示領域の上端」になるように決まり、その上にヘッダーが重なるためです。

高さ 64px の固定ヘッダーがあるページで測った結果です。

状態 実行後の scrollY 見出しの rect.top 見え方
対策なしで scrollIntoView() 1664 0 ヘッダー(0〜64px)の下に隠れる
scroll-margin-top: 64px を指定 1600 64 ヘッダーのすぐ下に見える
scroll-padding-top: 64px を html に指定 1600 64 同上
JavaScriptで 64px 引いて scrollTo 1600 64 同上

直し方1:scroll-margin-top(移動先の要素に付ける)

いちばん手軽なのは、移動先になる要素に scroll-margin-top を付ける方法です。JavaScriptを書き換える必要がなく、ページ内リンク(<a href="#s3">)にも scrollIntoView() にも効きます。

.site-header {
  position: fixed;
  inset: 0 0 auto 0;
  height: 64px;
}

/* 移動先になる要素に、ヘッダーの高さ分の余白を指定する */
section[id],
h2[id],
h3[id] {
  scroll-margin-top: 64px;
}

URLのハッシュを #s4 に変えた場合も、rect.top が 64 の位置で止まることを確認しました。少し余裕を持たせたいときは scroll-margin-top: 80px のように大きめにします。

直し方2:scroll-padding-top(スクロールする側に付ける)

ページ全体に一括で効かせたいときは、スクロールする側(ページ全体なら html)に scroll-padding-top を指定します。

html {
  scroll-padding-top: 64px;
}

ヘッダーの高さが画面幅で変わる場合は、CSS変数にしておくと1か所の変更で済みます。

:root {
  --header-h: 64px;
}
@media (max-width: 600px) {
  :root { --header-h: 48px; }
}
.site-header { height: var(--header-h); }
html { scroll-padding-top: var(--header-h); }
方法 指定する場所 向いている場面
scroll-margin-top 移動先の要素 見出しごとに余白を変えたい。特定の要素だけ調整したい
scroll-padding-top スクロールする要素(html など) ページ全体に同じ余白を一括で指定したい
JavaScriptで計算 移動させるコード ヘッダーの高さが動的に変わる。CSSを変更できない

直し方3:JavaScriptで計算する

ヘッダーの高さがスクロール中に変わる(縮むヘッダーなど)場合は、移動の直前に高さを測って引き算します。

function scrollToElement(el) {
  const header = document.querySelector('.site-header');
  const headerH = header ? header.getBoundingClientRect().height : 0;
  const y = el.getBoundingClientRect().top + window.scrollY - headerH;
  window.scrollTo({ top: y, behavior: 'smooth' });
}

scrollToElement(document.getElementById('s3'));

「ページ先頭からの距離(rect.top + scrollY)」から「ヘッダーの高さ」を引く、という計算です。上の表の4行目はこの方法で測った結果で、scrollY は 1600 になりました。

scrollイベントの使い方:passive と間引き

scrollイベントの基本

スクロールに合わせて処理をしたいときは、scrollイベントを使います。

// ページ全体
window.addEventListener('scroll', () => {
  console.log(window.scrollY);
}, { passive: true });

// スクロールできる要素
box.addEventListener('scroll', () => {
  console.log(box.scrollTop);
}, { passive: true });

scrollイベントは、スクロールしている間 何度も発生します。 scrollTo({ top: 0, behavior: 'smooth' }) で 1000px をなめらかに戻したとき、Chromium 141 では scrollイベントが 32回発生しました。重い処理をそのまま書くと、1回のスクロールで数十回実行されることになります。

passive: true の意味

{ passive: true } は「このリスナーの中で preventDefault()(既定の動作の取り消し)を呼びません」とブラウザに伝える指定です。ブラウザはリスナーの終了を待たずにスクロールを進められるので、動きがなめらかになります。

スクロールに関係するイベントと passive の関係を整理します。

イベント 発生するタイミング preventDefault() でスクロールを止められるか
scroll スクロール位置が変わった後 止められない(位置が変わった後の通知のため)
wheel マウスホイール・トラックパッドの操作時 { passive: false } を指定すれば止められる
touchmove タッチで指を動かしている間 { passive: false } を指定すれば止められる
scrollend スクロールが止まったとき 止められない

Chromium 141 で、window に wheel のリスナーをオプションなしで登録し、その中で preventDefault() を呼んだ結果です。

window.addEventListener('wheel', (e) => {
  e.preventDefault();
  console.log(e.cancelable, e.defaultPrevented);
});
Unable to preventDefault inside passive event listener due to target being treated as passive. See https://www.chromestatus.com/feature/6662647093133312
false false

ホイール操作の後、scrollY は 0 から 200 に進みました。window・document・body に登録した wheel や touchmove のリスナーは、Chromium では既定で passive として扱われるため、preventDefault() は無視され、コンソールに上のメッセージが出ます。

要素に { passive: false } を明示して登録すると、取り消しが効きます。

document.getElementById('s1').addEventListener('wheel', (e) => {
  e.preventDefault(); // この要素の上でのホイール操作ではスクロールしない
}, { passive: false });

この場合は e.cancelable も e.defaultPrevented も true になり、scrollY は 0 のままでした。

やりたいこと 指定
スクロール量を読んで表示を更新するだけ { passive: true }(取り消しをしないことを明示)
地図やスライダーなど、自前で動きを制御したい 対象の要素に { passive: false } を付け、preventDefault() を呼ぶ
背景のスクロールを止めたい(モーダル表示中など) CSSの overflow: hidden や overscroll-behavior: contain を検討する

間引き1:requestAnimationFrame で1フレームに1回にする

表示の更新(進捗バーの幅、ボタンの表示切り替えなど)は、画面の描画に合わせて1フレームに1回行えば十分です。

let ticking = false;

function update() {
  // ここに表示の更新を書く
  console.log(window.scrollY);
  ticking = false;
}

window.addEventListener('scroll', () => {
  if (!ticking) {
    ticking = true;
    requestAnimationFrame(update);
  }
}, { passive: true });

ticking が true の間は新しい予約をしないので、scrollイベントが1フレームの間に何度来ても、update は1回しか実行されません。

間引き2:一定間隔に1回(スロットル)・止まってから1回(デバウンス)

通信や集計など、描画と関係のない処理は、時間で間引きます。

// 一定間隔に1回だけ実行する(スロットル)
function throttle(fn, wait) {
  let last = 0;
  return (...args) => {
    const now = Date.now();
    if (now - last >= wait) {
      last = now;
      fn(...args);
    }
  };
}

// 最後の呼び出しから wait ミリ秒たったら1回だけ実行する(デバウンス)
function debounce(fn, wait) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), wait);
  };
}

window.addEventListener('scroll', throttle(() => {
  console.log('200msに1回', window.scrollY);
}, 200), { passive: true });

Chromium 141 で、なめらかなスクロールを1回行う間に上の throttle(200ms)と debounce(200ms)を登録して回数を数えたところ、scrollイベント 40回に対して、スロットルは 4回、デバウンスは 1回の実行でした。

方法 実行される回数 向いている処理
そのまま scrollイベントのたび ごく軽い処理だけ
requestAnimationFrame 1フレームに1回 見た目の更新(バーの幅、クラスの切り替え)
スロットル 指定した間隔に1回 位置の記録、ログ送信
デバウンス 止まってから1回 スクロール位置の保存、追加読み込みの判定
scrollend イベント 止まったときに1回 デバウンスの代わり(次の章)

scrollend と IntersectionObserver に置き換える

scrollイベントで書いていた処理の多くは、目的に合った別の仕組みに置き換えると、間引きのコードが不要になります。

スクロールが止まったら:scrollend

scrollend は、スクロールが完了したときに1回だけ発生するイベントです。Chromium 141 で 1000px をなめらかにスクロールしたとき、scroll は 32回、scrollend は 1回でした。

window.addEventListener('scrollend', () => {
  console.log('スクロールが止まりました', window.scrollY);
});

MDN の scrollend のページでは、ホイール・キーボード・スクロールスナップ・API による移動のいずれでも位置の更新が終わったときに発生すること、スクロール位置が変わらなかった場合は発生しないこと、タッチ操作では指を離した後に発生することが説明されています。対応状況は「Baseline 2025(Newly available)、2025年12月から主要ブラウザの最新版で利用可能」という表示でした。少し前のバージョンのブラウザも対象にするなら、次のように使えるかどうかを調べて切り替えます。

function onScrollEnd(callback) {
  if ('onscrollend' in window) {
    window.addEventListener('scrollend', callback);
  } else {
    // scrollend がない環境では、150ms スクロールがなければ「止まった」とみなす
    let timer;
    window.addEventListener('scroll', () => {
      clearTimeout(timer);
      timer = setTimeout(callback, 150);
    }, { passive: true });
  }
}

onScrollEnd(() => {
  console.log('停止:', Math.round(window.scrollY));
});

Chromium 141 では 'onscrollend' in window が true でした。scrollend を取り除いた状態を再現して else 側も実行し、500px スクロールした後に callback が1回呼ばれることを確認しています。

要素が見えたら:IntersectionObserver

「ある要素が画面に入ったら処理する」は、scrollイベントで getBoundingClientRect() を毎回計算する代わりに、IntersectionObserver に任せられます。要素が表示範囲に出入りしたときだけ、コールバックが呼ばれます。

// 表示中のセクションに合わせて、目次の現在位置を切り替える
const links = document.querySelectorAll('.toc a');

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    links.forEach((a) => {
      a.classList.toggle('is-current', a.hash === '#' + entry.target.id);
    });
  }
}, {
  // 上は固定ヘッダーの分だけ狭め、下は表示領域の60%を除いて「上のほうに来たら」反応させる
  rootMargin: '-64px 0px -60% 0px',
});

document.querySelectorAll('section[id]').forEach((s) => observer.observe(s));

rootMargin: '-64px 0px 0px 0px' を指定して5つのセクションを監視し、scrollTo(0, 900) を実行したところ、1つ目のセクションが isIntersecting: false、2つ目が true として通知されました(Chromium 141)。上の目次切り替えのコード全体は、今回は未実行です。

scrollイベントで書いていた処理 置き換え先
要素が画面に入ったらアニメーションを始める IntersectionObserver
画像を近づいたら読み込む <img loading="lazy">、または IntersectionObserver
最下部に近づいたら続きを読み込む 末尾に置いた目印の要素を IntersectionObserver で監視
スクロールが止まったら位置を保存する scrollend
ヘッダーを途中から固定する CSS の position: sticky
一定位置で区切って止める CSS の scroll-snap

コピペで使える定番コード

スクロール率バー、トップへ戻るボタン、ページ最下部の判定を1つのHTMLにまとめました。ファイルに保存してブラウザで開けば、そのまま動きます。

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>スクロールの定番コード</title>
<style>
  html { scroll-behavior: smooth; }
  body { margin: 0; font: 16px/1.7 sans-serif; }
  .site-header { position: fixed; inset: 0 0 auto 0; height: 64px; background: #1f3a5f; color: #fff; }
  .progress { position: fixed; top: 0; left: 0; height: 4px; width: 0; background: #e8b93c; z-index: 10; }
  main { padding-top: 64px; }
  section { min-height: 800px; padding: 16px; scroll-margin-top: 64px; }
  .to-top { position: fixed; right: 16px; bottom: 16px; padding: 8px 12px; }
  .to-top[hidden] { display: none; }
  @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
</style>
</head>
<body>
<div class="progress" id="progress"></div>
<header class="site-header"><a href="#s3" style="color:#fff">3章へ</a></header>
<main>
  <section id="s1"><h2>1章</h2></section>
  <section id="s2"><h2>2章</h2></section>
  <section id="s3"><h2>3章</h2></section>
  <section id="s4"><h2>4章</h2></section>
  <div id="sentinel"></div>
</main>
<button type="button" class="to-top" id="toTop" hidden>トップへ戻る</button>

<script>
  const progress = document.getElementById('progress');
  const toTop = document.getElementById('toTop');
  const root = document.documentElement;

  // 1. スクロール率バー と トップへ戻るボタンの表示切り替え(1フレームに1回へ間引き)
  let ticking = false;
  function update() {
    const max = root.scrollHeight - root.clientHeight;
    const rate = max > 0 ? window.scrollY / max : 0;
    progress.style.width = (rate * 100) + '%';
    toTop.hidden = window.scrollY < 300;
    ticking = false;
  }
  window.addEventListener('scroll', () => {
    if (!ticking) {
      ticking = true;
      requestAnimationFrame(update);
    }
  }, { passive: true });
  update();

  // 2. トップへ戻る
  toTop.addEventListener('click', () => {
    window.scrollTo({ top: 0, behavior: 'smooth' });
  });

  // 3. ページ最下部の判定(スクロールが止まったときに1回)
  function isBottom() {
    return Math.ceil(window.scrollY + window.innerHeight) >= root.scrollHeight - 1;
  }
  function onScrollEnd(callback) {
    if ('onscrollend' in window) {
      window.addEventListener('scrollend', callback);
    } else {
      let timer;
      window.addEventListener('scroll', () => {
        clearTimeout(timer);
        timer = setTimeout(callback, 150);
      }, { passive: true });
    }
  }
  onScrollEnd(() => {
    console.log('停止: scrollY=' + Math.round(window.scrollY) + ' 最下部=' + isBottom());
  });

  // 4. 最下部の手前まで来たことを IntersectionObserver で知る
  const io = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting) console.log('sentinel が表示範囲に入りました');
  }, { rootMargin: '0px 0px 200px 0px' });
  io.observe(document.getElementById('sentinel'));
</script>
</body>
</html>

このページを Chromium 141(表示領域 1000×600px、ページ全体の高さ 3392px)で操作した結果です。

操作 scrollY バーの幅 ボタン 出力されたログ
読み込み直後 0 0% 非表示 なし
400px へ移動 400 14.3266% 表示 停止: scrollY=400 最下部=false
ヘッダーの「3章へ」をクリック 1664 59.5989% 表示 停止: scrollY=1664 最下部=false
いちばん下へ移動 2792 100% 表示 sentinel が表示範囲に入りました/停止: scrollY=2792 最下部=true
「トップへ戻る」をクリック 0 0% 非表示 停止: scrollY=0 最下部=false

「3章へ」をクリックした後の見出しの rect.top は 64 で、scroll-margin-top: 64px によって固定ヘッダーのすぐ下に止まっています。

スクロール率の計算式

const max = root.scrollHeight - root.clientHeight; // スクロールできる最大量
const rate = max > 0 ? window.scrollY / max : 0;   // 0〜1

分母は「ページ全体の高さ」ではなく「スクロールできる最大量」です。ページ全体の高さで割ると、いちばん下まで行っても100%になりません。ページが表示領域より短い場合は max が 0 になるので、0 で割らないように分岐しています。

ページ最下部の判定式

// ページ全体
const atBottom = Math.ceil(window.scrollY + window.innerHeight) >= document.documentElement.scrollHeight - 1;

// スクロールできる要素
const atEnd = box.scrollTop + box.clientHeight >= box.scrollHeight - 1;

「上に隠れている分 + 見えている分」が「全体の高さ」に届いたら最下部です。scrollY は小数になることがあるため、=== で比べず、切り上げたうえで 1px の余裕を持たせます。 要素の例では、box.scrollTo(0, 99999) の後に scrollTop が 802、判定が true になることを確認しました。

「最下部の少し手前」で続きを読み込みたい場合は、右辺から余白を引く(scrollHeight - 200)か、上のコードのように末尾に目印の要素を置いて IntersectionObserver の rootMargin で手前から検知します。

スクロール位置を保存して復元する

一覧から詳細へ移動して戻ったときなどに、元の位置へ戻したい場合のコードです。ブラウザには位置を自動で復元する仕組みがあり、history.scrollRestoration で切り替えられます(Chromium 141 での既定値は 'auto' でした)。自分で制御したいときは 'manual' にして、保存と復元を書きます。次のコードは今回は未実行です。

history.scrollRestoration = 'manual';

const KEY = 'scroll:' + location.pathname;

// 離れるときに保存
window.addEventListener('pagehide', () => {
  sessionStorage.setItem(KEY, String(window.scrollY));
});

// 表示したときに復元(一瞬で移動させる)
window.addEventListener('pageshow', () => {
  const y = Number(sessionStorage.getItem(KEY));
  if (Number.isFinite(y)) {
    window.scrollTo({ top: y, behavior: 'instant' });
  }
});

思ったとおりに動かないときの確認表

症状 考えられる原因 確認・直し方
scrollY がずっと 0 ページではなく、内側の要素がスクロールしている スクロールしている要素の scrollTop を読む。scrollイベントもその要素に登録する
window の scrollイベントが発生しない 同上(html, body { height: 100%; overflow: auto; } など) スクロールする要素を特定して、そこに登録する
scrollTo で動かない 高さが足りずスクロールできない/対象が違う scrollHeight > clientHeight になっているか確認する
移動先がヘッダーに隠れる 固定ヘッダーの高さ分ずれている scroll-margin-top または scroll-padding-top を指定する
offsetTop が小さすぎる offsetParent がページではなく祖先の要素 getBoundingClientRect().top + scrollY に変える
なめらかに動かない behavior が未指定で、CSSの scroll-behavior も auto behavior: 'smooth' を指定するか、CSSで scroll-behavior: smooth
preventDefault() が効かない リスナーが passive として扱われている 要素に { passive: false } を明示して登録する
最下部の判定が true にならない 小数の差で === が成立しない Math.ceil() と >=、1px の余裕で比べる
非表示の要素へ移動しない display: none の要素は位置を持たない 先に表示してから scrollIntoView() を呼ぶ
読み込み直後の位置がずれる 画像の読み込みなどで後から高さが変わる 画像に width・height を指定する。load 後に移動する

よくある質問

Q. scrollY と pageYOffset はどちらを使えばよいですか?

A. 同じ値を返します。今回の測定でも、どのスクロール位置でも一致しました。新しく書くコードでは scrollY を使うと、横方向の scrollX と名前がそろって読みやすくなります。

Q. scrollIntoView() と scrollTo() はどう使い分けますか?

A. 移動先が要素で決まっているなら scrollIntoView()、ピクセル数で決まっているなら scrollTo() です。固定ヘッダーのずれは、どちらを使う場合も CSS の scroll-margin-top や scroll-padding-top で調整できます。ヘッダーの高さが動的に変わるときは、getBoundingClientRect().top + scrollY から高さを引いて scrollTo() に渡します。

Q. スムーズスクロールの速度や時間は指定できますか?

A. behavior: 'smooth' には、時間や速度を指定するオプションがありません。動き方はブラウザに任されます。時間を細かく決めたい場合は、requestAnimationFrame で少しずつ scrollTo を呼ぶ処理を自分で書くことになります。

Q. scrollイベントには必ず { passive: true } を付けるべきですか?

A. scrollイベント自体は取り消せないイベントなので、付けなくてもスクロールを止めることはありません。それでも付けておくと「このリスナーはスクロールを妨げない」という意図がコード上で明確になります。指定の有無で動きが変わるのは wheel と touchmove で、取り消したいときだけ { passive: false } を明示します。

まとめ

  • ページ全体の位置は window.scrollY、要素の中の位置は 要素.scrollTop で取得します。
  • getBoundingClientRect().top は表示領域からの距離、+ scrollY でページ先頭からの距離、offsetTop は offsetParent からの距離です。
  • 移動は、絶対位置なら scrollTo、相対なら scrollBy、要素へは scrollIntoView。behavior: 'smooth' でなめらかになります。
  • 固定ヘッダーのずれは scroll-margin-top(移動先)か scroll-padding-top(html)で直せます。 JavaScriptの変更は不要です。
  • scrollイベントは何度も発生するので、{ passive: true } を付け、requestAnimationFrame・スロットル・デバウンスで間引きます。
  • 「止まったら」は scrollend、「見えたら」は IntersectionObserver に置き換えると、間引きのコードが不要になります。
  • 最下部の判定は Math.ceil(scrollY + innerHeight) >= scrollHeight - 1、スクロール率の分母は scrollHeight - clientHeight です。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「JavaScriptスクロール位置の取得と移動|scrollTo・イベント早見表」

株式会社RJCでは、JavaScriptのスクロール処理の書き方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?