こんにちは、株式会社ファストコーディングのBigViです。通勤電車でニュース記事を読んでいたら、乗り換えのタイミングでページを閉じてしまいました。。次の電車でもう一度開いたら、最初に戻っていた。「さっきどこまで読んだっけ?」ってスクロールして探すの、地味に面倒でした。
先日、お客さまから「LPが長いけど、途中で離脱して戻ってきたユーザーが、また最初から読み直している」と相談されました。ヒートマップを見ると、再訪ユーザーのスクロール深度が初回訪問より浅い。途中で読むのをやめてしまっているみたいです。
私もよくあります。長いページを途中まで読んで、タブを閉じて、後で戻ったら「どこまで読んだっけ?」ってなる。。
今回は「localStorageで最深到達セクションを保存して、再訪時に『つづきを読む』リンクをフェードインさせる」という実装をしました。ユーザーの読書進捗を覚えていて、静かに案内します。
「最初から読み直し」が離脱を生む理由
長いLPやサービスページでは、1回で全部読む人は少ないです。お昼休みに途中まで読んで、帰りの電車でもう一度開く。そんな使い方が多いです。
でも、再訪したときにまた最初から始まると「もういいや」ってなります。特にスマホだと、スクロールして探すのが大変です。
ある案件で、再訪ユーザーの平均スクロール深度を調べました。初回訪問は72%まで読んでいるのに、再訪は38%で止まっていました。つまり、前回読んだところまで戻るのが面倒で、途中でやめてしまっている。お客さまは「再訪ユーザーのCVRが低い理由がこれかもしれない」と言っていました。
実装方法
HTML構造
HTMLは普通のセクション構造です。各セクションにidをつけるだけです。
<main class="lp-content">
<section id="section-hero" data-section-label="トップ">
<h1>サービス名</h1>
<p>キャッチコピー</p>
</section>
<section id="section-features" data-section-label="特徴">
<h2>サービスの特徴</h2>
<p>説明テキスト...</p>
</section>
<section id="section-pricing" data-section-label="料金">
<h2>料金プラン</h2>
<p>料金テキスト...</p>
</section>
<section id="section-faq" data-section-label="よくある質問">
<h2>FAQ</h2>
<p>質問と回答...</p>
</section>
<section id="section-contact" data-section-label="お問い合わせ">
<h2>お問い合わせ</h2>
<form>...</form>
</section>
</main>
<!-- 再訪時に表示されるリンク -->
<div class="continue-reading" id="js-continue-reading" aria-live="polite">
<a href="#" id="js-continue-link">
<span class="continue-icon">▼</span>
<span class="continue-text">つづきを読む(<span id="js-continue-label"></span>)</span>
</a>
<button class="continue-close" id="js-continue-close" aria-label="閉じる">×</button>
</div>data-section-labelは、「つづきを読む(料金)」のように、ユーザーにわかりやすい名前を表示するためです。
CSSスタイル
「つづきを読む」のリンクは、画面の右下に固定で表示します。最初は非表示で、フェードインします。
.continue-reading {
position: fixed;
bottom: 24px;
right: 24px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 12px 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 8px;
opacity: 0;
transform: translateY(8px);
transition: opacity 0.4s ease, transform 0.4s ease;
pointer-events: none;
z-index: 100;
}
.continue-reading.is-visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.continue-reading a {
text-decoration: none;
color: #1a73e8;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
}
.continue-icon {
font-size: 12px;
animation: bounce-down 1.5s ease-in-out infinite;
}
@keyframes bounce-down {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(3px); }
}
.continue-close {
background: none;
border: none;
font-size: 16px;
color: #999;
cursor: pointer;
padding: 0 0 0 8px;
}
@media (prefers-reduced-motion: reduce) {
.continue-reading {
transition: opacity 0.01s;
transform: none;
}
.continue-icon {
animation: none;
}
}
@media (max-width: 480px) {
.continue-reading {
bottom: 16px;
right: 16px;
left: 16px;
}
}スマホでは左右いっぱいに広げています。小さいボタンだと押しにくいからです。
JavaScriptの実装
IntersectionObserverで各セクションの通過を検知して、localStorageに最深到達セクションを保存します。
document.addEventListener('DOMContentLoaded', () => {
const STORAGE_KEY = 'lp_deepest_section';
const sections = document.querySelectorAll('[data-section-label]');
const continueEl = document.getElementById('js-continue-reading');
const continueLink = document.getElementById('js-continue-link');
const continueLabel = document.getElementById('js-continue-label');
const closeBtn = document.getElementById('js-continue-close');
if (!sections.length || !continueEl) return;
// セクションの順序マップを作成
const sectionOrder = new Map();
sections.forEach((sec, i) => sectionOrder.set(sec.id, i));
let deepest = -1;
// 前回の最深到達セクションを取得
const saved = localStorage.getItem(STORAGE_KEY);
// 再訪判定+つづきリンク表示
if (saved) {
const target = document.getElementById(saved);
if (target && sectionOrder.has(saved)) {
const label = target.dataset.sectionLabel;
continueLabel.textContent = label;
continueLink.addEventListener('click', (e) => {
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth' });
continueEl.classList.remove('is-visible');
});
closeBtn.addEventListener('click', () => {
continueEl.classList.remove('is-visible');
});
// 少し遅らせてフェードイン
setTimeout(() => {
continueEl.classList.add('is-visible');
}, 800);
}
}
// 最深到達セクションの更新
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const idx = sectionOrder.get(entry.target.id);
if (idx > deepest) {
deepest = idx;
localStorage.setItem(STORAGE_KEY, entry.target.id);
}
});
}, { threshold: 0.3 });
sections.forEach((sec) => io.observe(sec));
});ポイントはsectionOrderのMapです。各セクションに番号をつけて、「前回より深いセクションに到達したか」を判定します。浅いセクションに戻ってもlocalStorageは更新しません。最深だけを記録します。
再訪時に、800ms後にフェードインさせているのは、ページが読み込まれてすぐ出すとびっくりするからです。少し待ってから静かに出します。
注意点
実装するときに気をつけたことがあります。
1つ目はプライバシーです。localStorageはブラウザに保存されるだけで、サーバーには送りません。セクションIDだけを保存していて、個人を特定する情報は含みません。でも、プライバシーポリシーには書いておいたほうがいいです。
2つ目はlocalStorageが使えない場合です。プライベートブラウジングなどで使えないことがあります。try-catchで囲むのが安全です。今回のコードでは省略していますが、実際のプロジェクトでは必ず入れています。
3つ目は「つづきを読む」の位置です。右下に固定していますが、他のフローティング要素(チャットボットなど)と重ならないように気をつけてください。bottomとrightの値を調整するか、既存のフローティングUIがある側と反対に出すのがいいです。
prefers-reduced-motionにも対応しています。アニメーションが苦手なユーザーには、フェードやバウンスを無効にしています。
実際の結果
このお客さまのプロジェクトで、A/Bテストを3週間やりました。
| 指標 | つづきリンクなし(A) | つづきリンクあり(B) | 差分 |
|---|---|---|---|
| 再訪ユーザーの平均スクロール深度 | 38.2% | 61.7% | +23.5pt |
| 再訪ユーザーのCVR | 2.1% | 3.4% | +1.3pt |
| つづきリンクのクリック率 | – | 34.8% | – |
再訪ユーザーのスクロール深度が23.5ポイント上がりました。「つづきを読む」をクリックした人は34.8%でした。3人に1人が使ってくれています。CVRも1.3ポイント上がりました。途中まで読んだ人は、続きを読むと最後のCTAまで到達しやすいです。ただし、これは1つのプロジェクトでの結果です。効果はLPの長さやコンテンツの種類によって変わります。
お客さまは「再訪ユーザーのことを、こんなに考えたことがなかった」と言ってくれました。。
まとめ
再訪ユーザーは、すでに興味を持っているユーザーです。でも「どこまで読んだっけ?」という小さなストレスで離脱してしまうことがあります。localStorageで最深到達セクションを覚えておくだけで、その問題を解決できます。
今回のポイントは以下の3つです:
- localStorageに最深到達セクションのIDだけを保存。個人情報は含まない
- IntersectionObserverで「通過した」セクションを検知。scrollイベントより効率的
- 再訪時は800ms後にフェードイン。すぐ出すとびっくりするので、少し待つ
実装は30行ぐらいのJSで、既存のLPにすぐ追加できます。長いページで再訪ユーザーの離脱が気になっている方は、試してみてください。
株式会社ファストコーディングでは、こうしたユーザー体験の改善やフロントエンドの実装サポートをしています。「LPの離脱率を下げたい」「再訪ユーザーのCVRを上げたい」という方は、お問い合わせフォームから気軽にご連絡ください。
※本記事は弊社外国人スタッフによる投稿です。言い回しや表現が不十分な個所がありますことご容赦いただきますようお願いいたします。

