diff --git a/.changeset/scroll-state-navigation.md b/.changeset/scroll-state-navigation.md
new file mode 100644
index 000000000000..a7d814d3caea
--- /dev/null
+++ b/.changeset/scroll-state-navigation.md
@@ -0,0 +1,12 @@
+---
+'@sveltejs/kit': minor
+---
+
+feat: add `scroll` property to `NavigationTarget` in navigation callbacks
+
+Navigation callbacks (`beforeNavigate`, `onNavigate`, and `afterNavigate`) now include scroll position information via the `scroll` property on `from` and `to` targets:
+
+- `from.scroll`: The scroll position at the moment navigation was triggered
+- `to.scroll`: In `beforeNavigate` and `onNavigate`, this is populated for `popstate` navigations (back/forward) with the scroll position that will be restored, and `null` for other navigation types. In `afterNavigate`, this is always the final scroll position after navigation completed.
+
+This enables use cases like animating transitions based on the target scroll position when using browser back/forward navigation.
diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts
index 200ae665893f..8558f481ac68 100644
--- a/packages/kit/src/exports/public.d.ts
+++ b/packages/kit/src/exports/public.d.ts
@@ -1200,6 +1200,19 @@ export interface NavigationTarget<
* The URL that is navigated to
*/
url: URL;
+ /**
+ * The scroll position associated with this navigation.
+ *
+ * For the `from` target, this is the scroll position at the moment of navigation.
+ *
+ * For the `to` target, this represents the scroll position that will be or was restored:
+ * - In `beforeNavigate` and `onNavigate`, this is only available for `popstate` navigations (back/forward button)
+ * and will be `null` for other navigation types, since the final scroll position isn't known
+ * ahead of time.
+ * - In `afterNavigate`, this is always the scroll position that was applied after the navigation
+ * completed.
+ */
+ scroll: { x: number; y: number } | null;
}
/**
diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js
index c4f78548655c..c410814399ee 100644
--- a/packages/kit/src/runtime/client/client.js
+++ b/packages/kit/src/runtime/client/client.js
@@ -605,7 +605,8 @@ async function initialize(result, target, hydrate) {
to: {
params: current.params,
route: { id: current.route?.id ?? null },
- url: new URL(location.href)
+ url: new URL(location.href),
+ scroll: scroll_positions[current_history_index] ?? scroll_state()
},
willUnload: false,
type: 'enter',
@@ -1463,12 +1464,13 @@ function get_page_key(url) {
* intent?: import('./types.js').NavigationIntent;
* delta?: number;
* event?: PopStateEvent | MouseEvent;
+ * scroll?: { x: number, y: number };
* }} opts
*/
-function _before_navigate({ url, type, intent, delta, event }) {
+function _before_navigate({ url, type, intent, delta, event, scroll }) {
let should_block = false;
- const nav = create_navigation(current, intent, url, type);
+ const nav = create_navigation(current, intent, url, type, scroll ?? null);
if (delta !== undefined) {
nav.navigation.delta = delta;
@@ -1543,6 +1545,7 @@ async function navigate({
type,
delta: popped?.delta,
intent,
+ scroll: popped?.scroll,
// @ts-ignore
event
});
@@ -1808,6 +1811,11 @@ async function navigate({
nav.fulfil(undefined);
+ // Update to.scroll to the actual scroll position after navigation completed
+ if (nav.navigation.to) {
+ nav.navigation.to.scroll = scroll_state();
+ }
+
after_navigate_callbacks.forEach((fn) =>
fn(/** @type {import('@sveltejs/kit').AfterNavigate} */ (nav.navigation))
);
@@ -3094,8 +3102,9 @@ function reset_focus(url, scroll = null) {
* @param {import('./types.js').NavigationIntent | undefined} intent
* @param {URL | null} url
* @param {T} type
+ * @param {{ x: number, y: number } | null} [target_scroll] The scroll position for the target (for popstate navigations)
*/
-function create_navigation(current, intent, url, type) {
+function create_navigation(current, intent, url, type, target_scroll = null) {
/** @type {(value: any) => void} */
let fulfil;
@@ -3115,12 +3124,14 @@ function create_navigation(current, intent, url, type) {
from: {
params: current.params,
route: { id: current.route?.id ?? null },
- url: current.url
+ url: current.url,
+ scroll: scroll_state()
},
to: url && {
params: intent?.params ?? null,
route: { id: intent?.route?.id ?? null },
- url
+ url,
+ scroll: target_scroll
},
willUnload: !intent,
type,
diff --git a/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/scroll-state/a/+page.svelte b/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/scroll-state/a/+page.svelte
new file mode 100644
index 000000000000..2c06ed22932b
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/scroll-state/a/+page.svelte
@@ -0,0 +1,44 @@
+
+
+
Page A
+
+
+
+Go to B
+
+
diff --git a/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/scroll-state/b/+page.svelte b/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/scroll-state/b/+page.svelte
new file mode 100644
index 000000000000..cdd1aac75d6f
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/scroll-state/b/+page.svelte
@@ -0,0 +1,44 @@
+
+
+Page B
+
+
+
+Go to A
+
+
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 a2be35a47dc6..f47023909969 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
@@ -300,6 +300,118 @@ test.describe('Navigation lifecycle functions', () => {
'popstate /navigation-lifecycle/before-navigate/event/b -> /navigation-lifecycle/before-navigate/event/a'
]);
});
+
+ test('scroll state is provided on initial page load', async ({ page }) => {
+ /** @type {any} */
+ let afterNav;
+ const afterNavPromise = new Promise((resolve) => {
+ page.on('console', (msg) => {
+ const text = msg.text();
+ if (text.startsWith('afterNavigate:')) {
+ afterNav = JSON.parse(text.slice('afterNavigate:'.length));
+ resolve(afterNav);
+ }
+ });
+ });
+
+ await page.goto('/navigation-lifecycle/scroll-state/a');
+ await afterNavPromise;
+
+ expect(afterNav.fromScroll).toBe(undefined);
+ expect(afterNav.toScroll).toEqual({ x: 0, y: 0 });
+ expect(afterNav.type).toBe('enter');
+ });
+
+ test('scroll state is provided on link navigation', async ({ page, clicknav, scroll_to }) => {
+ await page.goto('/navigation-lifecycle/scroll-state/a');
+ await scroll_to(0, 500);
+
+ /** @type {any} */
+ let beforeNav, onNav, afterNav;
+ const navPromise = new Promise((resolve) => {
+ page.on('console', (msg) => {
+ const text = msg.text();
+ if (text.startsWith('beforeNavigate:')) {
+ beforeNav = JSON.parse(text.slice('beforeNavigate:'.length));
+ } else if (text.startsWith('onNavigate:')) {
+ onNav = JSON.parse(text.slice('onNavigate:'.length));
+ } else if (text.startsWith('afterNavigate:')) {
+ afterNav = JSON.parse(text.slice('afterNavigate:'.length));
+ }
+
+ if (beforeNav && onNav && afterNav) resolve(undefined);
+ });
+ });
+
+ await clicknav('#to-b');
+ await navPromise;
+
+ expect(beforeNav.fromScroll).toEqual({ x: 0, y: 500 });
+ expect(beforeNav.toScroll).toBe(null);
+ expect(beforeNav.type).toBe('link');
+
+ expect(onNav.fromScroll).toEqual({ x: 0, y: 500 });
+ expect(onNav.toScroll).toBe(null);
+ expect(onNav.type).toBe('link');
+
+ expect(afterNav.fromScroll).toEqual({ x: 0, y: 500 });
+ expect(afterNav.toScroll).toEqual({ x: 0, y: 0 });
+ expect(afterNav.type).toBe('link');
+ });
+
+ test('scroll state is provided on popstate navigation', async ({ page, clicknav, scroll_to }) => {
+ await page.goto('/navigation-lifecycle/scroll-state/a');
+ await scroll_to(0, 500);
+
+ /** @type {any} */
+ let afterNav;
+ let navPromise = new Promise((resolve) => {
+ page.on('console', (msg) => {
+ const text = msg.text();
+ if (text.startsWith('afterNavigate:')) {
+ afterNav = JSON.parse(text.slice('afterNavigate:'.length));
+ resolve(undefined);
+ }
+ });
+ });
+
+ await clicknav('#to-b');
+ await navPromise;
+
+ const savedScrollY = afterNav.fromScroll.y;
+
+ /** @type {any} */
+ let beforeNav, onNav;
+ navPromise = new Promise((resolve) => {
+ page.on('console', (msg) => {
+ const text = msg.text();
+ if (text.startsWith('beforeNavigate:')) {
+ beforeNav = JSON.parse(text.slice('beforeNavigate:'.length));
+ } else if (text.startsWith('onNavigate:')) {
+ onNav = JSON.parse(text.slice('onNavigate:'.length));
+ } else if (text.startsWith('afterNavigate:')) {
+ afterNav = JSON.parse(text.slice('afterNavigate:'.length));
+ }
+
+ if (beforeNav && onNav && afterNav) resolve(undefined);
+ });
+ });
+
+ await page.goBack();
+ await page.waitForURL('/navigation-lifecycle/scroll-state/a');
+ await navPromise;
+
+ expect(beforeNav.fromScroll).toEqual({ x: 0, y: 0 });
+ expect(beforeNav.toScroll).toEqual({ x: 0, y: savedScrollY });
+ expect(beforeNav.type).toBe('popstate');
+
+ expect(onNav.fromScroll).toEqual({ x: 0, y: 0 });
+ expect(onNav.toScroll).toEqual({ x: 0, y: savedScrollY });
+ expect(onNav.type).toBe('popstate');
+
+ expect(afterNav.toScroll).toEqual({ x: 0, y: savedScrollY });
+ expect(afterNav.type).toBe('popstate');
+ });
});
test.describe('Scrolling', () => {
diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts
index c41dd5ea556e..98d22ee98dbf 100644
--- a/packages/kit/types/index.d.ts
+++ b/packages/kit/types/index.d.ts
@@ -1175,6 +1175,19 @@ declare module '@sveltejs/kit' {
* The URL that is navigated to
*/
url: URL;
+ /**
+ * The scroll position associated with this navigation.
+ *
+ * For the `from` target, this is the scroll position at the moment of navigation.
+ *
+ * For the `to` target, this represents the scroll position that will be or was restored:
+ * - In `beforeNavigate` and `onNavigate`, this is only available for `popstate` navigations (back/forward button)
+ * and will be `null` for other navigation types, since the final scroll position isn't known
+ * ahead of time.
+ * - In `afterNavigate`, this is always the scroll position that was applied after the navigation
+ * completed.
+ */
+ scroll: { x: number; y: number } | null;
}
/**