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(
+
+
+ ,
+ );
+ });
+
+ 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(
+
+
+ ,
+ );
+ });
+
+ 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(
+
+
+ ,
+ );
+ });
+
+ 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(
+
+
+ ,
+ );
+ });
+
+ 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);
+ });
+ },
+});