セクションが画面に半分入ったら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通りを同じページで同時に走らせて比べます。使ったコードは全部載せてあります。
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つの意味がある
-
なぜ届かないのか —
intersectionRatioの分母は要素 - 実測① 何をどう測ったか
- 実測① 21セルとも計算と一致した
- 実測② 実運用のページで何割が届かないか
- 対処を4通り書いてみる
- rootMargin の必要量は、境界の1px手前まで式と一致した
- 4通りを同じページで同時に走らせた結果
- どれを選ぶか
- 測る前に「計測器が生きているか」を確かめる
- 自分のページで確かめるスニペット
- まとめ
「0件」には2つの意味がある
打ち手が正反対なので、混ぜたまま報告してはいけない
イベントが0件のとき、あり得る話は2つです。
- 読まれていない。条件は成立し得たが、誰もそこまで来なかった。直すべきは導線と中身で、計測は正しく動いている
- 発火し得なかった。条件が数学的に成立不能だった。読者は来ていたかもしれない。直すべきは計測コードで、中身をいくら良くしても0件のまま
この2つはログの見た目では区別がつきません。どちらも「0」と表示されます。そして①だと思い込むと、存在しない問題を直し続けることになります。
僕は①だと思って、セクションの見出しと導線を書き直そうとしていました。手を動かす前に「そもそも呼ばれているのか」を確かめたので助かった、という順序です。
PCで開くと「動いている」ように見えるのが厄介
この不具合は、画面の高さが十分にある環境では正常に動きます。だから開発機で確認する限り、一生気づきません。
しかも後で見るとおり、縦に短いノートPCのほうが、縦に長いスマホより先に壊れます。「スマホでだけ壊れる」という覚え方すら正確ではありません。
なぜ届かないのか
intersectionRatio の分母は要素の面積であって、画面ではない
IntersectionObserver API のコールバックに渡ってくる intersectionRatio は、「重なっている矩形の面積 ÷ 監視対象の矩形の面積」です。仕様 でもそう定義されています。
つまり分母は要素です。画面ではありません。
要素が画面より低ければ、いずれ全部が画面に入るので比は 1.0 まで上がります。要素が画面より高いと、画面いっぱいに映っていても、重なれる面積は画面の高さで頭打ちになります。
上限は 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段構えにしました。
- 100px 刻みで最後まで送る(ふつうのスクロールに近い刻み)
- 各要素の 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セルとも計算と一致した
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 が届かない
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まで掃きました。
| 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通りを同じページで同時に走らせた結果
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行書く
選べない理由はたいてい、「何を読まれたと呼ぶか」が決まっていないことです。決まれば手法は一意に決まります。
| 何を「読まれた」と呼ぶか | 使うもの |
|---|---|
| 要素の一部でも見えたら |
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つです。
- 「自分のページで確かめるスニペット」を自分のページのコンソールに貼って、ウィンドウの高さを2通りで実行する。
0.5に届かないが0件なら、この記事の話は当面関係ありません - 0件でなかったら、その要素に付けている
thresholdを確認する。天井より大きい値が書かれていたら、そのイベントは一度も送られていません
僕自身は、0件のイベントを「読まれていない」と読んで中身を直そうとしていました。直す前に測ったから助かった、というだけの話です。次にどこかで0件を見たときも、同じ順序でやります。
計測が入っていない・入っているのに値が出ていないといった、公開中のサイトの「測れていない箇所」を実測して直す順番まで出す単発監査もやっています(毎日ラボ/お問い合わせ)。必要なのは公開URLだけです。
この記事で使った測定の考え方(ヘッドレス Chrome を CDP で駆動して実フレームを回す)はこちらに、「書いてあるのに画面に出ていない文章」を機械で数える話はこちらに書いてあります。どちらも今回と同じで、自分の道具を疑うところから始めた記録です。
まずは「自分のページで確かめるスニペット」を、ウィンドウの高さを2通りにして試してみてください。0.5に届かない が1件でも出たら、そこに書いた threshold はいま一度も発火していません。






