セクションドットナビ

画面右に縦並びのドットを固定し、表示中のセクションを示す1ページナビ。ホバーでセクション名が現れ、クリックで移動します。フルスクリーン構成のLPやポートフォリオに似合います。

#sticky#dots#scrollspy#onepage

ライブデモ

使用例(お題: アイドルグループ Sakura)

この技法を「アイドルグループ Sakura」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- Sakura:1ページ構成の公式サイトのドットナビ -->
<div class="ssd-frame">
  <div class="ssd-scroll" id="ssdScroll">
    <section class="ssd-sec ssd-sec--1" id="d1"><div><p class="ssd-no">01</p><h2>Top</h2></div></section>
    <section class="ssd-sec ssd-sec--2" id="d2"><div><p class="ssd-no">02</p><h2>Live</h2></div></section>
    <section class="ssd-sec ssd-sec--3" id="d3"><div><p class="ssd-no">03</p><h2>Member</h2></div></section>
    <section class="ssd-sec ssd-sec--4" id="d4"><div><p class="ssd-no">04</p><h2>Goods</h2></div></section>
  </div>

  <nav class="ssd-dots" id="ssdDots" aria-label="セクション">
    <button class="ssd-dot is-active" type="button" data-to="d1"><span class="ssd-dot__label">Top</span></button>
    <button class="ssd-dot" type="button" data-to="d2"><span class="ssd-dot__label">Live</span></button>
    <button class="ssd-dot" type="button" data-to="d3"><span class="ssd-dot__label">Member</span></button>
    <button class="ssd-dot" type="button" data-to="d4"><span class="ssd-dot__label">Goods</span></button>
  </nav>
</div>
CSS
/* Sakura(アイドル):セクションドットナビの再スキン */
* { box-sizing: border-box; }
body { margin: 0; font-family: "Hiragino Kaku Gothic ProN", "Segoe UI", system-ui, sans-serif; }

.ssd-frame { position: relative; width: 100%; height: 380px; overflow: hidden; }

.ssd-scroll { height: 100%; overflow-y: auto; scroll-snap-type: y mandatory; scrollbar-width: none; }
.ssd-scroll::-webkit-scrollbar { display: none; }
.ssd-sec { height: 380px; scroll-snap-align: start; display: grid; place-content: center; text-align: center; color: #fff; }
.ssd-sec .ssd-no { margin: 0 0 6px; font-size: 13px; letter-spacing: .3em; opacity: .75; }
.ssd-sec h2 { margin: 0; font-size: 46px; font-weight: 900; letter-spacing: -.01em; }
.ssd-sec--1 { background: linear-gradient(135deg, #7b1d54, #d6336c); }
.ssd-sec--2 { background: linear-gradient(135deg, #d6336c, #f76707); }
.ssd-sec--3 { background: linear-gradient(135deg, #b5179e, #7048e8); }
.ssd-sec--4 { background: linear-gradient(135deg, #2a0e2e, #7b1d54); }

.ssd-dots { position: absolute; right: 16px; top: 50%; transform: translateY(-50%); z-index: 10; display: flex; flex-direction: column; gap: 14px; }
.ssd-dot { position: relative; width: 12px; height: 12px; padding: 0; cursor: pointer; border: 2px solid rgba(255,255,255,.7); border-radius: 50%; background: transparent; transition: transform .2s ease, background .2s ease; }
.ssd-dot.is-active { background: #fff; transform: scale(1.25); }
.ssd-dot__label { position: absolute; right: 22px; top: 50%; transform: translateY(-50%); font-size: 11px; font-weight: 700; white-space: nowrap; color: #fff; background: rgba(0,0,0,.4); padding: 3px 9px; border-radius: 6px; opacity: 0; pointer-events: none; transition: opacity .2s ease; -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); }
.ssd-dot:hover .ssd-dot__label, .ssd-dot.is-active .ssd-dot__label { opacity: 1; }

@media (prefers-reduced-motion: reduce) { .ssd-dot, .ssd-dot__label { transition: none; } }
JavaScript
// (デモと同じフックを流用)セクション位置からドット現在地を更新、クリックで移動
(() => {
  const sc = document.getElementById('ssdScroll');
  const dots = Array.from(document.querySelectorAll('#ssdDots .ssd-dot'));
  if (!sc || !dots.length) return;
  const secs = dots.map(d => sc.querySelector('#' + d.dataset.to)).filter(Boolean);
  dots.forEach((d, i) => d.addEventListener('click', () => { auto = false; if (secs[i]) sc.scrollTo({ top: secs[i].offsetTop, behavior: 'smooth' }); }));
  function spy() {
    const idx = Math.round(sc.scrollTop / sc.clientHeight);
    dots.forEach((d, i) => d.classList.toggle('is-active', i === Math.min(idx, dots.length - 1)));
  }
  let ticking = false;
  sc.addEventListener('scroll', () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { spy(); ticking = false; }); }, { passive: true });
  spy();
  let auto = !matchMedia('(prefers-reduced-motion: reduce)').matches, i = 0, dir = 1;
  ['wheel', 'touchstart', 'pointerdown'].forEach(ev => sc.addEventListener(ev, () => { auto = false; }, { passive: true }));
  if (auto) {
    const advance = () => {
      if (!auto) return;
      i += dir;
      if (i >= secs.length - 1) dir = -1; else if (i <= 0) dir = 1;
      if (secs[i]) sc.scrollTo({ top: secs[i].offsetTop, behavior: 'smooth' });
      setTimeout(advance, 1900);
    };
    setTimeout(advance, 1800);
  }
})();

実装ガイド

使いどころ

フルスクリーン構成のLP・ポートフォリオ・1ページ公式サイトに。画面右に固定した縦ドットが表示中のセクションを示し、ホバーで名称表示、クリックで移動できます。

実装時の注意点

scroll-snap で1画面ずつ吸着させ、scrollTop ÷ ビューポート高でおおよそのセクション番号を求めてドットを更新します。ドットのラベルは絶対配置で、ホバー/アクティブ時にフェード表示。クリックは該当セクションへ smooth スクロールします。

対応ブラウザ

scroll-snap・smooth scroll・transform は全モダンブラウザで対応します。scrollbar 非表示の指定はブラウザ毎の記法(-webkit- とプロパティ)を併記しています。

よくある失敗

snap が強すぎると途中位置で引っかかるため、セクション高をビューポートに合わせること。ドットだけだと意味が伝わりにくいので、ホバーラベルやアクティブ時の名称表示を用意。モバイルでは小さすぎてタップしづらいので、当たり判定を広げます。

応用例

ドットにセクション色を反映、進捗バー併用、縦書きラベル、キーボード(上下キー)操作対応などの展開が可能です。

コード

HTML
<!-- 右端固定のセクションドットナビ(現在地表示) -->
<div class="ssd-frame">
  <div class="ssd-scroll" id="ssdScroll">
    <section class="ssd-sec ssd-sec--1" id="d1"><div><p class="ssd-no">01</p><h2>Intro</h2></div></section>
    <section class="ssd-sec ssd-sec--2" id="d2"><div><p class="ssd-no">02</p><h2>Work</h2></div></section>
    <section class="ssd-sec ssd-sec--3" id="d3"><div><p class="ssd-no">03</p><h2>About</h2></div></section>
    <section class="ssd-sec ssd-sec--4" id="d4"><div><p class="ssd-no">04</p><h2>Contact</h2></div></section>
  </div>

  <nav class="ssd-dots" id="ssdDots" aria-label="セクション">
    <button class="ssd-dot is-active" type="button" data-to="d1"><span class="ssd-dot__label">Intro</span></button>
    <button class="ssd-dot" type="button" data-to="d2"><span class="ssd-dot__label">Work</span></button>
    <button class="ssd-dot" type="button" data-to="d3"><span class="ssd-dot__label">About</span></button>
    <button class="ssd-dot" type="button" data-to="d4"><span class="ssd-dot__label">Contact</span></button>
  </nav>
</div>
CSS
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", system-ui, -apple-system, sans-serif; }

.ssd-frame { position: relative; width: 100%; height: 380px; overflow: hidden; }

.ssd-scroll { height: 100%; overflow-y: auto; scroll-snap-type: y mandatory; scrollbar-width: none; }
.ssd-scroll::-webkit-scrollbar { display: none; }

.ssd-sec {
  height: 380px; scroll-snap-align: start;
  display: grid; place-content: center; text-align: center; color: #fff;
}
.ssd-sec .ssd-no { margin: 0 0 6px; font-size: 13px; letter-spacing: .3em; opacity: .7; }
.ssd-sec h2 { margin: 0; font-size: 46px; font-weight: 900; letter-spacing: -.01em; }
.ssd-sec--1 { background: linear-gradient(135deg, #4338ca, #6d28d9); }
.ssd-sec--2 { background: linear-gradient(135deg, #db2777, #f97316); }
.ssd-sec--3 { background: linear-gradient(135deg, #0ea5e9, #14b8a6); }
.ssd-sec--4 { background: linear-gradient(135deg, #1f2937, #4b5563); }

/* 右端固定ドット */
.ssd-dots {
  position: absolute; right: 16px; top: 50%; transform: translateY(-50%); z-index: 10;
  display: flex; flex-direction: column; gap: 14px;
}
.ssd-dot {
  position: relative; width: 12px; height: 12px; padding: 0; cursor: pointer;
  border: 2px solid rgba(255,255,255,.7); border-radius: 50%; background: transparent;
  transition: transform .2s ease, background .2s ease;
}
.ssd-dot.is-active { background: #fff; transform: scale(1.25); }
.ssd-dot__label {
  position: absolute; right: 22px; top: 50%; transform: translateY(-50%);
  font-size: 11px; font-weight: 700; white-space: nowrap; color: #fff;
  background: rgba(0,0,0,.45); padding: 3px 9px; border-radius: 6px;
  opacity: 0; pointer-events: none; transition: opacity .2s ease;
  -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
}
.ssd-dot:hover .ssd-dot__label, .ssd-dot.is-active .ssd-dot__label { opacity: 1; }

@media (prefers-reduced-motion: reduce) { .ssd-dot, .ssd-dot__label { transition: none; } }
JavaScript
// セクションのスクロール位置からドットの現在地を更新(クリックで移動)
(() => {
  const sc = document.getElementById('ssdScroll');
  const dots = Array.from(document.querySelectorAll('#ssdDots .ssd-dot'));
  if (!sc || !dots.length) return;
  const secs = dots.map(d => sc.querySelector('#' + d.dataset.to)).filter(Boolean);

  dots.forEach((d, i) => d.addEventListener('click', () => {
    auto = false;
    if (secs[i]) sc.scrollTo({ top: secs[i].offsetTop, behavior: 'smooth' });
  }));

  function spy() {
    const idx = Math.round(sc.scrollTop / sc.clientHeight);
    dots.forEach((d, i) => d.classList.toggle('is-active', i === Math.min(idx, dots.length - 1)));
  }
  let ticking = false;
  sc.addEventListener('scroll', () => {
    if (ticking) return; ticking = true;
    requestAnimationFrame(() => { spy(); ticking = false; });
  }, { passive: true });
  spy();

  // セクションを自動で巡回(snapに合わせて1画面ずつ)
  let auto = !matchMedia('(prefers-reduced-motion: reduce)').matches, i = 0, dir = 1;
  ['wheel', 'touchstart', 'pointerdown'].forEach(ev => sc.addEventListener(ev, () => { auto = false; }, { passive: true }));
  if (auto) {
    const advance = () => {
      if (!auto) return;
      i += dir;
      if (i >= secs.length - 1) dir = -1; else if (i <= 0) dir = 1;
      if (secs[i]) sc.scrollTo({ top: secs[i].offsetTop, behavior: 'smooth' });
      setTimeout(advance, 1900);
    };
    setTimeout(advance, 1800);
  }
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「セクションドットナビ」の効果を追加してください。

# 追加してほしい効果
セクションドットナビ(追従ウィジェット)
画面右に縦並びのドットを固定し、表示中のセクションを示す1ページナビ。ホバーでセクション名が現れ、クリックで移動します。フルスクリーン構成のLPやポートフォリオに似合います。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 右端固定のセクションドットナビ(現在地表示) -->
<div class="ssd-frame">
  <div class="ssd-scroll" id="ssdScroll">
    <section class="ssd-sec ssd-sec--1" id="d1"><div><p class="ssd-no">01</p><h2>Intro</h2></div></section>
    <section class="ssd-sec ssd-sec--2" id="d2"><div><p class="ssd-no">02</p><h2>Work</h2></div></section>
    <section class="ssd-sec ssd-sec--3" id="d3"><div><p class="ssd-no">03</p><h2>About</h2></div></section>
    <section class="ssd-sec ssd-sec--4" id="d4"><div><p class="ssd-no">04</p><h2>Contact</h2></div></section>
  </div>

  <nav class="ssd-dots" id="ssdDots" aria-label="セクション">
    <button class="ssd-dot is-active" type="button" data-to="d1"><span class="ssd-dot__label">Intro</span></button>
    <button class="ssd-dot" type="button" data-to="d2"><span class="ssd-dot__label">Work</span></button>
    <button class="ssd-dot" type="button" data-to="d3"><span class="ssd-dot__label">About</span></button>
    <button class="ssd-dot" type="button" data-to="d4"><span class="ssd-dot__label">Contact</span></button>
  </nav>
</div>

【CSS】
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", system-ui, -apple-system, sans-serif; }

.ssd-frame { position: relative; width: 100%; height: 380px; overflow: hidden; }

.ssd-scroll { height: 100%; overflow-y: auto; scroll-snap-type: y mandatory; scrollbar-width: none; }
.ssd-scroll::-webkit-scrollbar { display: none; }

.ssd-sec {
  height: 380px; scroll-snap-align: start;
  display: grid; place-content: center; text-align: center; color: #fff;
}
.ssd-sec .ssd-no { margin: 0 0 6px; font-size: 13px; letter-spacing: .3em; opacity: .7; }
.ssd-sec h2 { margin: 0; font-size: 46px; font-weight: 900; letter-spacing: -.01em; }
.ssd-sec--1 { background: linear-gradient(135deg, #4338ca, #6d28d9); }
.ssd-sec--2 { background: linear-gradient(135deg, #db2777, #f97316); }
.ssd-sec--3 { background: linear-gradient(135deg, #0ea5e9, #14b8a6); }
.ssd-sec--4 { background: linear-gradient(135deg, #1f2937, #4b5563); }

/* 右端固定ドット */
.ssd-dots {
  position: absolute; right: 16px; top: 50%; transform: translateY(-50%); z-index: 10;
  display: flex; flex-direction: column; gap: 14px;
}
.ssd-dot {
  position: relative; width: 12px; height: 12px; padding: 0; cursor: pointer;
  border: 2px solid rgba(255,255,255,.7); border-radius: 50%; background: transparent;
  transition: transform .2s ease, background .2s ease;
}
.ssd-dot.is-active { background: #fff; transform: scale(1.25); }
.ssd-dot__label {
  position: absolute; right: 22px; top: 50%; transform: translateY(-50%);
  font-size: 11px; font-weight: 700; white-space: nowrap; color: #fff;
  background: rgba(0,0,0,.45); padding: 3px 9px; border-radius: 6px;
  opacity: 0; pointer-events: none; transition: opacity .2s ease;
  -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
}
.ssd-dot:hover .ssd-dot__label, .ssd-dot.is-active .ssd-dot__label { opacity: 1; }

@media (prefers-reduced-motion: reduce) { .ssd-dot, .ssd-dot__label { transition: none; } }

【JavaScript】
// セクションのスクロール位置からドットの現在地を更新(クリックで移動)
(() => {
  const sc = document.getElementById('ssdScroll');
  const dots = Array.from(document.querySelectorAll('#ssdDots .ssd-dot'));
  if (!sc || !dots.length) return;
  const secs = dots.map(d => sc.querySelector('#' + d.dataset.to)).filter(Boolean);

  dots.forEach((d, i) => d.addEventListener('click', () => {
    auto = false;
    if (secs[i]) sc.scrollTo({ top: secs[i].offsetTop, behavior: 'smooth' });
  }));

  function spy() {
    const idx = Math.round(sc.scrollTop / sc.clientHeight);
    dots.forEach((d, i) => d.classList.toggle('is-active', i === Math.min(idx, dots.length - 1)));
  }
  let ticking = false;
  sc.addEventListener('scroll', () => {
    if (ticking) return; ticking = true;
    requestAnimationFrame(() => { spy(); ticking = false; });
  }, { passive: true });
  spy();

  // セクションを自動で巡回(snapに合わせて1画面ずつ)
  let auto = !matchMedia('(prefers-reduced-motion: reduce)').matches, i = 0, dir = 1;
  ['wheel', 'touchstart', 'pointerdown'].forEach(ev => sc.addEventListener(ev, () => { auto = false; }, { passive: true }));
  if (auto) {
    const advance = () => {
      if (!auto) return;
      i += dir;
      if (i >= secs.length - 1) dir = -1; else if (i <= 0) dir = 1;
      if (secs[i]) sc.scrollTo({ top: secs[i].offsetTop, behavior: 'smooth' });
      setTimeout(advance, 1900);
    };
    setTimeout(advance, 1800);
  }
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。