From 9104b9d575e0fd29985737633600e5a99744a69b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 26 Mar 2026 18:00:42 +0100 Subject: [PATCH 1/4] feat: add View Transitions API support for client-side navigation Integrates the View Transitions API into Fresh's existing partials system. When enabled via the `f-view-transition` attribute, partial updates are wrapped in `document.startViewTransition()` for smooth animated transitions between pages. Usage: The API is opt-in and progressive: - Only activates when `f-view-transition` attribute is present - Falls back to regular updates when the View Transitions API is not supported by the browser - Works with all navigation types: link clicks, popstate, and form submissions Users can customize animations via standard CSS: ::view-transition-old(root) { animation: ... } ::view-transition-new(root) { animation: ... } Supersedes #1532 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fresh/src/runtime/client/partials.ts | 44 ++++++++++++++++--- packages/fresh/src/runtime/shared_internal.ts | 1 + 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/fresh/src/runtime/client/partials.ts b/packages/fresh/src/runtime/client/partials.ts index bd5d3b5665b..f5b90599e09 100644 --- a/packages/fresh/src/runtime/client/partials.ts +++ b/packages/fresh/src/runtime/client/partials.ts @@ -6,6 +6,7 @@ import { matchesUrl, PartialMode, UrlMatchKind, + VIEW_TRANSITION_ATTR, } from "../shared_internal.ts"; import { ACTIVE_PARTIALS, @@ -43,6 +44,33 @@ function checkClientNavEnabled(el: HTMLElement) { return setting.getAttribute(CLIENT_NAV_ATTR) !== "false"; } +function isViewTransitionEnabled(): boolean { + const setting = document.querySelector(`[${VIEW_TRANSITION_ATTR}]`); + if (setting === null) return false; + return setting.getAttribute(VIEW_TRANSITION_ATTR) !== "false"; +} + +/** + * Wraps a DOM update function with the View Transitions API when available + * and enabled. Falls back to calling the update directly otherwise. + */ +function withViewTransition(update: () => Promise): Promise { + if ( + isViewTransitionEnabled() && + // deno-lint-ignore no-explicit-any + typeof (document as any).startViewTransition === "function" + ) { + return new Promise((resolve, reject) => { + // deno-lint-ignore no-explicit-any + const transition = (document as any).startViewTransition(async () => { + await update(); + }); + transition.finished.then(resolve, reject); + }); + } + return update(); +} + // Keep track of history state to apply forward or backward animations let index = history.state?.index || 0; if (!history.state) { @@ -132,8 +160,10 @@ document.addEventListener("click", async (e) => { partial ? partial : nextUrl.href, location.href, ); - await fetchPartials(nextUrl, partialUrl, true); - updateLinks(nextUrl); + await withViewTransition(async () => { + await fetchPartials(nextUrl, partialUrl, true); + updateLinks(nextUrl); + }); scrollTo({ left: 0, top: 0, behavior: "instant" }); } finally { if (indicator !== undefined) { @@ -192,8 +222,10 @@ addEventListener("popstate", async (e) => { const url = new URL(location.href, location.origin); try { - await fetchPartials(url, url, true); - updateLinks(url); + await withViewTransition(async () => { + await fetchPartials(url, url, true); + updateLinks(url); + }); scrollTo({ left: state.scrollX ?? 0, top: state.scrollY ?? 0, @@ -254,7 +286,9 @@ document.addEventListener("submit", async (e) => { init = { body: new FormData(el, e.submitter), method: lowerMethod }; } - await fetchPartials(actionUrl, partialUrl, true, init); + await withViewTransition(async () => { + await fetchPartials(actionUrl, partialUrl, true, init); + }); } } }); diff --git a/packages/fresh/src/runtime/shared_internal.ts b/packages/fresh/src/runtime/shared_internal.ts index 7fd6b515668..d91d6e642ed 100644 --- a/packages/fresh/src/runtime/shared_internal.ts +++ b/packages/fresh/src/runtime/shared_internal.ts @@ -5,6 +5,7 @@ export const DATA_CURRENT = "data-current"; export const DATA_ANCESTOR = "data-ancestor"; export const DATA_FRESH_KEY = "data-frsh-key"; export const CLIENT_NAV_ATTR = "f-client-nav"; +export const VIEW_TRANSITION_ATTR = "f-view-transition"; export const enum OptionsType { ATTR = "attr", From fe9ccc9c901cc4acce61650e1bac361153822ddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 26 Mar 2026 18:05:06 +0100 Subject: [PATCH 2/4] test: add view transitions integration tests Tests cover: - View transitions called when f-view-transition is present - View transitions NOT called without f-view-transition - View transitions NOT called when f-view-transition="false" - View transitions work with popstate (back/forward) navigation Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fresh/tests/partials_test.tsx | 227 +++++++++++++++++++++++++ 1 file changed, 227 insertions(+) diff --git a/packages/fresh/tests/partials_test.tsx b/packages/fresh/tests/partials_test.tsx index aae8420931f..19dedd59f05 100644 --- a/packages/fresh/tests/partials_test.tsx +++ b/packages/fresh/tests/partials_test.tsx @@ -2775,3 +2775,230 @@ Deno.test({ }); }, }); + +// View Transitions tests + +Deno.test({ + name: "partials - view transitions enabled with f-view-transition", + fn: async () => { + const app = testApp() + .get("/partial", (ctx) => { + return ctx.render( + + +

updated

+
+
, + ); + }) + .get("/", (ctx) => { + return ctx.render( + +
+ update + +

init

+
+
+
, + ); + }); + + await withBrowserApp(app, async (page, address) => { + await page.goto(address, { waitUntil: "load" }); + await page.locator(".init").wait(); + + // Track whether startViewTransition was called + await page.evaluate(() => { + // deno-lint-ignore no-explicit-any + (window as any).__vtCalled = false; + // deno-lint-ignore no-explicit-any + (document as any).startViewTransition = (fn: () => void) => { + // deno-lint-ignore no-explicit-any + (window as any).__vtCalled = true; + fn(); + return { finished: Promise.resolve() }; + }; + }); + + await page.locator(".update").click(); + await waitForText(page, ".done", "updated"); + + const vtCalled = await page.evaluate( + // deno-lint-ignore no-explicit-any + () => (window as any).__vtCalled, + ); + expect(vtCalled).toBe(true); + }); + }, +}); + +Deno.test({ + name: "partials - view transitions not called without f-view-transition", + fn: async () => { + const app = testApp() + .get("/partial", (ctx) => { + return ctx.render( + + +

updated

+
+
, + ); + }) + .get("/", (ctx) => { + return ctx.render( + +
+ update + +

init

+
+
+
, + ); + }); + + await withBrowserApp(app, async (page, address) => { + await page.goto(address, { waitUntil: "load" }); + await page.locator(".init").wait(); + + // Install spy - should NOT be called + await page.evaluate(() => { + // deno-lint-ignore no-explicit-any + (window as any).__vtCalled = false; + // deno-lint-ignore no-explicit-any + (document as any).startViewTransition = (fn: () => void) => { + // deno-lint-ignore no-explicit-any + (window as any).__vtCalled = true; + fn(); + return { finished: Promise.resolve() }; + }; + }); + + await page.locator(".update").click(); + await waitForText(page, ".done", "updated"); + + const vtCalled = await page.evaluate( + // deno-lint-ignore no-explicit-any + () => (window as any).__vtCalled, + ); + expect(vtCalled).toBe(false); + }); + }, +}); + +Deno.test({ + name: "partials - view transitions disabled with f-view-transition=false", + fn: async () => { + const app = testApp() + .get("/partial", (ctx) => { + return ctx.render( + + +

updated

+
+
, + ); + }) + .get("/", (ctx) => { + return ctx.render( + +
+ update + +

init

+
+
+
, + ); + }); + + await withBrowserApp(app, async (page, address) => { + await page.goto(address, { waitUntil: "load" }); + await page.locator(".init").wait(); + + await page.evaluate(() => { + // deno-lint-ignore no-explicit-any + (window as any).__vtCalled = false; + // deno-lint-ignore no-explicit-any + (document as any).startViewTransition = (fn: () => void) => { + // deno-lint-ignore no-explicit-any + (window as any).__vtCalled = true; + fn(); + return { finished: Promise.resolve() }; + }; + }); + + await page.locator(".update").click(); + await waitForText(page, ".done", "updated"); + + const vtCalled = await page.evaluate( + // deno-lint-ignore no-explicit-any + () => (window as any).__vtCalled, + ); + expect(vtCalled).toBe(false); + }); + }, +}); + +Deno.test({ + name: "partials - view transitions work with popstate navigation", + fn: async () => { + const app = testApp() + .get("/page2", (ctx) => { + return ctx.render( + + +

page 2

+
+
, + ); + }) + .get("/", (ctx) => { + return ctx.render( + +
+ go to page 2 + +

page 1

+
+
+
, + ); + }); + + await withBrowserApp(app, async (page, address) => { + await page.goto(address, { waitUntil: "load" }); + await page.locator(".page1").wait(); + + // Install spy that counts calls + await page.evaluate(() => { + // deno-lint-ignore no-explicit-any + (window as any).__vtCount = 0; + // deno-lint-ignore no-explicit-any + (document as any).startViewTransition = (fn: () => void) => { + // deno-lint-ignore no-explicit-any + (window as any).__vtCount++; + fn(); + return { finished: Promise.resolve() }; + }; + }); + + // Navigate forward + await page.locator(".nav").click(); + await waitForText(page, ".page2", "page 2"); + + // Navigate back + await page.evaluate(() => window.history.go(-1)); + await page.locator(".page1").wait(); + + // Both navigations should have used view transitions + const vtCount = await page.evaluate( + // deno-lint-ignore no-explicit-any + () => (window as any).__vtCount, + ); + expect(vtCount).toBe(2); + }); + }, +}); From c267144d01801d718745cbcb9fa22e3edfceddb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 26 Mar 2026 19:52:30 +0100 Subject: [PATCH 3/4] docs: add View Transitions section to partials documentation Documents the f-view-transition attribute, how to enable it, customize animations with CSS, and disable it. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/latest/advanced/partials.md | 64 ++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/docs/latest/advanced/partials.md b/docs/latest/advanced/partials.md index ac891a6056c..0674437d8ab 100644 --- a/docs/latest/advanced/partials.md +++ b/docs/latest/advanced/partials.md @@ -199,6 +199,70 @@ export default function LogView() { > [info]: When picking the `prepend` or `append` mode, make sure to add keys to > the elements. +## View Transitions + +Fresh supports the browser's native +[View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) +for animating partial updates. When enabled, DOM updates during client-side +navigation are wrapped in `document.startViewTransition()`, giving you smooth +animated transitions between pages. + +### Enabling view transitions + +Add the `f-view-transition` attribute alongside `f-client-nav`: + +```diff routes/_app.tsx +- ++ +``` + +This is progressive enhancement — if the browser doesn't support the View +Transitions API, partials work exactly as before with no animation. + +### Customizing animations + +The default view transition is a cross-fade. You can customize it with standard +CSS: + +```css static/styles.css +::view-transition-old(root) { + animation: fade-out 0.2s ease-in; +} +::view-transition-new(root) { + animation: fade-in 0.2s ease-out; +} +``` + +For per-element transitions, assign a `view-transition-name` in CSS: + +```css static/styles.css +.sidebar { + view-transition-name: sidebar; +} +.main-content { + view-transition-name: content; +} +``` + +Then target those named transitions: + +```css static/styles.css +::view-transition-old(content) { + animation: slide-out-left 0.3s ease-in; +} +::view-transition-new(content) { + animation: slide-in-right 0.3s ease-out; +} +``` + +### Disabling view transitions + +You can disable view transitions by setting `f-view-transition={false}`: + +```tsx + +``` + ## Bypassing or disabling Partials If you want to exempt a particular element from triggering a partial request From 24fb6ea0e21ab083d946152e0d20b3a89b6cd7fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 29 Mar 2026 21:47:43 +0200 Subject: [PATCH 4/4] docs: move view transitions to dedicated page Extract the View Transitions section from partials.md into its own page with expanded content: per-element transitions, direction-aware animations, browser support. Replace inline section with cross-link. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/latest/advanced/partials.md | 65 +------------- docs/latest/advanced/view-transitions.md | 105 +++++++++++++++++++++++ docs/toc.ts | 1 + 3 files changed, 110 insertions(+), 61 deletions(-) create mode 100644 docs/latest/advanced/view-transitions.md diff --git a/docs/latest/advanced/partials.md b/docs/latest/advanced/partials.md index 0674437d8ab..a3cfe1896b3 100644 --- a/docs/latest/advanced/partials.md +++ b/docs/latest/advanced/partials.md @@ -201,67 +201,10 @@ export default function LogView() { ## View Transitions -Fresh supports the browser's native -[View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) -for animating partial updates. When enabled, DOM updates during client-side -navigation are wrapped in `document.startViewTransition()`, giving you smooth -animated transitions between pages. - -### Enabling view transitions - -Add the `f-view-transition` attribute alongside `f-client-nav`: - -```diff routes/_app.tsx -- -+ -``` - -This is progressive enhancement — if the browser doesn't support the View -Transitions API, partials work exactly as before with no animation. - -### Customizing animations - -The default view transition is a cross-fade. You can customize it with standard -CSS: - -```css static/styles.css -::view-transition-old(root) { - animation: fade-out 0.2s ease-in; -} -::view-transition-new(root) { - animation: fade-in 0.2s ease-out; -} -``` - -For per-element transitions, assign a `view-transition-name` in CSS: - -```css static/styles.css -.sidebar { - view-transition-name: sidebar; -} -.main-content { - view-transition-name: content; -} -``` - -Then target those named transitions: - -```css static/styles.css -::view-transition-old(content) { - animation: slide-out-left 0.3s ease-in; -} -::view-transition-new(content) { - animation: slide-in-right 0.3s ease-out; -} -``` - -### Disabling view transitions - -You can disable view transitions by setting `f-view-transition={false}`: - -```tsx - -``` +Partial updates can be animated using the browser's +[View Transitions API](/docs/advanced/view-transitions). Add `f-view-transition` +alongside `f-client-nav` to enable smooth animated transitions between pages +with zero JavaScript animation code. ## Bypassing or disabling Partials diff --git a/docs/latest/advanced/view-transitions.md b/docs/latest/advanced/view-transitions.md new file mode 100644 index 00000000000..78aeafd4613 --- /dev/null +++ b/docs/latest/advanced/view-transitions.md @@ -0,0 +1,105 @@ +--- +description: Animate page navigations with the View Transitions API +--- + +Fresh integrates the browser's native +[View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) +into its [partials](/docs/advanced/partials) system. When enabled, DOM updates +during client-side navigation are wrapped in `document.startViewTransition()`, +giving you smooth animated transitions between pages with zero JavaScript +animation code. + +This is progressive enhancement -- if the browser doesn't support the View +Transitions API, partials work exactly as before with no animation. + +## Enabling view transitions + +Add the `f-view-transition` attribute alongside `f-client-nav`: + +```tsx routes/_app.tsx +export default function App({ Component }: PageProps) { + return ( + + + + + + + + + ); +} +``` + +All partial navigations (link clicks, form submissions, back/forward) will now +be animated. + +## Customizing animations + +The default view transition is a cross-fade. Customize it with standard CSS +using the `::view-transition-old` and `::view-transition-new` pseudo-elements: + +```css static/styles.css +::view-transition-old(root) { + animation: fade-out 0.2s ease-in; +} +::view-transition-new(root) { + animation: fade-in 0.2s ease-out; +} +``` + +### Per-element transitions + +Assign a `view-transition-name` in CSS to animate specific elements +independently from the rest of the page: + +```css static/styles.css +.sidebar { + view-transition-name: sidebar; +} +.main-content { + view-transition-name: content; +} +``` + +Then target those named transitions: + +```css static/styles.css +/* Sidebar stays in place */ +::view-transition-old(sidebar), +::view-transition-new(sidebar) { + animation: none; +} + +/* Content slides */ +::view-transition-old(content) { + animation: slide-out-left 0.3s ease-in; +} +::view-transition-new(content) { + animation: slide-in-right 0.3s ease-out; +} +``` + +This is useful for keeping persistent UI (navigation bars, sidebars) stable +while animating the main content area. + +### Direction-aware animations + +Since Fresh tracks navigation history, you can use CSS custom properties or +classes to apply different animations for forward vs. backward navigation. The +View Transitions API captures the old and new states automatically -- combine +this with `::view-transition-group` to create directional slide effects. + +## Disabling view transitions + +Disable view transitions on a subtree by setting `f-view-transition={false}`: + +```tsx + +``` + +## Browser support + +View Transitions are supported in Chrome 111+, Edge 111+, and Safari 18+. +Firefox support is in development. On unsupported browsers, navigations work +normally without animation -- no polyfill needed. diff --git a/docs/toc.ts b/docs/toc.ts index 953d10c2544..6185e0bef75 100644 --- a/docs/toc.ts +++ b/docs/toc.ts @@ -54,6 +54,7 @@ const toc: RawTableOfContents = { ["layouts", "Layouts", "link:latest"], ["error-handling", "Error handling", "link:latest"], ["partials", "Partials", "link:latest"], + ["view-transitions", "View Transitions", "link:latest"], ["forms", "Forms", "link:latest"], ["define", "Define Helpers", "link:latest"], ["serialization", "Serialization", "link:latest"],