Scrollspy table of contents with an ember segment that moves and stretches over the sections on screen, a read-progress rail, smooth jump links and a narrow disclosure layout.
Preview
Guide
Getting started with sync
Install the client
Add the sync client to your app with your package manager. It ships as one small module with no peer dependencies beyond your framework.
The client works in browsers, workers and edge runtimes. Node 20 or newer is required for server use.
Connect a workspace
Create a token in Settings, then pass it with your workspace ID. Tokens are scoped to one workspace and can be rotated at any time.
Authentication
Browser apps should exchange a short-lived token from your own server instead of shipping a long-lived one.
Regions
Pick the region closest to your users. Data stays in that region; the median round trip is 38 ms inside it.
Offline drafts
Edits made offline are queued locally and replayed in order when the connection returns.
Conflicts are merged per block. When two people change the same sentence, both versions are kept and flagged for review.
Listen for changes
Subscribe to a document to receive patches as they land. Each patch carries its author, a timestamp and the blocks it touched.
Limits
Documents up to 40,000 words stay under one frame per keystroke. Attachments are capped at 250 MB each.
Rate limits reset every minute and are reported in response headers.
Guide
Getting started with sync
Install the client
Add the sync client to your app with your package manager. It ships as one small module with no peer dependencies beyond your framework.
The client works in browsers, workers and edge runtimes. Node 20 or newer is required for server use.
Connect a workspace
Create a token in Settings, then pass it with your workspace ID. Tokens are scoped to one workspace and can be rotated at any time.
Authentication
Browser apps should exchange a short-lived token from your own server instead of shipping a long-lived one.
Regions
Pick the region closest to your users. Data stays in that region; the median round trip is 38 ms inside it.
Offline drafts
Edits made offline are queued locally and replayed in order when the connection returns.
Conflicts are merged per block. When two people change the same sentence, both versions are kept and flagged for review.
Listen for changes
Subscribe to a document to receive patches as they land. Each patch carries its author, a timestamp and the blocks it touched.
Limits
Documents up to 40,000 words stay under one frame per keystroke. Attachments are capped at 250 MB each.
Rate limits reset every minute and are reported in response headers.
Narrow layout
The list folds into a sticky disclosure that names the current section and shows read progress.
Install
Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.
Headings in document order. depth 1 indents a subsection.
container
RefObject<HTMLElement>
—
Scroll container holding the headings. Defaults to the window.
offset
number
24
Px under the top edge where a heading counts as passed, and where clicks land.
variant
"auto" | "rail" | "disclosure"
"auto"
Rail beside content, a disclosure, or auto (disclosure below lg).
label
string
"On this page"
Heading of the list and the disclosure button label.
Build notes
- Measurement: on scroll (rAF-throttled) and resize, read each heading's
`getBoundingClientRect().top`. Section _i_ spans from its heading to the next
heading (the last to the end of the scroll content). A section is on screen
when it overlaps `[view.top + offset, view.bottom]`. The active range is the
first to last such index; nothing overlapping falls back to the first or
last section.
- Progress: `scrollTop / (scrollHeight − clientHeight)`, a `bg-foreground/20`
fill on the 2 px `bg-border` rail.
- Indicator: in a layout effect, read the `offsetTop`/`offsetHeight` of the
first and last active links, then animate the ember segment's `top` and
`height` with a spring (380/34/0.7), so it travels and stretches as one
object instead of jumping between links.
- Click: `scrollTo({ top: heading − offset + 1, behavior })` on the container
(or window), then `focus({ preventScroll: true })`.
- Disclosure: a frosted raised strip; the progress ring is an SVG circle with
`pathLength=1` and `strokeDasharray="{progress} 1"`. The list opens with a
`grid-rows-[0fr]→[1fr]` transition and scrolls past 18 rem.
- `auto` renders both layouts and lets `lg:` choose; `rail` and `disclosure`
render only their own.
<!-- PEBBLE-LICENSE -->