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.
npx shadcn@latest add https://webberui.com/r/synced-outline-reader.jsonPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<SyncedOutlineReader />
Installation
npx shadcn@latest add https://webberui.com/r/synced-outline-reader.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>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 | "內容大綱" | 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
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.