Skip to content

Commit 400e217

Browse files
bartlomiejuclaude
andauthored
feat: add View Transitions API support for client-side navigation (#3708)
## Summary - Integrates the [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) into Fresh's existing partials system - Opt-in via `f-view-transition` attribute, progressive enhancement (falls back gracefully) - Wraps all navigation types (link clicks, popstate, form submissions) in `document.startViewTransition()` ## Usage ```html <body f-client-nav f-view-transition> <!-- your app --> </body> ``` Customize animations with standard CSS: ```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 work via `view-transition-name`: ```css .sidebar { view-transition-name: sidebar; } ``` ## Design decisions - **Opt-in** — only activates when `f-view-transition` attribute is present, can be disabled with `f-view-transition="false"` - **Progressive** — falls back to regular partial updates when the browser doesn't support the View Transitions API - **Minimal** — 35 lines of implementation, integrates into existing partials infrastructure rather than building a parallel navigation system Closes #3458 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cf73d29 commit 400e217

6 files changed

Lines changed: 379 additions & 5 deletions

File tree

docs/latest/advanced/partials.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,13 @@ export default function LogView() {
201201
> children from existing ones, leading to subtle rendering bugs. Fresh will log
202202
> a warning if it detects a missing key on an append/prepend partial.
203203
204+
## View Transitions
205+
206+
Partial updates can be animated using the browser's
207+
[View Transitions API](/docs/advanced/view-transitions). Add `f-view-transition`
208+
alongside `f-client-nav` to enable smooth animated transitions between pages
209+
with zero JavaScript animation code.
210+
204211
## Bypassing or disabling Partials
205212

206213
If you want to exempt a particular element from triggering a partial request
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
---
2+
description: Animate page navigations with the View Transitions API
3+
---
4+
5+
Fresh integrates the browser's native
6+
[View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API)
7+
into its [partials](/docs/advanced/partials) system. When enabled, DOM updates
8+
during client-side navigation are wrapped in `document.startViewTransition()`,
9+
giving you smooth animated transitions between pages with zero JavaScript
10+
animation code.
11+
12+
This is progressive enhancement -- if the browser doesn't support the View
13+
Transitions API, partials work exactly as before with no animation.
14+
15+
## Enabling view transitions
16+
17+
Add the `f-view-transition` attribute alongside `f-client-nav`:
18+
19+
```tsx routes/_app.tsx
20+
export default function App({ Component }: PageProps) {
21+
return (
22+
<html lang="en">
23+
<head>
24+
<meta charset="utf-8" />
25+
</head>
26+
<body f-client-nav f-view-transition>
27+
<Component />
28+
</body>
29+
</html>
30+
);
31+
}
32+
```
33+
34+
All partial navigations (link clicks, form submissions, back/forward) will now
35+
be animated.
36+
37+
## Customizing animations
38+
39+
The default view transition is a cross-fade. Customize it with standard CSS
40+
using the `::view-transition-old` and `::view-transition-new` pseudo-elements:
41+
42+
```css static/styles.css
43+
::view-transition-old(root) {
44+
animation: fade-out 0.2s ease-in;
45+
}
46+
::view-transition-new(root) {
47+
animation: fade-in 0.2s ease-out;
48+
}
49+
```
50+
51+
### Per-element transitions
52+
53+
Assign a `view-transition-name` in CSS to animate specific elements
54+
independently from the rest of the page:
55+
56+
```css static/styles.css
57+
.sidebar {
58+
view-transition-name: sidebar;
59+
}
60+
.main-content {
61+
view-transition-name: content;
62+
}
63+
```
64+
65+
Then target those named transitions:
66+
67+
```css static/styles.css
68+
/* Sidebar stays in place */
69+
::view-transition-old(sidebar),
70+
::view-transition-new(sidebar) {
71+
animation: none;
72+
}
73+
74+
/* Content slides */
75+
::view-transition-old(content) {
76+
animation: slide-out-left 0.3s ease-in;
77+
}
78+
::view-transition-new(content) {
79+
animation: slide-in-right 0.3s ease-out;
80+
}
81+
```
82+
83+
This is useful for keeping persistent UI (navigation bars, sidebars) stable
84+
while animating the main content area.
85+
86+
### Direction-aware animations
87+
88+
Since Fresh tracks navigation history, you can use CSS custom properties or
89+
classes to apply different animations for forward vs. backward navigation. The
90+
View Transitions API captures the old and new states automatically -- combine
91+
this with `::view-transition-group` to create directional slide effects.
92+
93+
## Disabling view transitions
94+
95+
Disable view transitions on a subtree by setting `f-view-transition={false}`:
96+
97+
```tsx
98+
<body f-client-nav f-view-transition={false}>
99+
```
100+
101+
## Browser support
102+
103+
View Transitions are supported in Chrome 111+, Edge 111+, and Safari 18+.
104+
Firefox support is in development. On unsupported browsers, navigations work
105+
normally without animation -- no polyfill needed.

docs/toc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const toc: RawTableOfContents = {
5454
["layouts", "Layouts", "link:latest"],
5555
["error-handling", "Error handling", "link:latest"],
5656
["partials", "Partials", "link:latest"],
57+
["view-transitions", "View Transitions", "link:latest"],
5758
["forms", "Forms", "link:latest"],
5859
["define", "Define Helpers", "link:latest"],
5960
["serialization", "Serialization", "link:latest"],

packages/fresh/src/runtime/client/partials.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
matchesUrl,
77
PartialMode,
88
UrlMatchKind,
9+
VIEW_TRANSITION_ATTR,
910
} from "../shared_internal.ts";
1011
import {
1112
ACTIVE_PARTIALS,
@@ -43,6 +44,33 @@ function checkClientNavEnabled(el: HTMLElement) {
4344
return setting.getAttribute(CLIENT_NAV_ATTR) !== "false";
4445
}
4546

47+
function isViewTransitionEnabled(): boolean {
48+
const setting = document.querySelector(`[${VIEW_TRANSITION_ATTR}]`);
49+
if (setting === null) return false;
50+
return setting.getAttribute(VIEW_TRANSITION_ATTR) !== "false";
51+
}
52+
53+
/**
54+
* Wraps a DOM update function with the View Transitions API when available
55+
* and enabled. Falls back to calling the update directly otherwise.
56+
*/
57+
function withViewTransition(update: () => Promise<void>): Promise<void> {
58+
if (
59+
isViewTransitionEnabled() &&
60+
// deno-lint-ignore no-explicit-any
61+
typeof (document as any).startViewTransition === "function"
62+
) {
63+
return new Promise((resolve, reject) => {
64+
// deno-lint-ignore no-explicit-any
65+
const transition = (document as any).startViewTransition(async () => {
66+
await update();
67+
});
68+
transition.finished.then(resolve, reject);
69+
});
70+
}
71+
return update();
72+
}
73+
4674
// Keep track of history state to apply forward or backward animations
4775
let index = history.state?.index || 0;
4876
if (!history.state) {
@@ -132,8 +160,10 @@ document.addEventListener("click", async (e) => {
132160
partial ? partial : nextUrl.href,
133161
location.href,
134162
);
135-
await fetchPartials(nextUrl, partialUrl, true);
136-
updateLinks(nextUrl);
163+
await withViewTransition(async () => {
164+
await fetchPartials(nextUrl, partialUrl, true);
165+
updateLinks(nextUrl);
166+
});
137167
scrollTo({ left: 0, top: 0, behavior: "instant" });
138168
} finally {
139169
if (indicator !== undefined) {
@@ -192,8 +222,10 @@ addEventListener("popstate", async (e) => {
192222

193223
const url = new URL(location.href, location.origin);
194224
try {
195-
await fetchPartials(url, url, true);
196-
updateLinks(url);
225+
await withViewTransition(async () => {
226+
await fetchPartials(url, url, true);
227+
updateLinks(url);
228+
});
197229
scrollTo({
198230
left: state.scrollX ?? 0,
199231
top: state.scrollY ?? 0,
@@ -263,7 +295,9 @@ document.addEventListener("submit", async (e) => {
263295
init = { body: new FormData(el, e.submitter), method: lowerMethod };
264296
}
265297

266-
await fetchPartials(actionUrl, partialUrl, true, init);
298+
await withViewTransition(async () => {
299+
await fetchPartials(actionUrl, partialUrl, true, init);
300+
});
267301
}
268302
}
269303
});

packages/fresh/src/runtime/shared_internal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const DATA_CURRENT = "data-current";
55
export const DATA_ANCESTOR = "data-ancestor";
66
export const DATA_FRESH_KEY = "data-frsh-key";
77
export const CLIENT_NAV_ATTR = "f-client-nav";
8+
export const VIEW_TRANSITION_ATTR = "f-view-transition";
89

910
export const enum OptionsType {
1011
ATTR = "attr",

0 commit comments

Comments
 (0)