Synced Outline Reader
A two-column reading layout of content plus outline, where an indicator spine stretches and slides continuously with the reading position and the current section comes into focus in sync.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add synced-outline-reader.enSynced Outline Reader is a React two-column reading layout with a table of contents, built with Tailwind CSS and Motion. It tracks how much of each section is visible with IntersectionObserver, and the indicator line in the outline column moves continuously with the scroll position, similar to a scroll-spy but without jumping from item to item.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<SyncedOutlineReader/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- Long technical docs, help articles or blog posts, so readers always know which section they are in.
- Terms of service, privacy policies and other legal pages with many sections, where readers jump straight to a clause from the outline.
- Release notes or annual reports read section by section.
- Settings help pages in a dashboard, or a reading panel inside an overflow container (with
container). - Not a good fit: if you only need section dots pinned to the side of the screen without a text outline, use Scroll Spy Dots.
Installation
npx webberui@latest add https://webberui.com/r/synced-outline-reader.en.jsonUsage
sections defines the outline list; the content is wrapped in OutlineSection components whose ids match it one to one:
import {
SyncedOutlineReader,
OutlineSection,
} from "@/components/ui/synced-outline-reader";
const sections = [
{ id: "intro", title: "Introduction" },
{ id: "usage", title: "Usage" },
{ id: "faq", title: "FAQ" },
];
<SyncedOutlineReader sections={sections}>
<OutlineSection id="intro" title="Introduction">
<p>Body text of the first chapter…</p>
</OutlineSection>
<OutlineSection id="usage" title="Usage">
<p>Body text of the second chapter…</p>
</OutlineSection>
<OutlineSection id="faq" title="FAQ">
<p>Body text of the third chapter…</p>
</OutlineSection>
</SyncedOutlineReader>When the scrolling happens inside a nested overflow container, pass that container's ref to container:
const scrollerRef = React.useRef<HTMLDivElement>(null);
<div ref={scrollerRef} className="h-[400px] overflow-y-auto">
<SyncedOutlineReader sections={sections} container={scrollerRef}>
{/* ... */}
</SyncedOutlineReader>
</div>Examples
Generating sections from data
Build sections and the matching OutlineSections from the same array, so the ids and order always line up.
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>
);
}In a Next.js App Router page
The component is already a Client Component, so a Server Component page can fetch the data and pass it straight in. If your site has a fixed-height top navigation bar, override the default scroll-mt-4 through OutlineSection's className so headings are not hidden behind the bar after clicking the outline.
// 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: "Accounts and sign-up", 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>
);
}Numbered section headings
OutlineSection's title accepts a ReactNode, so the content column can show a richer heading while the outline column uses the plain-text title from sections. outlineLabel changes the accessible label of the outline <nav>.
<SyncedOutlineReader sections={sections} outlineLabel="On this page">
<OutlineSection
id="setup"
title={
<>
<span className="mr-2 text-neutral-400">01</span>Environment setup
</>
}
>
<p>Install Node.js 20 or later, then run pnpm install in the project root.</p>
</OutlineSection>
{/* ... */}
</SyncedOutlineReader>Props
SyncedOutlineReader
| Prop | Type | Default | Description |
|---|---|---|---|
sections | { id: string; title: string }[] | — | The outline column's section list; the order must match the OutlineSections in the content |
children | ReactNode | — | Put OutlineSection children here directly |
container | RefObject<HTMLElement> | — | When the scrolling happens inside a nested overflow container, pass that container's ref |
outlineLabel | string | "Content outline" | Accessible label for the outline column's <nav> |
className | string | — | Appended to the two-column outer container's className |
OutlineSection
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Section anchor id; must match the entry of the same name in the sections array |
title | ReactNode | — | Section title, rendered as an <h2> |
children | ReactNode | — | Section body |
className | string | — | Appended to the section's <section> className |
How it works
- The indicator spine is not the jump-to-the-next-slot switching of a traditional scroll-spy: an
IntersectionObserver(with the scroll container as itsroot) tracks how much of each section is visible, converts that into a coverage range over the outline items, and then drivestopandheightwithuseSpring, so the spine stretches and slides continuously between outline items - The current section (the one with the highest visible ratio) renders its body at opacity 1 while the others drop to 0.6, so the reading focus moves along with the scroll
- Outline items are real
<button>elements: clicking one smoothly scrolls to the matching section, andaria-current="location"marks the current position - On narrow screens the outline column is hidden entirely and the two-column layout only appears at the
mdbreakpoint and up; the content column is unaffected - When the user has "reduce motion" enabled at the system level, the spine jumps straight to its target position (no spring), the focus change no longer transitions, and clicking an outline item scrolls instantly — the layout does not change at all
FAQ
Can I use it in a Next.js App Router Server Component page?
Yes. The component file already starts with "use client", so import it directly from a Server Component page and prepare sections and the section content on the server. IntersectionObserver and window are only used inside effects, so server rendering is unaffected.
Why is there no outline column on mobile?
The outline column is hidden below the md breakpoint (hidden md:block), and narrow screens show only the content column. If you need a table of contents on mobile, add a separate navigation component.
Headings end up behind my fixed header after clicking the outline. What do I do?
Clicking an outline item calls scrollIntoView aligned to the section top, and sections carry scroll-mt-4 by default. Pass a larger value such as scroll-mt-20 through OutlineSection's className to override it.
My content scrolls inside a panel and the indicator does not move.
The window is the default scroll container. When scrolling happens inside a nested overflow container, pass that container's ref to container so IntersectionObserver uses it as the root when measuring visibility.
Does it respect reduced motion?
Yes. With "reduce motion" enabled, the indicator jumps to its target without the spring, the section opacity change has no transition, and clicking the outline scrolls instantly.
Drill Stack Panels
A layered drill-down detail panel system — new panels push in from the right, lower layers recede with parallax and dimming, and breadcrumbs pop back one level at a time with optional URL sync.
Parallel Text Columns
A side-by-side comparison layout — bilingual or two-version content on facing columns, paired at the sentence level, auto-aligned on scroll with linked hover highlighting.