diff --git a/docs/latest/advanced/partials.md b/docs/latest/advanced/partials.md index 252bf40fe1d..ff9c608846a 100644 --- a/docs/latest/advanced/partials.md +++ b/docs/latest/advanced/partials.md @@ -201,6 +201,13 @@ export default function LogView() { > children from existing ones, leading to subtle rendering bugs. Fresh will log > a warning if it detects a missing key on an append/prepend partial. +## View Transitions + +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 If you want to exempt a particular element from triggering a partial request 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 acfb54e8abb..ae8ac56053c 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"], diff --git a/packages/fresh/src/runtime/client/partials.ts b/packages/fresh/src/runtime/client/partials.ts index 8ffcd236f20..bf8cf78aa92 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, @@ -263,7 +295,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", diff --git a/packages/fresh/tests/partials_test.tsx b/packages/fresh/tests/partials_test.tsx index 9bcf9376ba5..965cc134c05 100644 --- a/packages/fresh/tests/partials_test.tsx +++ b/packages/fresh/tests/partials_test.tsx @@ -3055,3 +3055,229 @@ 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); + }); + }, +});