0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

画面より高い要素では threshold 0.5 が一度も発火しない。IntersectionObserver が届く ratio の上限を3つの画面で実測してみた

0
Posted at

セクションが画面に半分入ったら1回だけイベントを送る、というよくある計測を入れました。素直に new IntersectionObserver(cb, { threshold: 0.5 }) と書いて、手元のPCで動くことを確認して、出しました。

数日後、そのイベントは 0件 でした(2026-09-17 に実測)。

「読まれていない」と読んで中身を直しかけたのですが、先に計測そのものを測ってみたら、コールバックが1回も呼ばれていませんでした。読まれていないのではなく、条件が成立し得なかった。

原因は一行で言えます。intersectionRatio の分母は要素の面積なので、画面より高い要素では比が 1.0 まで上がらない。高さ 2,024px のセクションは、375×812 の画面では最大でも 0.401 にしかならず、threshold: 0.5 は永遠に超えません。

この記事では、その上限を 3つの画面サイズ × 7つの高さ = 21パターンで実測し、計算式と突き合わせます。さらに、実運用している9ページの全ブロックを数えて「何割が届かないか」を出し、対処4通りを同じページで同時に走らせて比べます。使ったコードは全部載せてあります。

fig1.png

TL;DR

  • 到達できる intersectionRatio の上限は min(1, 画面の高さ ÷ 要素の高さ)。この値を超える threshold は一度も発火しない
  • Chrome 153 で 21パターン測ったところ、計算した上限と実測した最大は 21セルとも一致した(小数第3位まで)
  • 高さ 800px のノートPCは、高さ 812px のスマホより先に落ちる。効いているのは画面の広さではなく高さだけ
  • 自分が運用している9ページ・82ブロックのうち 13(15.9%)が、375×812 では threshold: 0.5 に到達できない
  • 対処は rootMargin を m = ceil((threshold × 要素の高さ − 画面の高さ) ÷ 2) で計算して足すのが素直。99px では発火せず、100px で発火するところまで実測した
  • そして何より、「0件」を見たら、数えた結果が0なのか、数える機会が0だったのかを先に分ける

目次


「0件」には2つの意味がある

打ち手が正反対なので、混ぜたまま報告してはいけない

イベントが0件のとき、あり得る話は2つです。

  1. 読まれていない。条件は成立し得たが、誰もそこまで来なかった。直すべきは導線と中身で、計測は正しく動いている
  2. 発火し得なかった。条件が数学的に成立不能だった。読者は来ていたかもしれない。直すべきは計測コードで、中身をいくら良くしても0件のまま

この2つはログの見た目では区別がつきません。どちらも「0」と表示されます。そして①だと思い込むと、存在しない問題を直し続けることになります。

僕は①だと思って、セクションの見出しと導線を書き直そうとしていました。手を動かす前に「そもそも呼ばれているのか」を確かめたので助かった、という順序です。

PCで開くと「動いている」ように見えるのが厄介

この不具合は、画面の高さが十分にある環境では正常に動きます。だから開発機で確認する限り、一生気づきません。

しかも後で見るとおり、縦に短いノートPCのほうが、縦に長いスマホより先に壊れます。「スマホでだけ壊れる」という覚え方すら正確ではありません。


なぜ届かないのか

intersectionRatio の分母は要素の面積であって、画面ではない

IntersectionObserver API のコールバックに渡ってくる intersectionRatio は、「重なっている矩形の面積 ÷ 監視対象の矩形の面積」です。仕様 でもそう定義されています。

つまり分母は要素です。画面ではありません。

要素が画面より低ければ、いずれ全部が画面に入るので比は 1.0 まで上がります。要素が画面より高いと、画面いっぱいに映っていても、重なれる面積は画面の高さで頭打ちになります。

fig2.png

上限は min(1, 画面の高さ ÷ 要素の高さ)

横幅は普通どちらも同じなので、比は高さの比になります。

// 到達できる ratio の上限(root が文書・rootMargin なしの場合)
const ceilingRatio = Math.min(1, window.innerHeight / el.getBoundingClientRect().height);

高さ 2,024px の要素を 812px の画面で見ると 812 / 2024 = 0.4012。threshold に 0.5 を渡した時点で、そのコールバックは一度も呼ばれません。

threshold は「超えたら呼ぶ」ではなく「またいだら呼ぶ」

もうひとつ勘違いしやすいのが、threshold の意味です。これは「この値以上になったら呼ぶ」ではなく、**「この値を跨いだときに呼ぶ」**です。上がるときも下がるときも呼ばれるので、コールバックの中では entry.isIntersecting や entry.intersectionRatio を自分で見る必要があります。

そして跨ぎようがない値を渡すと、呼ばれるのは監視を開始した直後の1回だけになります。この「1回は呼ばれる」がまた曲者で、console.log を仕込むと何か出るので「動いている」と勘違いします。


実測① 何をどう測ったか

高さ違いの7セクションを、5つの threshold で同時に監視する

高さの違う7つのセクション(180 / 300 / 480 / 700 / 900 / 1,400 / 2,024px)を縦に並べ、各セクションを5つの threshold(0 / 0.25 / 0.5 / 0.75 / 1.0)で同時に監視します。

HEIGHTS = [180, 300, 480, 700, 900, 1400, 2024]
TH = [0, 0.25, 0.5, 0.75, 1.0]

ページ側は、観測した最大の ratio と、発火した threshold を記録するだけです。

window.__LOG = [];      // 発火したもの: [id, threshold, ratio, scrollY]
window.__MAXR = {};     // id -> 観測した最大 ratio
document.querySelectorAll('.sec').forEach(function (el) {
  window.__MAXR[el.id] = 0;
  window.__TH.forEach(function (t) {
    new IntersectionObserver(function (ents) {
      ents.forEach(function (en) {
        if (en.intersectionRatio > window.__MAXR[en.target.id]) {
          window.__MAXR[en.target.id] = en.intersectionRatio;
        }
        if (en.isIntersecting) {
          window.__LOG.push([en.target.id, t, +en.intersectionRatio.toFixed(4), Math.round(scrollY)]);
        }
      });
    }, { threshold: t }).observe(el);
  });
});

駆動は、ヘッドレス Chrome を CDP でつないで実フレームを回します。--dump-dom と --virtual-time-budget の組み合わせではスクロールイベントが出ず、requestAnimationFrame も発火しないので、この種の測定には使えません(これは前に別記事で実測しました)。

ビューポートは Emulation.setDeviceMetricsOverride で指定します。--window-size=375,812 は headless では効かず innerWidth が 500 になるためです。

スクロールは2段構えにしました。

  1. 100px 刻みで最後まで送る(ふつうのスクロールに近い刻み)
  2. 各要素の ratio が最大になる位置へピンポイントで飛ぶ(刻みの粗さで取り逃がさないため)
// (2) 要素を画面の中央に収める位置へ飛ぶ
for (const el of els) {
  const r = el.getBoundingClientRect(), top = r.top + scrollY;
  const y = r.height >= innerHeight ? top : top - (innerHeight - r.height) / 2;
  scrollTo(0, Math.max(0, Math.round(y)));
  await raf();
  await sleep(120);
}

観測は通算で取り、最後に min(1, innerHeight / 要素の高さ) と突き合わせます。

実測① 21セルとも計算と一致した

fig3.png

Chrome 153.0.0.0・2026-09-21 実測。左が計算した上限、右が実測した最大です。

要素の高さ 375×812 計算 375×812 実測 768×1024 計算 768×1024 実測 1280×800 計算 1280×800 実測
180 1.000 1.000 1.000 1.000 1.000 1.000
300 1.000 1.000 1.000 1.000 1.000 1.000
480 1.000 1.000 1.000 1.000 1.000 1.000
700 1.000 1.000 1.000 1.000 1.000 1.000
900 0.902 0.902 1.000 1.000 0.889 0.889
1,400 0.580 0.580 0.731 0.731 0.571 0.571
2,024 0.401 0.401 0.506 0.506 0.395 0.395

21セルとも、小数第3位まで計算値と一致しました。 式は推測ではなく、そのまま使える予測式です。

発火した threshold も予想どおりでした。1,400px の要素は 375×812 で 0 / 0.25 / 0.5 の3つだけが発火し、0.75 と 1.0 は通しスクロールでも中央合わせでも一度も発火しませんでした。

高さ800pxのノートPCは、812pxのスマホより先に落ちる

表の右2列を比べてください。1280×800 のノートPCは、375×812 のスマホより値が小さい。2,024px の要素は 0.395 と 0.401 で、ノートPCのほうが先に 0.5 を割ります。

一方、768×1024 のタブレットだけは 0.506 で 0.5 を超えます。同じコード・同じページで、画面サイズによって「発火する/しない」が入れ替わります。

効いているのは高さだけです。「レスポンシブの確認はスマホ幅で」という習慣だけでは、この不具合は拾えません。縦に短い窓で確認する必要があります。ブラウザの devtools を下に大きく開いていると、それだけで再現することもあります。


実測② 実運用のページで何割が届かないか

デモで成立するのは当たり前として、実際のページにそんなに高い要素があるのかが次の問題です。自分で運用している9つの公開ページで数えました。

数え方

「計測を仕込みたくなる塊」を機械的に定義します。

  • section / article / main > div / div[class] / li
  • 幅 300px 以上(ビューポートに依らない固定の足切り)
  • 本文 80文字以上
  • 親子で高さがほぼ同じものは、外側だけ残す(入れ子の二重計上を避ける)
for (const el of document.querySelectorAll('section, article, main > div, div[class], li')) {
  const r = el.getBoundingClientRect();
  if (r.width < 300 || r.height < 40) continue;
  if ((el.textContent || '').trim().length < 80) continue;
  if (el.parentElement &&
      Math.abs(el.parentElement.getBoundingClientRect().height - r.height) < 2) continue;
  out.push({ h: Math.round(r.height), ceil: +Math.min(1, innerHeight / r.height).toFixed(3) });
}

この定義は粗いです。粗いと分かるように条件を全部書いたので、厳しくしたい方は足切りを上げてみてください。

82ブロックのうち 13 が届かない

fig4.png

375×812 での実測(2026-09-21)。

対象ブロック ratio 1.0 に届かない ratio 0.5 に届かない
9ページ合計 82 26(31.7%) 13(15.9%)

ページ単位ではばらつきます。ルール解説のような短いページは 9ブロックとも 1.0 に届きましたが、記事一覧のページは 17ブロック中 8 が 1.0 に届かず、いちばん高いブロックは 8,128px ありました。8,128px のブロックの上限は 812 / 8128 = 0.0999 です。threshold: 0.1 でも発火しません。

「PCなら安全」ではない

同じ9ページを 1280×800 で測り直しました。

対象ブロック ratio 1.0 に届かない ratio 0.5 に届かない
375×812 82 26(31.7%) 13(15.9%)
1280×800 76 18(23.7%) 10(13.2%)

レイアウトが横に広がって要素が縮むぶん減りますが、ゼロにはなりません。「PCで確認したから大丈夫」は成り立ちません。


対処を4通り書いてみる

高さ 2,024px のセクションを「半分読まれた」と判定する方法を4つ用意して、同じページに同時に仕掛けて比べました。

A — そのまま threshold: 0.5

new IntersectionObserver(es => es.forEach(e => {
  if (e.isIntersecting) send('read_half');
}), { threshold: 0.5 }).observe(el);

これが壊れていた元のコードです。

B — threshold を刻んで OR 判定

「要素の50%」か「画面の50%」のどちらかが満たされたら成立、とする書き方です。threshold を刻んで渡すのは、コールバックを呼ばせるためであって、判定そのものはコールバックの中でやります。

const steps = []; for (let i = 0; i <= 20; i++) steps.push(i / 20);
new IntersectionObserver(es => es.forEach(e => {
  const tall = e.intersectionRect.height >= innerHeight * 0.5;  // 画面の半分が埋まった
  if (e.intersectionRatio >= 0.5 || tall) send('read_half');
}), { threshold: steps }).observe(el);

intersectionRect は重なっている矩形そのものなので、割る相手を画面に変えられます。

C — rootMargin を式で計算して足す

rootMargin は root の矩形を広げます。広げれば重なれる面積が増え、ratio の上限そのものが上がります。

上下に m ずつ広げると root の高さは vh + 2m になるので、threshold: t を成立させるのに必要な m は次の式です。

const m = Math.max(0, Math.ceil((t * el.getBoundingClientRect().height - innerHeight) / 2));
new IntersectionObserver(cb, { threshold: t, rootMargin: m + 'px 0px' }).observe(el);

rootMargin の必要量は、境界の1px手前まで式と一致した

この式が本当に当たるのか、境界の手前1pxまで掃きました。

fig6.png

rootMargin(上下) 375×812・要素2,024px で threshold: 0.5 は
0 px 発火しない
80 px 発火しない
90 px 発火しない
99 px 発火しない
100 px 発火した
110 px 発火した
150 px 発火した

式が出した m = ceil((0.5 × 2024 − 812) / 2) = 100 と、実測の境界が1px単位で一致しました。1280×800 では式が 106 を出し、105 では発火せず 106 で発火。768×1024 では式が 0 を出し、素で成立します。

だから定数で書いてはいけません。 100px とハードコードすると、画面の高さが変わった瞬間に外れます。監視を張る直前に計算してください。

⚠️ ひとつ注意があります。rootMargin が画面の外側へ広げられるのは、root が文書(root: null)のときです。スクロールコンテナを root にした場合、広げた先はそのコンテナの外側になります。

D — scroll イベントで自前計算

IntersectionObserver を使わず、スクロールのたびに位置を読む方法です。

addEventListener('scroll', () => {
  const r = el.getBoundingClientRect();
  const visible = Math.min(r.bottom, innerHeight) - Math.max(r.top, 0);
  if (visible > 0 && (scrollY + innerHeight) >= (r.top + scrollY + r.height * 0.5)) send('read_half');
}, { passive: true });

確実ですが、スクロールのたびに getBoundingClientRect() を呼ぶので、そもそも IntersectionObserver が作られた理由と正面から衝突します。

4通りを同じページで同時に走らせた結果

fig5.png

375×812 で 50px 刻みに通しスクロール。y は最初に成立した scrollY、回数はコールバックが呼ばれた総数です(Chrome 153・2026-09-21 実測)。

手法 375×812 で成立 呼ばれた回数 768×1024 で成立
A そのまま threshold: 0.5 発火せず 1 y=400
B 刻んだ threshold + OR 判定 y=0 65 y=0
C rootMargin を必要量だけ足す y=500 17 y=400
C′ rootMargin が10px足りない 発火せず 1 y=400
D scroll イベントで自前計算 y=600 335 y=400

読みどころが3つあります。

A は 375×812 で不発、768×1024 では y=400 で成立しました。同じコードで結果が入れ替わっています。呼ばれた回数が 1 なのは、監視を開始した直後の1回だけという意味です。

B は y=0 で成立しました。 これは「画面の半分がこの要素で埋まった」が、この実測ページでは先頭(scrollY 0)ですでに真だったからです。B は「発火するようにする」は解いていますが、「意図した位置で発火する」は解いていません。この2つは別の問題です。

D は 335回呼ばれています。 C の 17回と比べると20倍です。正しく動きますが、毎回レイアウトを読みます。

5つ目の手 — 番兵を置く

要素の中に高さ 1px の空要素を置いて、それを監視する方法もあります。

<section class="tall">
  ...前半...
  <span class="sentinel" aria-hidden="true"></span>  <!-- 高さ1px -->
  ...後半...
</section>
new IntersectionObserver(es => es.forEach(e => {
  if (e.isIntersecting) send('read_half');
}), { threshold: 0 }).observe(document.querySelector('.sentinel'));

番兵は必ず画面より低いので、天井の問題が構造的に起きません。 「要素の中ほどまで到達したか」を知りたいだけなら、これがいちばん壊れにくいと思います。欠点は、マークアップに計測用の要素が混ざることです。


どれを選ぶか

数式より先に、判定の定義を日本語で1行書く

fig7.png

選べない理由はたいてい、「何を読まれたと呼ぶか」が決まっていないことです。決まれば手法は一意に決まります。

何を「読まれた」と呼ぶか 使うもの
要素の一部でも見えたら threshold: 0 でよい。高さに関係なく必ず発火する
要素の何割かが見えたら threshold: t + rootMargin を式で計算して足す
画面の何割かがその要素で埋まったら intersectionRect.height / innerHeight を自分で見る
要素の中ほどまで到達したら 高さ1pxの番兵を置いて threshold: 0 で監視する

計測に送るときの形

GA4 のイベントに送るなら、発火条件と一緒に、そのとき成立し得た上限も送っておくと後から自分を疑えます。

function observeSectionRead(el, name, t = 0.5) {
  const h = el.getBoundingClientRect().height;
  const ceiling = Math.min(1, innerHeight / h);              // 到達できる上限
  const m = Math.max(0, Math.ceil((t * h - innerHeight) / 2)); // 必要な rootMargin
  let sent = false;
  const io = new IntersectionObserver(es => es.forEach(e => {
    if (!e.isIntersecting || sent) return;
    sent = true; io.disconnect();
    gtag('event', 'section_read', {
      section: name,
      threshold: t,
      ceiling: +ceiling.toFixed(3),   // 0.5 未満なら rootMargin なしでは死んでいた
      root_margin: m,
      vh: innerHeight
    });
  }), { threshold: t, rootMargin: m + 'px 0px' });
  io.observe(el);
}

ceiling を一緒に送っておけば、後から「この端末では素の threshold では発火し得なかった」が分かります。0件だったときに、①と②のどちらだったかを後追いで切り分けられる、ということです。


測る前に「計測器が生きているか」を確かめる

ここまでの測定で、実は2回ほど測定そのものが黙って死にかけました。

非表示タブでは rAF が回らず、IO が1回も発火しない

タブがバックグラウンドにあると、requestAnimationFrame は呼ばれません(Page Visibility API と requestAnimationFrame の仕様どおりの挙動です)。このとき IntersectionObserver の初回コールバックすら来ないことがあります。

つまり 「0件だった」は「コードが壊れている」ではなく「環境が動いていなかった」かもしれない、という別の0件が発生します。

今回、ヘッドレスでも同じ形を2回踏みました。document.visibilityState は "visible"、document.hidden は false のまま、rAF だけが 1.5秒待っても回らないという状態です。同じコマンドを24回連続で回したときは0件(2026-09-21 実測)でしたので、頻度は測れていませんが、起きることは確認できました。

🔑 だから見るべきは visibilityState ではなく、rAF が実際に回ったかどうかです。

カナリアの入れ方

測定の先頭に、必ずこれを置いています。

const sleep = ms => new Promise(r => setTimeout(r, ms));
const raf = () => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));

let rafOk = false;
await Promise.race([raf().then(() => { rafOk = true; }), sleep(1500)]);
if (!rafOk) return { err: 'rAF did not run (hidden tab?)' };   // 数値を返さず、エラーを返す
if (!innerHeight) return { err: 'zero viewport' };

ポイントは、怪しいときに「0」を返さないことです。 0 を返すと、呼び出し側では正常な0と区別がつきません。err を返せば、集計に混ざりません。

症状 先に見るもの
IO が1回も発火しない rAF が実際に回ったか(visibilityState ではなく)
要素のサイズが全部0 innerHeight / innerWidth が 0 でないか
測定値が妙に小さい ページ側のスクリプトが走り終わっているか

3つ目も実際に踏みました。file:// でも、駆動側の評価がページのインラインスクリプトより先に走ることがあります。window.__LOG が配列になるまで待つガードを入れて直しました。


自分のページで確かめるスニペット

devtools のコンソールに貼れば、そのページで到達不能な threshold が一覧で出ます。

(() => {
  const vh = innerHeight, rows = [];
  for (const el of document.querySelectorAll('section, article, main > div, div[class], li')) {
    const r = el.getBoundingClientRect();
    if (r.width < 300 || r.height < 40) continue;
    if ((el.textContent || '').trim().length < 80) continue;
    if (el.parentElement &&
        Math.abs(el.parentElement.getBoundingClientRect().height - r.height) < 2) continue;
    const ceiling = Math.min(1, vh / r.height);
    rows.push({
      要素: el.tagName.toLowerCase() + (el.className ? '.' + String(el.className).split(/\s+/)[0] : ''),
      高さ: Math.round(r.height),
      到達上限: +ceiling.toFixed(3),
      '0.5に届く': ceiling >= 0.5 ? 'OK' : 'NG',
      '1.0に届く': ceiling >= 1 ? 'OK' : 'NG',
      'th0.5に必要なrootMargin': Math.max(0, Math.ceil((0.5 * r.height - vh) / 2))
    });
  }
  rows.sort((a, b) => a.到達上限 - b.到達上限);
  console.table(rows);
  console.log('画面の高さ', vh, '/ 対象', rows.length,
              '/ 0.5に届かない', rows.filter(r => r['0.5に届く'] === 'NG').length);
})();

ウィンドウの高さを変えて2回やってください。 1回だけだと、ちょうど届いている高さで安心してしまいます。

なお、IntersectionObserver 自体の対応状況は現行ブラウザで問題ありません。困るのは対応状況ではなく、対応しているのに条件が成立しないことのほうです。


まとめ

  • intersectionRatio の分母は要素。画面より高い要素では min(1, 画面の高さ ÷ 要素の高さ) が天井になる
  • 21パターン測って、計算値と実測値は小数第3位まで一致。式はそのまま予測に使える
  • 縦に短い窓ほど先に壊れる。1280×800 は 375×812 より先に 0.5 を割った
  • 実運用9ページの82ブロックのうち 13(15.9%)は 375×812 で threshold: 0.5 に到達できない
  • rootMargin の必要量は ceil((t × 要素の高さ − 画面の高さ) ÷ 2)。99px と 100px の境界まで実測で一致した
  • 「発火するようにする」と「意図した位置で発火する」は別問題。OR 判定はページ先頭で成立してしまう
  • そして、0件を見たら「測れていたか」を先に疑う。測定の先頭に rAF のカナリアを置く

次にやること

この記事を読んだ直後にできるのは2つです。

  1. 「自分のページで確かめるスニペット」を自分のページのコンソールに貼って、ウィンドウの高さを2通りで実行する。0.5に届かない が0件なら、この記事の話は当面関係ありません
  2. 0件でなかったら、その要素に付けている threshold を確認する。天井より大きい値が書かれていたら、そのイベントは一度も送られていません

僕自身は、0件のイベントを「読まれていない」と読んで中身を直そうとしていました。直す前に測ったから助かった、というだけの話です。次にどこかで0件を見たときも、同じ順序でやります。


計測が入っていない・入っているのに値が出ていないといった、公開中のサイトの「測れていない箇所」を実測して直す順番まで出す単発監査もやっています(毎日ラボ/お問い合わせ)。必要なのは公開URLだけです。

この記事で使った測定の考え方(ヘッドレス Chrome を CDP で駆動して実フレームを回す)はこちらに、「書いてあるのに画面に出ていない文章」を機械で数える話はこちらに書いてあります。どちらも今回と同じで、自分の道具を疑うところから始めた記録です。

まずは「自分のページで確かめるスニペット」を、ウィンドウの高さを2通りにして試してみてください。0.5に届かない が1件でも出たら、そこに書いた threshold はいま一度も発火していません。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?