WebberUI

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.

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

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 />

Installation

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

Usage

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

PropTypeDefaultDescription
sections{ id: string; title: string }[]The outline column's section list; the order must match the OutlineSections in the content
childrenReactNodePut OutlineSection children here directly
containerRefObject<HTMLElement>When the scrolling happens inside a nested overflow container, pass that container's ref
outlineLabelstring"內容大綱"Accessible label for the outline column's <nav>
classNamestringAppended to the two-column outer container's className

OutlineSection

PropTypeDefaultDescription
idstringSection anchor id; must match the entry of the same name in the sections array
titleReactNodeSection title, rendered as an <h2>
childrenReactNodeSection body
classNamestringAppended 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 its root) tracks how much of each section is visible, converts that into a coverage range over the outline items, and then drives top and height with useSpring, 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, and aria-current="location" marks the current position
  • On narrow screens the outline column is hidden entirely and the two-column layout only appears at the md breakpoint 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

On this page