Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/latest/advanced/partials.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions docs/latest/advanced/view-transitions.md
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="en">
<head>
<meta charset="utf-8" />
</head>
<body f-client-nav f-view-transition>
<Component />
</body>
</html>
);
}
```

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
<body f-client-nav f-view-transition={false}>
```

## 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.
1 change: 1 addition & 0 deletions docs/toc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
44 changes: 39 additions & 5 deletions packages/fresh/src/runtime/client/partials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
matchesUrl,
PartialMode,
UrlMatchKind,
VIEW_TRANSITION_ATTR,
} from "../shared_internal.ts";
import {
ACTIVE_PARTIALS,
Expand Down Expand Up @@ -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<void>): Promise<void> {
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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
}
}
});
Expand Down
1 change: 1 addition & 0 deletions packages/fresh/src/runtime/shared_internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading