## Overview

By default, zudo-doc navigates between pages without a full browser reload. Instead of fetching a brand-new document and repainting the whole window, it swaps only the page body in place, keeps the surrounding chrome (header, sidebar, footer) untouched, and animates the change with a cross-fade. The result feels like a single-page app (SPA): fast, smooth, and free of the white flash that a hard navigation produces.

This behaviour is called **Dynamic page transition**, and it is **on by default**.

[Dynamic content unavailable in text export]

Dynamic page transition is purely a presentational enhancement. The site is still a fully static export — every page exists as standalone HTML and works without JavaScript. The transition layer only intercepts in-app link clicks when JS is available; everything degrades to ordinary navigation otherwise.



## What It Does

A dynamic navigation combines three pieces:

1. **Same-document body swap.** When you click an internal link, the router fetches the target page in the background, then replaces the current page's body content with the new page's content — without tearing down and rebuilding the entire document. The header, sidebar, and footer DOM nodes persist across the navigation, so their scroll position and state are preserved.
2. **History handling.** The router updates the browser history with `pushState` so the URL changes and the Back/Forward buttons work exactly as they would with a normal navigation. Pressing Back fires a `popstate` event, which the router handles by swapping back to the previous page — again without a full reload.
3. **View Transitions API cross-fade.** The swap is wrapped in the browser's [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API), which captures a snapshot of the old and new states and animates between them. The non-chrome content cross-fades, while the header, sidebar, and footer are extracted into their own transition layers and held static so they don't flicker.

[Dynamic content unavailable in text export]

The chrome elements (`<header>`, the desktop sidebar, `<footer>`, and the sidebar-toggle button) are pinned with a stable `view-transition-name`, which is what lets the cross-fade animate only the article area. When a chrome element exists on one page but not the next (for example, moving from a docs page with a sidebar to a top page without one), that element fades in or out in sync with the content instead of snapping.



## Island state during navigation

Retaining a chrome ancestor does not freeze every nested island. When effective identity and serialized props are unchanged, the native runtime retains its DOM, handle, and scope-local state. Changed identity or props recreate the render scope; changed topology or scheduling invalidates the affected persistence boundary. Session-owned host props can opt into `data-zd-props-preserve`; keep page-derived props fresh. See [Host Chrome Bindings](https://zudo-doc.takazudomodular.com/docs/reference/host-chrome-bindings).

## The Loading Overlay

While the target page is being fetched, zudo-doc shows a full-screen **loading overlay** with a centered spinner. It appears when the navigation begins and disappears as soon as the new content is swapped in, giving immediate feedback that a click was registered — especially useful on slower connections where the fetch takes a moment.

The link or button you clicked is also briefly highlighted while its navigation is pending, so it's clear which action is in flight.

[Dynamic content unavailable in text export]

The overlay only shows for the brief window between click and swap. For fast local navigations it may flash by almost instantly — that's expected. Its job is to cover the perceptible delay on slower fetches, not to add an artificial pause.



## Reduced Motion

zudo-doc respects the user's `prefers-reduced-motion` setting. When a visitor has reduced motion enabled at the OS level:

- The cross-fade animation is collapsed to an **instant swap** — the new page appears immediately with no fade or slide.
- The spinner stops rotating and is shown as a **static** dimmed ring instead of an animated one.

The navigation itself still works the same way (same-document swap, history handling); only the motion is removed. This keeps the experience comfortable for users who are sensitive to animation while preserving the performance benefit of soft navigation.

## Toggling the Feature

Dynamic page transition is controlled by the `dynamicPageTransition` setting, and it is **enabled by default**.

### In zfb.config.ts

```ts title="zfb.config.ts"
export default defineConfig(
  zudoDoc({
    // ...
    dynamicPageTransition: true,
  }),
);
```

Set it to `false` to disable dynamic transitions entirely.

### In the preset generator

When scaffolding a new project with `create-zudo-doc`, the option is labelled **"Dynamic page transition"** and is enabled by default. You can control it non-interactively with CLI flags:

```sh
# Explicitly enable (this is the default)
create-zudo-doc my-docs --dynamic-page-transition

# Disable — generate a project with plain full-page navigation
create-zudo-doc my-docs --no-dynamic-page-transition
```

## What "Off" Means

With dynamic page transition disabled, navigation falls back to **plain full-page loads**:

- Every internal link triggers a normal browser navigation — the document is fetched and the whole page is repainted.
- There is **no SPA body swap**: the header, sidebar, and footer are rebuilt on each navigation rather than persisting.
- There is **no loading overlay** and **no cross-fade** — you get the browser's default navigation behaviour.

This is a simpler model with zero transition JavaScript. Choose it if you prefer a classic multi-page experience, need to rule out any interaction between the soft-swap router and custom client-side scripts, or simply don't want the animation.

[Dynamic content unavailable in text export]

Turning the feature off does not change your content or routes in any way — only how the browser moves between pages. Every page remains a standalone, statically exported HTML document either way.



## Wiring the Overlay in a Standalone Layout

If you build your layout with `DocLayoutWithDefaults`, the loading overlay is already wired for you — there is nothing to do, and you can skip this section. It applies only when you assemble your own layout (without `DocLayoutWithDefaults`) and want the same overlay behaviour.

The overlay ships from `@takazudo/zudo-doc` as two pieces you wire yourself: the `PageLoadingOverlay` component and its stylesheet, `@takazudo/zudo-doc/page-loading.css`.

### Mount the component

Import `PageLoadingOverlay` and render it **once per layout**, in a body-end slot (the same place you mount your other body-end providers). It is server-rendered with **no hydration** — it emits a small inline script that self-wires its visibility to the navigation lifecycle (`zfb:before-preparation` / `zfb:after-swap`), so there is no island to register and no `client:*` directive to add.

```tsx
import { PageLoadingOverlay } from "@takazudo/zudo-doc/page-loading";

// ...inside your layout, at the end of <body>:
<PageLoadingOverlay />;
```

The component takes one optional prop, `id`, which overrides the DOM `id` of the overlay element (useful only when more than one overlay could co-exist on a page, such as in tests). Leave it unset for the stable default.

[Dynamic content unavailable in text export]

Mount it **once** per layout. A second instance would render a duplicate overlay element and a second bootstrap script.



### Import the stylesheet

The component renders the overlay markup but not its CSS. Import the shipped stylesheet at the **top** of your `global.css`, alongside the other package CSS imports:

```css
@import "@takazudo/zudo-doc/page-loading.css";
```

[Dynamic content unavailable in text export]

The import must appear **before** any rule statements in your `global.css`. CSS requires all `@import` rules to precede other rules — placing it after a selector or `:root` block silently drops the import, and the overlay renders unstyled.



### Host tokens the stylesheet consumes

The stylesheet reads a handful of host design tokens. **All are optional** — each has a sensible built-in fallback, so the overlay works on a bare project that defines none of them:

| Token                          | Used for                                  | Fallback                                                            |
| ------------------------------ | ----------------------------------------- | ------------------------------------------------------------------ |
| `--color-page-loading-overlay` | The scrim (overlay background)            | `color-mix(in oklch, var(--color-overlay) 60%, transparent)`       |
| `--color-overlay`              | Backing colour for the scrim fallback     | `#000` (used only inside the fallback above)                       |
| `--color-page-loading-spinner` | Spinner border colour                     | `#fff`                                                             |
| `--color-accent`               | Highlight on the pending link/button      | _(no fallback — the highlight is simply omitted if unset)_         |
| `--z-index-modal`              | Stack level of the overlay                | `100`                                                             |

If your project already defines the standard zudo-doc semantic tokens, the overlay picks them up automatically and matches your theme with no extra work.

### Retoning the scrim independently

The scrim now reads its **own** token, `--color-page-loading-overlay`, which is **decoupled from the lightbox/image-enlarge backdrop** (`--color-overlay`). Set it to retone the loading scrim without touching any other overlay:

```css
:root {
  /* A light, frosted brand scrim for the loading overlay only —
     the image lightbox keeps using --color-overlay. */
  --color-page-loading-overlay: color-mix(
    in oklch,
    var(--color-bg) 70%,
    transparent
  );
}
```

When `--color-page-loading-overlay` is unset, the stylesheet falls back to a 60%-opacity mix of `--color-overlay`, so existing projects keep their previous appearance with no change.

### Why the spinner has its own token

The scrim is **theme-independent** — it is always a dark wash, in light and dark color schemes alike. So the spinner ring is pinned light too, via its own `--color-page-loading-spinner` token, instead of following the page foreground. A ring that flipped dark in a light scheme would sit at 3.02:1 against that always-dark scrim; the pinned white ring measures 6.18:1 in Default Light and 20.13:1 in Default Dark, clearing the WCAG 1.4.11 non-text floor of 3:1 in both.

For the same reason the reduced-motion ring — a static, complete circle rather than a spinning arc — carries **no** opacity reduction. Dimming it to 50% would composite to 2.85:1 in a light scheme, below the floor.

If you retone the scrim to something light, override the ring to match:

```css
:root {
  --color-page-loading-overlay: color-mix(in oklch, var(--color-bg) 70%, transparent);
  /* A scheme-aware scrim wants a scheme-aware ring. */
  --color-page-loading-spinner: var(--color-fg);
}
```

### Drop any hand-copied overlay rules

Before this stylesheet shipped, standalone consumers had to **hand-copy** the overlay, spinner, and pending-link rules into their own `global.css`. If you did this, **remove those copied rules** when you adopt the `@import`. Keeping both leaves a duplicate `@keyframes page-loading-spin` and colliding `.page-loading-overlay` / `.page-loading-spinner` declarations, where whichever rule wins is order-dependent and easy to get wrong. The shipped stylesheet is now the single source of truth.