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
111 changes: 99 additions & 12 deletions docs/view-transitions.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: "View Transitions"
description: "Browser View Transitions API support for smooth animated transitions between pages and states with opt-in configuration."
description: "Browser View Transitions API support for animated page and state changes, including navigation scroll behavior."
---

# View Transitions
Expand All @@ -12,17 +12,29 @@ Cossack supports the browser [View Transitions API](https://developer.mozilla.or
Pass `viewTransitions: true` to `createClientApp` in your `entry-client.ts`:

```typescript
createClientApp({ container: '#root', viewTransitions: true });
import { createClientApp } from '@cossackframework/framework/client/app';
import { App } from '../App';

createClientApp({
container: '#root',
AppComponent: App,
viewTransitions: true,
});
```

When enabled and the browser supports the API (`document.startViewTransition`), SPA navigations automatically wrap their DOM commit phase in a view transition. On unsupported browsers (e.g., Firefox at the time of writing), navigation still works — just without animation.
When enabled and the browser supports the API (`document.startViewTransition`), SPA navigations automatically wrap their DOM commit phase in a view transition. On unsupported browsers, navigation still works without animation.

## Navigation Progress Bar

Enable a slim progress bar at the top of the page that fills during SPA navigations — the same UX pattern popularized by NProgress and Next.js:

```typescript
createClientApp({ container: '#root', viewTransitions: true, progressBar: true });
createClientApp({
container: '#root',
AppComponent: App,
viewTransitions: true,
progressBar: true,
});
```

The bar appears at 30% when a navigation starts and completes to 100% when the new page is ready. No additional configuration or CSS is needed — the framework injects everything automatically. Both `viewTransitions` and `progressBar` are independent options; use either or both.
Expand All @@ -32,9 +44,51 @@ The bar appears at 30% when a navigation starts and completes to 100% when the n
When a user clicks a link and navigates between pages:

1. The framework fetches the new page data (network request happens normally).
2. The old page is destroyed and the new page is instantiated.
3. **This DOM commit step is wrapped inside `document.startViewTransition()`**, so the browser snapshots the old and new states and crossfades between them.
4. The loading.ts swap (if any) happens *before* the transition snapshots — so the transition animates from your loading skeleton to the real content.
2. The browser snapshots the current state through `document.startViewTransition()`.
3. Inside the transition update callback, Cossack destroys the old page, instantiates the new page, commits its DOM, and applies its scroll position.
4. The browser snapshots the committed destination and animates between the two states.

The loading.ts swap (if any) happens before the view transition starts, so the transition animates from your loading skeleton to the real content.

## Navigation Scroll Behavior

Scroll behavior belongs to SPA navigation and works the same way whether View Transitions are enabled or disabled. The default policy is `auto`:

| Navigation | `auto` behavior |
|---|---|
| New link or client redirect | Scroll to the URL fragment, or to the top when there is no matching fragment |
| Browser back/forward | Restore the position saved for that history entry |

You can set the app-wide policy in `entry-client.ts`:

```typescript
createClientApp({
container: '#root',
AppComponent: App,
viewTransitions: true,
navigation: { scroll: 'auto' },
});
```

Available policies are:

- `auto` — browser-like fragment, top, and history-restoration behavior. This is the default.
- `top` — always scroll to the top, including during back/forward traversal.
- `preserve` — leave the current viewport position unchanged.

Override the policy for an individual link with `data-scroll`:

```html
<a href="/articles?page=2" data-scroll="preserve">Next page</a>
```

Programmatic navigation accepts the same override:

```typescript
this.redirect('/articles?page=2', { scroll: 'preserve' });
```

When View Transitions are enabled, Cossack applies the destination scroll position inside the transition update callback. The new snapshot therefore represents the destination at its final scroll position.

## Per-Link Transition Types

Expand Down Expand Up @@ -75,16 +129,22 @@ You can pass multiple types by separating them with whitespace:
<a href="/dashboard" data-transition-types="nav-forward expand">Dashboard</a>
```

## Programmatic Navigation with Types
## Programmatic Navigation Options

When calling `this.redirect()` on the client, you can pass transition types via an options object:
When calling `this.redirect()` on the client, you can combine transition types and scroll behavior in one options object:

```typescript
// Redirect with a custom transition type
this.redirect('/dashboard', { types: ['nav-forward'] });

// Redirect with both status and types
this.redirect('/login', { status: 302, types: ['fade'] });
// Preserve scroll while using a custom transition
this.redirect('/dashboard?tab=activity', {
types: ['tab-forward'],
scroll: 'preserve',
});

// Server redirects can still include an HTTP status
this.redirect('/login', { status: 302, types: ['fade'], scroll: 'top' });
```

The original `redirect(url, status)` signature still works unchanged.
Expand Down Expand Up @@ -185,7 +245,34 @@ This makes the feature accessible by default. Users who want partial motion can

## Browser Back/Forward

Browser-initiated back/forward navigation (the browser's back button, `history.back()`) does **not** carry transition types. This matches the behavior of other frameworks — there's no reliable cross-browser signal for "back" navigation. If you need to detect navigation direction, use the `cossack:ready` event's `navigationType` field.
Browser-initiated back/forward navigation does not carry transition types, but Cossack marks it as history traversal and restores its saved scroll position under the default `auto` policy.

The `cossack:ready` event exposes the navigation kind through `event.detail.navigationType`:

- `initial` — initial hydration.
- `push` — a link or programmatic client navigation.
- `traverse` — browser back/forward navigation.

## Closing Transient UI During Navigation

Persistent layouts keep their client state between pages. Close transient UI such as a mobile navigation sheet when navigation begins, with navigation completion as a fallback:

```typescript
import { ClientState, OnDocument } from '@cossackframework/core';

@ClientState() mobileNavigationOpen = false;

@OnDocument('cossack:before-navigate')
closeMobileNavigationBeforeNavigate() {
this.mobileNavigationOpen = false;
}

onNavigateComplete() {
this.mobileNavigationOpen = false;
}
```

Using the built-in event decorator and lifecycle hook keeps the listeners scoped to the component and ensures an open sheet is not carried into the destination page.

## Graceful Fallback

Expand Down
143 changes: 134 additions & 9 deletions packages/core/src/client/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,121 @@ export interface NavigateOptions {
* Authors target these in CSS via `::view-transition-group(.<type>)`.
*/
types?: string[];
/**
* Control scrolling after this navigation. `auto` follows browser-like
* semantics: new entries go to a fragment or the top, while history
* traversal restores the destination entry's saved position.
*/
scroll?: NavigationScrollBehavior;
/** @internal Identifies browser back/forward traversal. */
navigationType?: NavigationType;
}

export type NavigationScrollBehavior = 'auto' | 'top' | 'preserve';
export type NavigationType = 'push' | 'traverse';

export interface ScrollPosition {
x: number;
y: number;
}

const COSSACK_NAVIGATION_STATE = '__cossackNavigation';

type NavigationHistoryState = Record<string, unknown> & {
[COSSACK_NAVIGATION_STATE]?: {
scroll?: ScrollPosition;
};
};

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function historyStateWithScroll(state: unknown, scroll: ScrollPosition): NavigationHistoryState {
const base: NavigationHistoryState = isRecord(state) ? { ...state } : {};
const existing = isRecord(base[COSSACK_NAVIGATION_STATE])
? base[COSSACK_NAVIGATION_STATE]
: {};
base[COSSACK_NAVIGATION_STATE] = { ...existing, scroll };
return base;
}

/** Save the current viewport position on the active session-history entry. */
export function saveCurrentScrollPosition(): void {
if (typeof window === 'undefined') return;
window.history.replaceState(
historyStateWithScroll(window.history.state, { x: window.scrollX, y: window.scrollY }),
'',
window.location.href,
);
}

/** Create state for a newly pushed history entry at the current viewport. */
export function createNavigationHistoryState(): NavigationHistoryState {
if (typeof window === 'undefined') return {};
return historyStateWithScroll({}, { x: window.scrollX, y: window.scrollY });
}

/** Read a scroll position previously stored by Cossack from history state. */
export function getSavedScrollPosition(state: unknown): ScrollPosition | undefined {
if (!isRecord(state)) return undefined;
const navigationState = state[COSSACK_NAVIGATION_STATE];
if (!isRecord(navigationState) || !isRecord(navigationState.scroll)) return undefined;
const { x, y } = navigationState.scroll;
return typeof x === 'number' && typeof y === 'number' ? { x, y } : undefined;
}

function scrollToPosition(position: ScrollPosition): void {
window.scrollTo({ left: position.x, top: position.y, behavior: 'instant' });
}

function scrollToUrlTarget(url: string): void {
const { hash } = new URL(url, window.location.href);
if (hash) {
let fragment = hash.slice(1);
try {
fragment = decodeURIComponent(fragment);
} catch {
// Keep the encoded fragment when it is not valid URI data.
}

const target = document.getElementById(fragment)
?? document.getElementsByName(fragment)[0];
if (target) {
target.scrollIntoView({ block: 'start', behavior: 'instant' });
return;
}
}

scrollToPosition({ x: 0, y: 0 });
}

/**
* Apply the configured scroll policy after the destination DOM is committed.
* Call this from inside a View Transition update callback so its new snapshot
* contains the destination at the final viewport position.
*/
export function applyNavigationScroll(
url: string,
behavior: NavigationScrollBehavior,
navigationType: NavigationType,
): void {
if (typeof window === 'undefined' || behavior === 'preserve') return;

if (behavior === 'auto' && navigationType === 'traverse') {
const saved = getSavedScrollPosition(window.history.state);
if (saved) {
scrollToPosition(saved);
return;
}
}

if (behavior === 'top') {
scrollToPosition({ x: 0, y: 0 });
return;
}

scrollToUrlTarget(url);
}

/**
Expand Down Expand Up @@ -68,6 +183,13 @@ export function enableClientNavigation(
return types.length ? types : undefined;
};

const readScrollBehavior = (target: HTMLAnchorElement): NavigationScrollBehavior | undefined => {
const value = target.dataset.scroll;
return value === 'auto' || value === 'top' || value === 'preserve'
? value
: undefined;
};

// Intercept clicks on links
const handleClick = async (e: MouseEvent) => {
// A component may own this link (for example Sidebar.onNavigate).
Expand All @@ -92,13 +214,13 @@ export function enableClientNavigation(

const options: NavigateOptions | undefined = (() => {
const types = readTransitionTypes(target);
return types ? { types } : undefined;
const scroll = readScrollBehavior(target);
return types || scroll ? { types, scroll } : undefined;
})();

const accepted = await onNavigate(href, options);
if (accepted) {
window.history.pushState({}, '', href);
}
await onNavigate(href, options);
// The navigation implementation owns pushState so it can create the
// destination entry before applying its scroll position.
};
document.addEventListener('click', handleClick);

Expand All @@ -119,11 +241,14 @@ export function enableClientNavigation(
document.addEventListener('mouseover', handleMouseOver);

// Handle back/forward buttons.
// Browser-initiated back/forward navigations carry no transition types
// matching Next.js's behavior. Authors who need "back" semantics can
// detect them via `navigationType: 'spa'` in the `cossack:ready` event.
// Browser-initiated back/forward navigations carry no transition types.
// Mark them as traversal so the app can restore the destination entry's
// saved scroll position after its DOM has been committed.
const handlePopState = async () => {
await onNavigate(window.location.pathname + window.location.search);
await onNavigate(
window.location.pathname + window.location.search + window.location.hash,
{ navigationType: 'traverse' },
);
};
window.addEventListener('popstate', handlePopState);

Expand Down
22 changes: 18 additions & 4 deletions packages/core/src/shared/cossack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2004,24 +2004,38 @@ export abstract class Cossack<Env = any, T extends CossackOptions = {}> extends
public static SSR_HYDRATABLE = true;

public redirect(url: string, status?: RedirectStatusCode): Response | void;
public redirect(url: string, options: { status?: RedirectStatusCode; types?: string[] }): Response | void;
public redirect(url: string, options: {
status?: RedirectStatusCode;
types?: string[];
scroll?: NavigateOptions['scroll'];
}): Response | void;
// Redirect is intentionally universal: server actions return an HTTP
// redirect, while client handlers delegate to the SPA router. Marking it
// @Server caused client bootstrap to replace this implementation with an
// RPC proxy, turning local navigation into a /crpc call + full reload.
@Shared()
public redirect(
url: string,
statusOrOptions: RedirectStatusCode | { status?: RedirectStatusCode; types?: string[] } = 302,
statusOrOptions: RedirectStatusCode | {
status?: RedirectStatusCode;
types?: string[];
scroll?: NavigateOptions['scroll'];
} = 302,
): Response | void {
if (!this.isServer) {
const opts = typeof statusOrOptions === 'object' ? statusOrOptions : {};
const types = opts.types;
if (Cossack._onNavigate) {
// _onNavigate (the SPA entry) performs the navigation AND the
// history.pushState on success. Pushing state here too would
// create two history entries per redirect (Back needed twice).
Cossack._onNavigate(url, types ? { types } : undefined);
const options: NavigateOptions = {
types: opts.types,
scroll: opts.scroll,
};
Cossack._onNavigate(
url,
options.types || options.scroll ? options : undefined,
);
} else {
window.location.href = url;
}
Expand Down
13 changes: 13 additions & 0 deletions packages/core/tests/cossack.client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,4 +271,17 @@ describe('redirect() does not double-push history state', () => {
// redirect() itself must not push state.
expect(pushSpy).not.toHaveBeenCalled();
});

it('forwards a per-navigation scroll override', () => {
const component = new RedirectComponent();
const navSpy = vi.fn();
const prev = Cossack._onNavigate;
Cossack._onNavigate = navSpy;
try {
component.redirect('/same-context', { scroll: 'preserve' });
} finally {
Cossack._onNavigate = prev;
}
expect(navSpy).toHaveBeenCalledWith('/same-context', { scroll: 'preserve' });
});
});
Loading
Loading