Synced Outline Reader
內容+大綱雙欄閱讀版面:指示脊椎隨閱讀位置連續伸縮滑動,當前章節同步聚焦。
把這個效果裝進你的專案
在專案資料夾執行指令,CLI 會加入原始碼與依賴套件,再依下方使用範例引用元件。
npx webberui@latest add synced-outline-readerSynced 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 一致 |
children | ReactNode | — | 直接放入 OutlineSection 子元件 |
container | RefObject<HTMLElement> | — | 滾動發生在巢狀 overflow 容器內時,傳入該容器的 ref |
outlineLabel | string | "內容大綱" | 大綱欄 <nav> 的無障礙標籤 |
className | string | — | 附加到雙欄外層容器的 class |
OutlineSection
| Prop | 型別 | 預設值 | 說明 |
|---|---|---|---|
id | string | — | 章節錨點 id,需對應 sections 陣列中的同名項目 |
title | ReactNode | — | 章節標題,渲染為 <h2> |
children | ReactNode | — | 章節內文 |
className | string | — | 附加到章節 <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 計算可見比例。
有支援減少動態效果嗎?
有。系統開啟「減少動態效果」時,指示線直接跳到目標位置、不經彈簧,章節透明度切換不做過渡,點擊大綱改為即時捲動。