fix: make the reading viewport a script-driven scroll container so sticky and view timelines work

This commit is contained in:
2026-10-10 18:10:28 +03:00
parent 6c18228ed6
commit 71959f7541
3 changed files with 36 additions and 8 deletions

View File

@@ -99,8 +99,19 @@ const browserRuntime = (): NavigationRuntime => ({
pushHash: (hash) => window.history.pushState(window.history.state, "", hash), pushHash: (hash) => window.history.pushState(window.history.state, "", hash),
replaceHash: (hash) => replaceHash: (hash) =>
window.history.replaceState(window.history.state, "", hash), window.history.replaceState(window.history.state, "", hash),
scrollTarget: (target, behavior) => scrollTarget: (target, behavior) => {
target.scrollIntoView({ behavior, block: "start" }), // Inside the live reading stage the document is the only real scroller;
// scrollIntoView would move the script-driven inner viewport instead.
if (target.closest('[data-reading-stage="live"]') === null) {
target.scrollIntoView({ behavior, block: "start" });
return;
}
const margin = parseFloat(getComputedStyle(target).scrollMarginTop) || 0;
window.scrollTo({
top: window.scrollY + target.getBoundingClientRect().top - margin,
behavior,
});
},
focusHeading: (target) => { focusHeading: (target) => {
target target
.querySelector<HTMLElement>('h1[tabindex="-1"], h2[tabindex="-1"]') .querySelector<HTMLElement>('h1[tabindex="-1"], h2[tabindex="-1"]')

View File

@@ -48,8 +48,10 @@ export function createSignStage(resources: SceneResourceBag) {
Math.min(window.scrollY, contentHeight - viewportHeight), Math.min(window.scrollY, contentHeight - viewportHeight),
); );
if (offset !== lastOffset) { if (offset !== lastOffset) {
main.style.transform = `translate3d(0, ${-offset}px, 0)`; // The viewport is a real (overflow: hidden) scroll container driven by
// the document scroll, so sticky and view-timeline layout work inside it.
lastOffset = offset; lastOffset = offset;
viewport.scrollTop = offset;
} }
// Quantize the opening reveal: the mask image is rebuilt at most // Quantize the opening reveal: the mask image is rebuilt at most
// REVEAL_STEPS times on the way in instead of on every scroll event. // REVEAL_STEPS times on the way in instead of on every scroll event.
@@ -149,6 +151,21 @@ export function createSignStage(resources: SceneResourceBag) {
}; };
resources.listen(window, 'scroll', moveContent, { passive: true }); resources.listen(window, 'scroll', moveContent, { passive: true });
// Anything the browser scrolls inside the viewport (find-in-page, focus,
// scrollIntoView) is forwarded to the document, which stays the one scroller.
if (viewport)
resources.listen(
viewport,
'scroll',
() => {
if (!ownsStage() || Number.isNaN(lastOffset)) return;
const drift = viewport.scrollTop - lastOffset;
if (Math.abs(drift) < 1) return;
viewport.scrollTop = lastOffset;
window.scrollBy({ top: drift, behavior: 'instant' });
},
{ passive: true },
);
const layoutChanged = () => { const layoutChanged = () => {
dirty = true; dirty = true;
if (ownsStage()) update(root.clientWidth, window.innerHeight); if (ownsStage()) update(root.clientWidth, window.innerHeight);
@@ -184,7 +201,7 @@ export function createSignStage(resources: SceneResourceBag) {
delete stage.dataset.readingStage; delete stage.dataset.readingStage;
stage.style.removeProperty('height'); stage.style.removeProperty('height');
} }
main.style.removeProperty('transform'); if (viewport) viewport.scrollTop = 0;
viewport?.style.removeProperty('mask-image'); viewport?.style.removeProperty('mask-image');
viewport?.style.removeProperty('height'); viewport?.style.removeProperty('height');
for (const image of lazyImages) image.loading = 'lazy'; for (const image of lazyImages) image.loading = 'lazy';

View File

@@ -524,12 +524,12 @@
.reading-stage[data-reading-stage='live'] > .reading-viewport { .reading-stage[data-reading-stage='live'] > .reading-viewport {
position: sticky; position: sticky;
top: 0; top: 0;
overflow: clip; /* A real scroll container (scrolled from script, never by the user), so
sticky children and view timelines resolve against the reading window. */
overflow: hidden;
overscroll-behavior: none;
isolation: isolate; isolation: isolate;
} }
.reading-stage[data-reading-stage='live'] .portfolio-main {
will-change: transform;
}
/* The stationary reading edge supplies the live reveal. View timelines use /* The stationary reading edge supplies the live reveal. View timelines use
layout positions, which no longer follow the translated content here. */ layout positions, which no longer follow the translated content here. */