• #CSS
  • #position sticky
  • #Nuxt
  • #Vue
  • #デバッグ
未分類

マークダウンページの目次がスクロール時に固定表示されない問題

問題の症状

マークダウンページ([...slug].vue経由で表示されるページ)で、右側に表示されている目次(Table of Contents)がスクロール時に固定表示されず、コンテンツと一緒に上に移動して画面外に消えてしまう。

期待される動作

  • ページをスクロールしても目次が画面上部に固定表示される
  • position: stickyにより、目次が常に見える状態を維持

実際の動作

  • スクロールすると目次がコンテンツと一緒に上に移動する
  • position: stickyが機能していない
  • 目次が画面外に消えてしまう

原因の調査プロセス

1. 初期仮説(誤り)

最初に疑ったのは、グリッドレイアウトのjustify-content: centerが原因ではないかという点。

DocPage.vue:367-373

.doc-container {
  display: grid;
  grid-template-columns: minmax(0, 800px) 300px;
  gap: 3rem;
  justify-content: center;  // ← これが原因?
}

試した修正1

justify-content: centermax-widthmargin: 0 autoに変更:

.doc-container {
  display: grid;
  grid-template-columns: minmax(0, 800px) 300px;
  gap: 3rem;
  max-width: 1200px;
  margin: 0 auto;
}

結果: 問題は解決せず

2. グリッドアイテムの高さ制限(部分的な原因)

次に発見したのは、グリッドレイアウトのデフォルト動作(align-items: stretch)により、.doc-sidebarが記事全体の高さ(7094px)に引き伸ばされていた点。

Chrome DevToolsでの調査結果:

{
  "sidebar": {
    "position": "static",
    "height": "7094.58px",  // 記事全体の高さに引き伸ばされている
    "rect": {
      "top": -1903.515625,
      "height": 7094.578125
    }
  },
  "toc": {
    "position": "sticky",
    "top": "16px",
    "rect": {
      "top": -1903.515625  // stickyが機能していない
    }
  }
}

試した修正2

.doc-sidebaralign-self: startを追加:

.doc-sidebar {
  display: block;
  align-self: start;  // ← 追加
}

結果: サイドバーの高さは493pxに縮小したが、stickyは依然として機能せず

3. グリッドコンテナのalign-items設定

.doc-sidebaralign-self: startを設定すると、サイドバーが縮小されすぎて、中の.tocがコンテナの高さに制限されてしまう問題が発生。

Chrome DevToolsでの調査結果:

{
  "sidebar": {
    "height": "493.078px",  // TOCの内容の高さに縮小
    "rect": {
      "top": -1903.515625  // 画面外
    }
  }
}

試した修正3

.doc-containeralign-items: startを追加し、.doc-sidebaralign-selfを削除:

.doc-container {
  display: grid;
  grid-template-columns: minmax(0, 800px) 300px;
  gap: 3rem;
  max-width: 1200px;
  margin: 0 auto;
  align-items: start;  // ← 追加
}

.doc-sidebar {
  display: block;
  // align-self: start を削除
}

結果: サイドバーの高さは適切になったが、stickyは依然として機能せず

4. グリッドアイテムとしての直接配置

.doc-sidebarというラッパー要素を削除し、TableOfContentsコンポーネントを直接グリッドアイテムとして配置。

DocPage.vue:360

<!-- 修正前 -->
<aside class="doc-sidebar">
  <TableOfContents :links="doc.body?.toc?.links" />
</aside>

<!-- 修正後 -->
<TableOfContents :links="doc.body?.toc?.links" class="doc-toc" />

CSS:

.doc-toc {
  display: block;
}

@media (max-width: 1024px) {
  .doc-container {
    grid-template-columns: 1fr;
  }

  .doc-toc {
    display: none;
  }
}

結果: 依然としてstickyが機能しない

5. グリッドアイテムの実際の構造確認

Chrome DevToolsで実際のDOM構造とスタイルを確認:

{
  "containerDisplay": "grid",
  "containerAlignItems": "start",
  "children": [
    {
      "tag": "ARTICLE",
      "className": "doc",
      "position": "static",
      "height": "7094.58px"
    },
    {
      "tag": "NAV",
      "className": "toc doc-toc",
      "position": "sticky",
      "top": "16px",
      "height": "459.078px",
      "rect": {
        "top": 96.484375  // 初期位置
      }
    }
  ]
}

スクロール後(scrollY = 600):

{
  "toc": {
    "position": "sticky",
    "top": "16px",
    "rect": {
      "top": -503.515625  // 画面外に移動している
    },
    "isStuck": false
  }
}

問題点: position: stickyが設定されているにもかかわらず、要素がスクロールと一緒に移動している

6. 親要素のtransformプロパティ確認

position: stickyが機能しない一般的な原因として、親要素にtransformプロパティが設定されている可能性を確認:

{
  "ancestors": [
    {
      "tag": "NAV",
      "className": "toc doc-toc",
      "transform": "none",
      "filter": "none",
      "perspective": "none"
    },
    {
      "tag": "DIV",
      "className": "doc-container",
      "transform": "none"
    },
    {
      "tag": "MAIN",
      "transform": "none"
    }
  ]
}

結果: transformプロパティは設定されていない

原因と解決策(解決済み)

真の原因:htmlタグへのoverflow-x: hidden

調査の結果、apps/web/app/app.vueで設定されていた以下のグローバルスタイルが原因であることが判明しました。

/* 修正前 */
html, body {
  overflow-x: hidden;
  /* ... */
}

position: stickyは、親要素(または祖先要素)のいずれかにoverflowプロパティ(visible以外、特にhidden, scroll, auto)が設定されている場合、その要素がスクロールコンテナとして振る舞ってしまい、期待通りにウィンドウのスクロールに追従しなくなる(スティッキーコンテキストが閉じ込められる)という仕様があります。

今回の場合、bodyだけでなく**htmlタグにもoverflow-x: hiddenが適用されていた**ため、ルート要素レベルでスクロールの挙動に影響を与え、stickyが機能しなくなっていました。

実施した修正

apps/web/app/app.vueを修正し、overflow-x: hiddenhtmlタグから削除し、bodyタグのみに適用するように変更しました。

/* 修正後 */
html {
  max-width: 100%;
  margin: 0;
  padding: 0;
}

body {
  overflow-x: hidden; /* bodyのみに残す */
  max-width: 100%;
  margin: 0;
  padding: 0;
}

これにより、横スクロールの防止(bodyでの制御)を維持しつつ、html要素がstickyの動作を阻害しないようになり、目次が正しく固定表示されるようになりました。

追加機能:目次の自動スクロール

課題

position: stickyの問題を解決した後、目次が固定表示されるようになったものの、ページをスクロールして下部のセクションに到達した際に、目次の表示領域内で現在のアクティブな項目が視界に入らない(目次自体のスクロールが自動で追従しない)という問題が確認されました。

例えば、ユーザーがページの最下部(「まとめ」セクション)までスクロールした場合でも、目次は上部のセクション(例:「問題の症状」など)が表示されたままになっており、現在読んでいるセクションがハイライトされていても、それが目次の表示領域外にあり見えないという状態でした。

解決策

apps/web/app/components/TableOfContents.vueに、activeId(現在アクティブなセクションID)が変更されたときに、目次コンテナ内で該当するリンクが見える位置まで自動的にスクロールする機能を実装しました。

実装内容

  1. ref の追加: 目次の <nav> 要素への参照を追加
const tocNav = ref<HTMLElement | null>(null)
<nav v-if="flatLinks.length > 0" ref="tocNav" class="toc" aria-label="目次">
  1. watch による自動スクロール: activeId の変更を監視し、アクティブなリンクを目次の表示領域内に自動スクロール
// アクティブな見出しが変わったときにTOCをスクロール
watch(activeId, async (newId) => {
  if (!newId || !tocNav.value) return

  await nextTick()
  const activeLink = tocNav.value.querySelector<HTMLElement>(`.toc__link[href="#${newId}"]`)
  if (activeLink) {
    // scrollIntoViewだと親コンテナ(ウィンドウ全体)もスクロールしてしまう可能性があるため、
    // TOCコンテナ内のスクロール位置を手動で計算して制御する

    const container = tocNav.value
    const linkRect = activeLink.getBoundingClientRect()
    const containerRect = container.getBoundingClientRect()

    // リンクがコンテナの表示領域外にある、または端に近い場合にスクロール
    const offset = 20 // 余白
    const isAbove = linkRect.top < containerRect.top + offset
    const isBelow = linkRect.bottom > containerRect.bottom - offset

    if (isAbove || isBelow) {
      // 中央に表示されるようにスクロール位置を調整
      const scrollTop = container.scrollTop + (linkRect.top - containerRect.top) - (containerRect.height / 2) + (linkRect.height / 2)
      container.scrollTo({
        top: scrollTop,
        behavior: 'smooth'
      })
    }
  }
})

実装のポイント

  • scrollIntoView を使わない理由: Element.scrollIntoView() を使用すると、目次コンテナだけでなく、ページ全体(window)もスクロールしてしまう可能性があるため、目次コンテナ内のスクロールのみを制御するために手動で scrollTo を使用
  • 中央配置: アクティブなリンクが目次コンテナの中央付近に表示されるように計算
  • スムーズスクロール: behavior: 'smooth' を指定することで、スクロール時のアニメーションを実現

結果

この実装により、ユーザーがページをスクロールすると、目次も自動的にスクロールし、現在読んでいるセクションに対応するリンクが常に目次の表示領域内に保たれるようになりました。

関連ファイル

  • apps/web/app/components/DocPage.vue - ドキュメントページのレイアウト
  • apps/web/app/components/TableOfContents.vue - 目次コンポーネント
  • apps/web/app/pages/[...slug].vue - 動的ルートページ

参考情報