diff --git a/.changeset/smart-lamps-dance.md b/.changeset/smart-lamps-dance.md
new file mode 100644
index 000000000000..b6a11bf7defa
--- /dev/null
+++ b/.changeset/smart-lamps-dance.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+fix: support SvelteKit scroll management in app-owned scroll containers
diff --git a/documentation/docs/30-advanced/30-link-options.md b/documentation/docs/30-advanced/30-link-options.md
index bf368876ffa9..58eae8538861 100644
--- a/documentation/docs/30-advanced/30-link-options.md
+++ b/documentation/docs/30-advanced/30-link-options.md
@@ -102,6 +102,18 @@ In certain cases, you may wish to disable this behaviour. Adding a `data-sveltek
...will prevent scrolling after the link is clicked.
+## data-sveltekit-scroll-container
+
+SvelteKit applies its scroll management to the window by default. If your app keeps the document fixed and scrolls inside an app-owned container instead, mark the scrollable element that should own route scroll state with `data-sveltekit-scroll-container`:
+
+```svelte
+
+
+
+```
+
+SvelteKit will read and restore that element's `scrollLeft` and `scrollTop` when navigating between routes. Unlike the link options above, this attribute applies to the scrollable app shell element itself. Use a single scroll container for the app shell; if multiple elements have the attribute, the first one in the document whose value is not `"false"` is used.
+
## Disabling options
To disable any of these options inside an element where they have been enabled, use the `"false"` value:
diff --git a/packages/kit/src/core/sync/write_app_types.js b/packages/kit/src/core/sync/write_app_types.js
index d0a8c71614a9..2502296efe77 100644
--- a/packages/kit/src/core/sync/write_app_types.js
+++ b/packages/kit/src/core/sync/write_app_types.js
@@ -68,6 +68,7 @@ declare module "svelte/elements" {
'data-sveltekit-preload-data'?: true | false | '' | 'hover' | 'tap' | undefined | null;
'data-sveltekit-reload'?: true | false | '' | undefined | null;
'data-sveltekit-replacestate'?: true | false | '' | undefined | null;
+ 'data-sveltekit-scroll-container'?: true | false | '' | undefined | null;
}
}
`;
diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js
index 8148d4d55470..060c2255a104 100644
--- a/packages/kit/src/runtime/client/client.js
+++ b/packages/kit/src/runtime/client/client.js
@@ -19,6 +19,7 @@ import {
get_router_options,
is_external_url,
origin,
+ scroll_to,
scroll_state,
load_css
} from './utils.js';
@@ -188,11 +189,11 @@ function reset_scroll_and_focus(url, scroll, keepfocus, active_element) {
if (autoscroll) {
if (scroll) {
- scrollTo(scroll.x, scroll.y);
+ scroll_to(scroll.x, scroll.y);
} else if ((deep_linked = get_hash_element(url))) {
deep_linked.scrollIntoView();
} else {
- scrollTo(0, 0);
+ scroll_to(0, 0);
}
}
@@ -518,7 +519,7 @@ export async function start(_app, _target, hydrate) {
function restore_scroll() {
if (scroll) {
history.scrollRestoration = 'manual';
- scrollTo(scroll.x, scroll.y);
+ scroll_to(scroll.x, scroll.y);
}
}
@@ -3087,7 +3088,7 @@ function _start_router() {
// /#top and click on a link that goes to /#top. In those cases just go to
// the top of the page, and avoid a history change.
if (hash === '' || (hash === 'top' && a.ownerDocument.getElementById('top') === null)) {
- scrollTo({ top: 0 });
+ scroll_to({ top: 0 });
} else {
const element = a.ownerDocument.getElementById(decodeURIComponent(hash));
if (element) {
@@ -3251,7 +3252,7 @@ function _start_router() {
current_history_index = history_index;
current_noscroll_index = no_scroll_index;
current_keepfocus_index = keep_focus_index;
- if (!noscroll && scroll) scrollTo(scroll.x, scroll.y);
+ if (!noscroll && scroll) scroll_to(scroll.x, scroll.y);
return;
}
@@ -3622,7 +3623,7 @@ function reset_focus(url, scroll = true) {
// If scroll management has already happened earlier, we need to restore
// the scroll position after setting the sequential focus navigation starting point
- if (scroll) scrollTo(x, y);
+ if (scroll) scroll_to(x, y);
resetting_focus = false;
});
} else {
diff --git a/packages/kit/src/runtime/client/utils.js b/packages/kit/src/runtime/client/utils.js
index 602255582097..8c2f48779f2a 100644
--- a/packages/kit/src/runtime/client/utils.js
+++ b/packages/kit/src/runtime/client/utils.js
@@ -17,13 +17,41 @@ export function resolve_url(url) {
return new URL(url, baseURI);
}
+function get_scroll_container() {
+ return /** @type {HTMLElement | null} */ (
+ document.querySelector(
+ '[data-sveltekit-scroll-container]:not([data-sveltekit-scroll-container="false"])'
+ )
+ );
+}
+
export function scroll_state() {
+ const scroll_container = get_scroll_container();
+
+ if (scroll_container) {
+ return {
+ x: scroll_container.scrollLeft,
+ y: scroll_container.scrollTop
+ };
+ }
+
return {
x: pageXOffset,
y: pageYOffset
};
}
+/**
+ * @param {number | ScrollToOptions} x
+ * @param {number} [y]
+ */
+export function scroll_to(x, y) {
+ const scroll_container = get_scroll_container();
+ const scroll = scroll_container ?? window;
+ if (typeof x === 'number') scroll.scrollTo(x, y ?? 0);
+ else scroll.scrollTo(x);
+}
+
const warned = new WeakSet();
/** @typedef {keyof typeof valid_link_options} LinkOptionName */
diff --git a/packages/kit/test/apps/basics/src/routes/scroll/custom-container/+layout.svelte b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/+layout.svelte
new file mode 100644
index 000000000000..d1fc9a5a036b
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/+layout.svelte
@@ -0,0 +1,29 @@
+
+
+
diff --git a/packages/kit/test/apps/basics/src/routes/scroll/custom-container/a/+page.svelte b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/a/+page.svelte
new file mode 100644
index 000000000000..d9717dad6a80
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/a/+page.svelte
@@ -0,0 +1,10 @@
+custom container a
+b
+
+bottom a
+
+
diff --git a/packages/kit/test/apps/basics/src/routes/scroll/custom-container/b/+page.svelte b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/b/+page.svelte
new file mode 100644
index 000000000000..ffd88679e0ac
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/b/+page.svelte
@@ -0,0 +1,10 @@
+custom container b
+a
+
+bottom b
+
+
diff --git a/packages/kit/test/apps/basics/test/cross-platform/client.test.js b/packages/kit/test/apps/basics/test/cross-platform/client.test.js
index dd6c740c0306..ec06cd4e9941 100644
--- a/packages/kit/test/apps/basics/test/cross-platform/client.test.js
+++ b/packages/kit/test/apps/basics/test/cross-platform/client.test.js
@@ -541,6 +541,42 @@ test.describe('Scrolling', () => {
expect(await page.evaluate(() => scrollY)).toBe(0);
});
+ test('no-anchor url will scroll app-owned container to top when navigated from scrolled page', async ({
+ page,
+ clicknav
+ }) => {
+ await page.goto('/scroll/custom-container/a');
+ const scroll_container = page.locator('#scroll-container');
+
+ await scroll_container.evaluate((node) => node.scrollTo(0, node.scrollHeight));
+ await expect.poll(() => scroll_container.evaluate((node) => node.scrollTop)).toBeGreaterThan(0);
+
+ await clicknav('nav [href="/scroll/custom-container/b"]');
+ await expect.poll(() => scroll_container.evaluate((node) => node.scrollTop)).toBe(0);
+ });
+
+ test('app-owned container scroll is restored after hitting the back button', async ({
+ page,
+ clicknav
+ }) => {
+ await page.goto('/scroll/custom-container/a');
+ const scroll_container = page.locator('#scroll-container');
+
+ await scroll_container.evaluate((node) => node.scrollTo(0, 300));
+ await expect
+ .poll(() => scroll_container.evaluate((node) => node.scrollTop))
+ .toBeGreaterThan(250);
+
+ await clicknav('nav [href="/scroll/custom-container/b"]');
+ await expect.poll(() => scroll_container.evaluate((node) => node.scrollTop)).toBe(0);
+
+ await page.goBack();
+ await page.waitForURL('/scroll/custom-container/a');
+ await expect
+ .poll(() => scroll_container.evaluate((node) => node.scrollTop))
+ .toBeGreaterThan(250);
+ });
+
test('scroll is restored after hitting the back button', async ({ clicknav, page }) => {
await page.goto('/anchor');
await page.locator('#scroll-anchor').click();