版面構圖

Synced Outline Reader

內容+大綱雙欄閱讀版面:指示脊椎隨閱讀位置連續伸縮滑動,當前章節同步聚焦。

把這個效果裝進你的專案

在專案資料夾執行指令,CLI 會加入原始碼與依賴套件,再依下方使用範例引用元件。

npx webberui@latest add synced-outline-reader

還沒有專案,或安裝遇到問題?看快速開始 →

Synced Outline Reader 是用 React、Tailwind CSS 與 Motion 做的文章目錄(table of contents)雙欄閱讀版面:以 IntersectionObserver 追蹤每個章節的可見比例,右側大綱欄的指示線跟著捲動位置連續移動,類似 scroll-spy,但不是逐格跳動。

Playground

即時調整 props、程式碼片段同步更新——直接試出你要的樣子再複製。

編輯中的呼叫預設屬性
<SyncedOutlineReader/>

完整範例包含預覽的排版與資料。先安裝元件,再把範例檔貼入你的專案。

適用情境

  • 長篇技術文件、說明文件或部落格文章,讓讀者隨時知道自己讀到哪一節。
  • 服務條款、隱私權政策這類章節多的法律頁面,可從大綱直接跳到指定條款。
  • 產品更新說明或年度報告,依章節分段閱讀。
  • 後台的設定說明頁或內嵌在 overflow 容器中的閱讀面板(搭配 container)。
  • 不適合:只需要固定在畫面側邊的區段圓點導覽、不需要文字大綱時,改用 Scroll Spy Dots。

安裝

npx webberui@latest add https://webberui.com/r/synced-outline-reader.json

使用

sections 定義大綱清單,內容以 OutlineSection 包裹、id 一一對應:

import {
  SyncedOutlineReader,
  OutlineSection,
} from "@/components/ui/synced-outline-reader";

const sections = [
  { id: "intro", title: "簡介" },
  { id: "usage", title: "使用方式" },
  { id: "faq", title: "常見問題" },
];

<SyncedOutlineReader sections={sections}>
  <OutlineSection id="intro" title="簡介">
    <p>第一章內文……</p>
  </OutlineSection>
  <OutlineSection id="usage" title="使用方式">
    <p>第二章內文……</p>
  </OutlineSection>
  <OutlineSection id="faq" title="常見問題">
    <p>第三章內文……</p>
  </OutlineSection>
</SyncedOutlineReader>

滾動發生在巢狀 overflow 容器內時,把該容器的 ref 傳給 container:

const scrollerRef = React.useRef<HTMLDivElement>(null);

<div ref={scrollerRef} className="h-[400px] overflow-y-auto">
  <SyncedOutlineReader sections={sections} container={scrollerRef}>
    {/* ... */}
  </SyncedOutlineReader>
</div>

範例

從資料產生章節

把文章資料轉成 sections 與對應的 OutlineSection,兩者用同一份陣列產生,id 與順序就不會對不上。

import {
  SyncedOutlineReader,
  OutlineSection,
  type OutlineItem,
} from "@/components/ui/synced-outline-reader";

type Chapter = { slug: string; heading: string; paragraphs: string[] };

export function ArticleReader({ chapters }: { chapters: Chapter[] }) {
  const sections: OutlineItem[] = chapters.map((c) => ({ id: c.slug, title: c.heading }));

  return (
    <SyncedOutlineReader sections={sections}>
      {chapters.map((c) => (
        <OutlineSection key={c.slug} id={c.slug} title={c.heading}>
          {c.paragraphs.map((text, i) => (
            <p key={i}>{text}</p>
          ))}
        </OutlineSection>
      ))}
    </SyncedOutlineReader>
  );
}

搭配 Next.js App Router 文章頁

元件已是 Client Component,可以在 Server Component 頁面取得資料後直接傳入。網站有固定高度的頂部導覽列時,用 OutlineSection 的 className 覆寫預設的 scroll-mt-4,點擊大綱後標題才不會被導覽列遮住。

// app/docs/terms/page.tsx
import {
  SyncedOutlineReader,
  OutlineSection,
} from "@/components/ui/synced-outline-reader";
import { getTerms } from "@/lib/content";

export default async function TermsPage() {
  const terms = await getTerms(); // [{ id: "account", title: "帳號與註冊", body: "..." }, ...]

  return (
    <main className="mx-auto max-w-5xl px-4 py-12">
      <SyncedOutlineReader sections={terms.map(({ id, title }) => ({ id, title }))}>
        {terms.map((t) => (
          <OutlineSection key={t.id} id={t.id} title={t.title} className="scroll-mt-20">
            <p>{t.body}</p>
          </OutlineSection>
        ))}
      </SyncedOutlineReader>
    </main>
  );
}

帶編號的章節標題

OutlineSection 的 title 接受 ReactNode,內文可以放更豐富的標題;大綱欄使用 sections 裡的純文字 title。outlineLabel 可改寫大綱欄 <nav> 的無障礙標籤。

<SyncedOutlineReader sections={sections} outlineLabel="本頁章節">
  <OutlineSection
    id="setup"
    title={
      <>
        <span className="mr-2 text-neutral-400">01</span>環境設定
      </>
    }
  >
    <p>安裝 Node.js 20 以上版本,並在專案根目錄執行 pnpm install。</p>
  </OutlineSection>
  {/* ... */}
</SyncedOutlineReader>

Props

SyncedOutlineReader

Prop型別預設值說明
sections{ id: string; title: string }[]—大綱欄的章節清單,順序需與內容中的 OutlineSection 一致
childrenReactNode—直接放入 OutlineSection 子元件
containerRefObject<HTMLElement>—滾動發生在巢狀 overflow 容器內時,傳入該容器的 ref
outlineLabelstring"內容大綱"大綱欄 <nav> 的無障礙標籤
classNamestring—附加到雙欄外層容器的 class

OutlineSection

Prop型別預設值說明
idstring—章節錨點 id,需對應 sections 陣列中的同名項目
titleReactNode—章節標題,渲染為 <h2>
childrenReactNode—章節內文
classNamestring—附加到章節 <section> 的 class

細節

  • 指示脊椎不是傳統 scroll-spy 的跳格切換:以 IntersectionObserver(root 為捲動容器)追蹤各章節的可見比例,換算成大綱項上的覆蓋範圍,再用 useSpring 驅動 top 與 height,在大綱項之間連續伸縮滑動
  • 當前章節(可見比例最高者)內文 opacity 為 1,其餘章節降至 0.6,閱讀焦點隨滾動同步轉移
  • 大綱項是真正的 <button>,點擊平滑捲動到對應章節,並以 aria-current="location" 標示當前位置
  • 窄螢幕大綱欄整欄隱藏,md 斷點以上才顯示雙欄;內容欄不受影響
  • 使用者系統開啟「減少動態效果」時,脊椎直接跳到目標位置(不經彈簧)、聚焦切換不再過渡、點擊大綱改為即時捲動,版面完全不變

常見問題

可以在 Next.js App Router 的 Server Component 頁面使用嗎?

可以。元件檔案開頭已有 "use client",從 Server Component 頁面直接 import 即可,sections 與章節內容都可以在伺服器端準備好再傳入。IntersectionObserver 與 window 只在 effect 中使用,不影響伺服器渲染。

手機上為什麼看不到大綱欄?

大綱欄在 md 斷點以下整欄隱藏(hidden md:block),窄螢幕只顯示內容欄。需要手機版目錄的話,要另外放一個導覽元件。

點擊大綱後,標題被固定導覽列擋住怎麼辦?

點擊大綱會呼叫 scrollIntoView 並對齊章節頂端,章節預設帶有 scroll-mt-4。在 OutlineSection 的 className 傳入較大的值(例如 scroll-mt-20)即可覆寫。

內容在可捲動的區塊裡,指示線不會動?

預設以視窗為捲動容器。捲動發生在巢狀 overflow 容器內時,把該容器的 ref 傳給 container,IntersectionObserver 才會以它為 root 計算可見比例。

有支援減少動態效果嗎?

有。系統開啟「減少動態效果」時,指示線直接跳到目標位置、不經彈簧,章節透明度切換不做過渡,點擊大綱改為即時捲動。