ページを開いたまましばらく何も操作しない(スクロールしない)ユーザーは、「読むのに迷っている」「次にどこを見ればいいか分からない」状態かもしれません。そのタイミングでキャンペーンや関連コンテンツのポップアップをそっと出すと、離脱を防いで次の行動へ誘導できます。この記事では、JavaScript だけで「一定時間スクロール(操作)がなかったらポップアップを表示する」処理を、最小実装から実務で使える完成形まで順番に組み立てます。
- 無操作を検知する「タイマー方式」の基本の仕組み
- スクロールだけを見る最小実装と、その落とし穴
- マウス・キー・タップも含めた本物のアイドル検出への拡張
sessionStorage/localStorageによる表示回数の制御- スクロールできない短いページで誤発火させない対策
- 閉じる導線・ESC・
aria属性などアクセシビリティ対応 - そのまま使える再利用クラス
IdlePopup
基本の仕組み:タイマーを「操作のたびにリセット」する
考え方はシンプルです。ページ読み込み時に「○秒後にポップアップを出す」タイマーをセットし、ユーザーが操作するたびにそのタイマーをリセット(クリアして貼り直す)します。こうすると、操作が止まってから○秒経ったときだけタイマーが満了してポップアップが表示されます。
- ページ読み込み後にタイマーを開始する
- 操作(スクロールなど)を検知したら
clearTimeout()でリセットする - 一定時間(例:10秒)操作がなければ
setTimeout()が満了して表示する
最小実装:スクロールが止まったら表示する
まずポップアップの土台を HTML で用意します。最初は hidden 属性で隠しておきます。
<!-- 最初は hidden で非表示にしておく -->
<div id="idle-popup" class="idle-popup" role="dialog" aria-modal="false"
aria-labelledby="idle-popup-title" hidden>
<p id="idle-popup-title">あなたへのおすすめ記事をピックアップしました</p>
<a href="/campaign/" class="idle-popup__cta">詳しく見る</a>
<button type="button" class="idle-popup__close" data-close aria-label="閉じる">×</button>
</div>
右下に固定表示するスタイルの例です。
.idle-popup {
position: fixed;
right: 20px;
bottom: 20px;
max-width: 320px;
padding: 20px 24px;
background: #1e293b;
color: #fff;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, .25);
z-index: 1000;
}
.idle-popup[hidden] { display: none; } /* hidden を確実に効かせる */
.idle-popup__close {
position: absolute;
top: 8px;
right: 10px;
border: 0;
background: none;
color: #cbd5e1;
font-size: 18px;
cursor: pointer;
}
JavaScript はスクロールを監視して、止まってから10秒でポップアップを表示します。
const popup = document.getElementById("idle-popup");
const idleTime = 10000; // 10秒(ミリ秒)
let timer;
function showPopup() {
popup.hidden = false;
}
function resetTimer() {
clearTimeout(timer);
timer = setTimeout(showPopup, idleTime);
}
resetTimer(); // 初回セット
// スクロールのたびにリセット(passive: true でスクロール性能を確保)
window.addEventListener("scroll", resetTimer, { passive: true });
{ passive: true } を付けるスクロールイベントは高頻度で発火します。リスナーを
{ passive: true } で登録すると、ブラウザが preventDefault() を待たずにスクロールを処理できるため、スクロールのカクつきを防げます。スクロール監視では基本的に付けておきます。スクロールイベントの扱いはスクロールイベントの使い方(throttle・IntersectionObserver)も参考にしてください。落とし穴:「スクロールだけ」では無操作を判定しきれない
上の実装はスクロールしか見ていません。ユーザーがマウスを動かしたり、テキストを選択したり、リンクにカーソルを合わせていても、スクロールさえしなければ「無操作」とみなしてポップアップが出てしまいます。「ページを読んでいる最中なのに割り込まれる」と、かえって体験を損ねます。
本当に「何も操作していない(アイドル状態)」を検知したいなら、スクロールに加えてマウス移動・キー入力・クリック・タップもリセット対象にします。複数イベントを1つの resetTimer にまとめて登録します。
const popup = document.getElementById("idle-popup");
const idleTime = 10000;
let timer;
// 「操作あり」とみなすイベント一覧
const ACTIVITY_EVENTS = ["scroll", "mousemove", "keydown", "click", "touchstart"];
function showPopup() {
popup.hidden = false;
}
function resetTimer() {
clearTimeout(timer);
timer = setTimeout(showPopup, idleTime);
}
// すべての操作イベントでタイマーをリセット
ACTIVITY_EVENTS.forEach(function (type) {
window.addEventListener(type, resetTimer, { passive: true });
});
resetTimer();
「最後まで読んだ=スクロールが止まった人」に出したいならスクロール限定、「席を外した・放置している人」に出したいなら全操作のアイドル検出が向いています。目的に合わない方を選ぶと、読んでいる人の邪魔をしたり、逆に出したい人に出せなかったりします。ログアウトやセッションタイムアウトなど、より厳密なアイドル検出は一定時間操作がなかった場合に処理を実行する方法(アイドル検出・自動ログアウト)で詳しく解説しています。
一度表示したら繰り返さない:表示回数を制御する
表示するたびにポップアップが出ると鬱陶しく感じられます。「同じ訪問では一度だけ」「一度閉じたらしばらく出さない」といった制御にはsessionStorage か localStorage を使います。メモリ上の変数(let shown = true など)だけだとページを再読み込みするたびにリセットされてしまうため、永続化が必要です。
const STORAGE_KEY = "idlePopupShown";
function showPopup() {
// すでに表示済みなら何もしない
if (sessionStorage.getItem(STORAGE_KEY)) return;
popup.hidden = false;
sessionStorage.setItem(STORAGE_KEY, "1"); // このセッションでは再表示しない
}
sessionStorage と localStorage の違いsessionStorage はタブを閉じると消えるため「同じ訪問の間だけ抑制」に向きます。localStorage は明示的に消すまで残るので「次回アクセス以降も出さない」用途に使います。「一定期間が過ぎたら再表示」したい場合は、表示した日時を保存して経過時間で判定します。ポップアップの表示ルールそのものは初回アクセス時に一度だけ表示するポップアップや一度閉じたら一定時間表示しないポップアップも合わせて参考になります。短いページで誤発火させない
コンテンツが画面に収まりスクロールできないページでは、ユーザーは「スクロールしようがない」のに無操作と判定され、必ずポップアップが出ます。スクロール停滞をトリガーにする場合は、そもそもスクロール可能かを先に確認しましょう。
function isScrollable() {
// ページの高さがビューポート + 余白より大きいか
return document.documentElement.scrollHeight > window.innerHeight + 100;
}
function resetTimer() {
clearTimeout(timer);
if (!isScrollable()) return; // 短いページでは作動させない
timer = setTimeout(showPopup, idleTime);
}
閉じる導線とアクセシビリティ
ポップアップは確実に閉じられることが大前提です。閉じるボタンに加えて、ESC キーでも閉じられるようにしておくと親切です。土台の HTML には role="dialog" と aria-labelledby を付け、支援技術にダイアログだと伝えています。
function closePopup() {
popup.hidden = true;
}
// 閉じるボタン
popup.querySelector(".idle-popup__close").addEventListener("click", closePopup);
// ESC キーでも閉じる
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") closePopup();
});
背景を覆うオーバーレイ方式にするときは、オーバーレイ自身のクリックでのみ閉じ、中身のクリックでは閉じないよう
if (e.target === overlay) closePopup(); とクリック対象を判定します。背景スクロールの固定など、モーダル特有の配慮はモーダル表示中に背景のスクロールを禁止する方法も参考にしてください。完成形:再利用できる IdlePopup クラス
ここまでの「複数イベント監視・表示回数制御・短ページ対策・閉じる導線・表示後の監視停止」を1つのクラスにまとめました。要素のセレクターとオプションを渡すだけで使えます。
class IdlePopup {
constructor(target, options = {}) {
this.el = typeof target === "string"
? document.querySelector(target)
: target;
this.idleTime = options.idleTime ?? 10000;
this.events = options.events ?? ["scroll", "mousemove", "keydown", "touchstart"];
this.storageKey = options.storageKey ?? "idlePopupShown";
this.storage = options.storage ?? sessionStorage; // localStorage に差し替え可
this.timer = null;
this._reset = this.reset.bind(this);
this.init();
}
init() {
if (!this.el) return;
if (this.storage.getItem(this.storageKey)) return; // 表示済みなら何もしない
if (!this.isScrollable()) return; // 短すぎるページは対象外
this.events.forEach((t) =>
window.addEventListener(t, this._reset, { passive: true })
);
this.reset();
const closeBtn = this.el.querySelector("[data-close]");
if (closeBtn) closeBtn.addEventListener("click", () => this.hide());
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") this.hide();
});
}
isScrollable() {
return document.documentElement.scrollHeight > window.innerHeight + 100;
}
reset() {
clearTimeout(this.timer);
this.timer = setTimeout(() => this.show(), this.idleTime);
}
show() {
this.el.hidden = false;
this.storage.setItem(this.storageKey, "1");
this.teardown(); // 表示後は監視をやめる
}
hide() {
this.el.hidden = true;
}
teardown() {
clearTimeout(this.timer);
this.events.forEach((t) =>
window.removeEventListener(t, this._reset)
);
}
}
// 使い方:閉じるボタンに data-close を付けておく
new IdlePopup("#idle-popup", { idleTime: 10000 });
無操作トリガーと他の表示トリガーの比較
| トリガー | 発火する条件 | 向いている用途 |
|---|---|---|
| スクロール停滞 | スクロールが一定時間止まる | 読み終えた・迷っている読者への追加導線 |
| アイドル検出(全操作) | マウス・キー・タップも含め無操作 | 放置・離席の検知、再エンゲージ |
| exit-intent(離脱動作) | カーソルが画面上端へ抜ける等 | 離脱直前の引き止め・クーポン提示 |
「離脱しようとした瞬間」に出したい場合は、無操作タイマーではなくフォーム離脱時にアラートを表示する方法やページ離脱時にアラートを表示する完全ガイドで扱う離脱検知(mouseleave / beforeunload)が適しています。スクロールで要素を出し分けたいだけならIntersectionObserver でスクロール時に要素をふわっと表示させる方法も検討してください。
ポップアップは強い割り込みです。表示回数を必ず制限し、本文を読み込む前の早すぎる発火は避け、閉じる手段を分かりやすくしておきます。コンバージョンを狙うあまり頻発させると、直帰率の悪化や検索評価の低下につながります。
よくある質問(FAQ)
mousemove / keydown / click / touchstart を加え、それぞれで resetTimer を呼びます。これでマウス移動やタップも「操作あり」とみなせます。より厳密なアイドル検出はアイドル検出・自動ログアウトの記事を参照してください。localStorage に表示フラグを保存します。sessionStorage はタブを閉じると消えるため「同じ訪問の間だけ」、localStorage は「次回以降もずっと」抑制したい場合に使い分けます。「○日経ったら再表示」したいときは表示日時を保存し、経過時間で判定します。scroll に加えて touchstart / touchmove を監視対象に入れてください。いずれも { passive: true } を付けるとスクロール性能を損ないません。hidden 属性または CSS の display: none を初期値にし、JavaScript で表示に切り替えます。CSS で .idle-popup[hidden] { display: none; } を明示しておくと、hidden が確実に効いてチラつきを防げます。mouseleave、タブを閉じる直前の beforeunload などを使います。詳しくはページ離脱時にアラートを表示する完全ガイドをご覧ください。まとめ
無操作をトリガーにしたポップアップは、setTimeout を「操作のたびにリセットする」というシンプルな仕組みで実現できます。実務で使うときは、次の点を押さえておきましょう。
- スクロール限定か、全操作のアイドル検出かを目的に合わせて選ぶ
- スクロール監視は
{ passive: true }で性能を確保する - 表示回数は
sessionStorage/localStorageで必ず制御する - スクロールできない短いページでは作動させない
- 閉じる導線・ESC・
ariaでアクセシビリティを担保する - 出しすぎてユーザー体験を損なわない
紹介した IdlePopup クラスをベースに、表示時間や監視イベント、保存先ストレージをサイトの目的に合わせて調整してみてください。

