Ownership-Based Scroll Sync Between Reader and Chapter List
The Problem
Open a manga. Scroll to chapter 12 and tap it. Read forward to chapter 15. Swipe back to the manga details. The chapter list still shows chapter 12 in view — the scroll position hasn’t moved.
The manga view stays in the DOM while the reader is open (it’s visibility: hidden, not unmounted). This is what makes swipe-back instant — no re-render. But it also means the scroll position is frozen at wherever the user left it.
The Idea
Since the manga view is always in the DOM, we can scroll it while the user reads. When they swipe back, the list is already showing the right chapter.
The key insight: if chapter 12 was at 75% from the top of the container when the user tapped it, then chapter 15 should sit at that same 75% position when they progress to it. This preserves spatial context — the user sees the current chapter in roughly the same spot they last tapped.
Ownership Model
Three participants, clear ownership:
MangaState (owner)
scrollAnchorRatio — captured on reader entry
scrollTarget — signal for the view to consume
ReaderState (writer)
syncChapterProgress() → calls manga.updateScrollTarget(chapterId)
ChapterList (executor)
on click → measures element position, calls manga.captureScrollAnchor(ratio)
$effect → reads scrollTarget, performs DOM scroll
MangaState owns the data. ReaderState writes to it. ChapterList reads from it and executes the scroll. No circular dependencies, no shared mutable state.
Capturing the Anchor
When the user taps a chapter, we measure where that element sits relative to the scrollable container:
function handleClick(chapter: ChapterMeta) {
const manga = appState.manga.activeManga;
if (!manga) return;
const container = document.getElementById('view-manga');
const el = container?.querySelector(
`[data-chapter-id="${CSS.escape(chapter.id)}"]`
);
if (container && el) {
const containerRect = container.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
appState.manga.captureScrollAnchor(
(elRect.top - containerRect.top) / containerRect.height
);
}
appState.reader.openReader(manga, chapter);
}
The ratio is a number between 0 and 1. A chapter at the very top of the visible area is 0. One at the bottom is ~1. This is viewport-relative, not scroll-relative — it captures where on screen the user was looking.
Updating the Target
Every time the reader crosses a chapter boundary, syncChapterProgress fires. One new line:
syncChapterProgress(chapterId: string): void {
this.currentChapterId = chapterId;
this.manga.updateScrollTarget(chapterId);
// ... existing progress sync unchanged
}
updateScrollTarget pairs the new chapter ID with the stored anchor ratio:
updateScrollTarget(chapterId: string) {
this.scrollTarget = { chapterId, ratio: this.scrollAnchorRatio };
}
This is a reactive $state property. Changing it triggers any $effect that reads it.
Executing the Scroll
ChapterList has an $effect that watches scrollTarget:
$effect(() => {
const target = appState.manga.scrollTarget;
if (!target) return;
const container = document.getElementById('view-manga');
const el = container?.querySelector(
`[data-chapter-id="${CSS.escape(target.chapterId)}"]`
);
if (!container || !el) return;
const elTop = (el as HTMLElement).offsetTop;
const desiredScrollTop = elTop - target.ratio * container.clientHeight;
container.scrollTop = Math.max(0, desiredScrollTop);
});
The math: offsetTop gives the element’s position in the scrollable content. Subtracting ratio * clientHeight places it at the same viewport-relative position as the original click. Math.max(0, ...) handles the top edge; the browser’s native scrollTop clamping handles the bottom edge.
Since the container is visibility: hidden, this scroll is invisible. No layout thrash, no flicker. When the user swipes back, the list is already correct.
Edge Cases
Chapter not in filtered list. If the user has group filters active and the current chapter belongs to a filtered group, querySelector returns null. The effect exits early. No scroll happens, which is the right behavior — you can’t scroll to something that isn’t rendered.
Near the top of the list. If the computed scrollTop would be negative (chapter is near the top but the anchor ratio pushes it above the container), Math.max(0, ...) clamps to 0. The chapter appears lower than the anchor position, which looks natural.
Near the bottom. The browser clamps scrollTop to scrollHeight - clientHeight. The chapter appears higher than the anchor position. Again, natural.
Manga closed. closeManga() resets both scrollTarget and scrollAnchorRatio to their defaults.
Takeaway
The manga view being always-in-DOM (for swipe-back performance) created a scroll staleness problem, but also provided the solution: we can scroll it invisibly while the user reads. The ownership model keeps it clean — one owner for the anchor data, one writer that signals chapter changes, one executor that performs the DOM mutation. No polling, no timers, just reactive state flowing through an $effect.