Client-Side Routing & View Transitions
Turn zfb into an SPA with soft-swap navigation, View Transition animations, and link prefetching.
zfb pages are static HTML by default. <ClientRouter /> opts them into SPA-style navigation: clicking a same-origin link fetches the next page in the background, swaps only the <body> (and the changed <head> nodes), and plays a View Transition animation — without a full browser reload.
Mounting <ClientRouter />
Import from @takazudo/zfb-runtime and place the component once inside your page <head>:
import { ClientRouter } from "@takazudo/zfb-runtime";
export default function Layout({ children }) {
return (
<html>
<head>
<meta charset="UTF-8" />
<ClientRouter fallback="animate" />
</head>
<body>{children}</body>
</html>
);
}Mounting the component is all a zfb project needs. The import itself is inert — importing ClientRouter from @takazudo/zfb-runtime registers no listeners and touches no history — but the build notices the import, ships the client router to the browser, and the router activates there. Activation is idempotent, so multiple mounts on the same page (or HMR re-runs) are safe.
The two halves are worth keeping straight when something doesn't work:
The component renders into
<head>: the opt-in<meta>tags the router reads, plus the global stylesheet for the offscreen ARIA route announcer. It registers nothing and navigates nothing.init()does everything else at runtime — restores this page's history entry and scroll position, marks the scripts the initial load already ran, and registers thepopstate/load/pageshow/ scroll listeners plus the click and form-submit intercepts. It runs as a side effect when the browser evaluates@takazudo/, which zfb's island scanner injects into the page's client bundle as soon as it sees a page reachzfb- runtime/ client- router <ClientRouter />— no"use client"boilerplate needed. See the "Enabling SPA soft-navigation" section of the runtime README for the detection rules and the manual escape hatch.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
fallback | "none" | "animate" | "swap" | "animate" | Behaviour when the browser does not support native View Transitions. |
prefetchAll | boolean | false | When true, every same-origin link is opted into the "hover" prefetch strategy automatically. |
preserveHtmlAttrs | string[] | [] | Extra <html> attribute names whose runtime values survive each SPA swap — see Preserving runtime <html> attributes. |
traverseRefetch | boolean | false | Opt this page out of the same-page Back/Forward fast-path, forcing a re-fetch on every traversal — see Same-page traversal. |
Fallback modes
The fallback prop controls what happens in browsers that do not support document.startViewTransition:
"animate"— simulates the transition using CSS animations. zfb setsdata-zfb-transition-fallback="old"on<html>before the swap and"new"after. Target these with CSS to replicate the fade/slide you defined for native View Transitions."swap"— immediately swaps head and body with no animation."none"— skips the router entirely; same-origin links fall back to full-page browser navigation.
{/* Use full document navigation when native View Transitions are unavailable */}
<ClientRouter fallback="none" />fallback is not a universal native-animation switch: on a native-capable browser, fallback="none" and fallback="swap" do not disable native View Transitions. Native snapshot CSS and simulated fallback CSS are separate policies. The simulated path also serves special cases such as formData navigations; it cannot reproduce the native snapshot tree.
Preserving runtime <html> attributes
On every SPA swap the router copies the incoming server-rendered document's <html> attributes onto the live root — so a runtime attribute a persisted island sets on <html> (a data-theme or data-sidebar-hidden driven from localStorage) is dropped on every navigation. List those attribute names in preserveHtmlAttrs and the router re-applies their current value after each swap:
<ClientRouter preserveHtmlAttrs={["data-theme", "data-sidebar-hidden"]} />The list is emitted as a <meta name="zfb-preserve-html-attrs"> tag that the swap reads from the current (outgoing) page, so mount <ClientRouter /> with the same list on every page that participates in SPA navigation — a page that omits an entry drops that attribute when navigating away from it. Names match case-insensitively (DOM attribute names are lowercased).
For dynamic or computed attribute values that a static preserve-list can't express, mutate event.newDocument.documentElement in a zfb:before-swap listener instead — see Navigation lifecycle events.
Programmatic navigation with navigate()
Call navigate() from client-side code (inside an island or an event handler) to trigger a soft navigation:
import { navigate } from "@takazudo/zfb-runtime/client-router";
// Push a new history entry (default)
await navigate("/about");
// Replace the current history entry instead of pushing
await navigate("/search?q=zfb", { history: "replace" });`<ClientRouter />` must be mounted on the CURRENT page
navigate() only soft-navigates when the page it is called from has <ClientRouter /> mounted. Internally it checks for the <meta name="zfb-view-transitions-enabled"> tag that <ClientRouter /> renders into <head>; when that tag is absent it falls back to a plain location.href assignment — a full page load, not a soft navigation, with no error or warning.
Importing navigate (or syncHistoryEntry) from the root @takazudo/zfb-runtime barrel is not enough by itself. That barrel is side-effect-free and deliberately does not export init(), so importing from it installs no interception listeners — the router never starts watching link clicks or form submits. Each helper keeps its own documented behaviour when you call it directly (navigate() still navigates, syncHistoryEntry() still writes its history entry); what you don't get is the automatic interception.
Mount <ClientRouter /> in the layout <head> of every page you want soft-navigable. That renders the required meta tag, and it is also what makes zfb's island scanner ship the router to the browser, where import "@takazudo/ runs init() for you. If you have a reason to call init() yourself instead, import it from that same subpath — note that importing the subpath at all already activates the router.
navigate() accepts an optional Options object:
type Options = {
history?: "auto" | "push" | "replace";
info?: any; // passed to before-preparation event as event.info
state?: any; // merged into the new history.state entry
formData?: FormData;
};history: "auto"(the default when the option is omitted) — creates a new browser history entry viapushState. Passing"push"explicitly does the same.history: "replace"— callshistory.replaceStateso no back-button entry is created.
navigate() is a no-op during SSR and emits a console warning if called on the server.
data-zfb-history on links
You can opt individual <a> tags into replaceState without JavaScript:
<a href="/terms" data-zfb-history="replace">Terms</a>Opting a link out of the router
Add data-zfb-reload to any <a> or <form> to force a full browser reload for that navigation:
<a href="/admin" data-zfb-reload>Admin (full reload)</a>Form submissions
<ClientRouter /> also intercepts same-origin <form> submits and turns them into a soft navigation through the same navigate() path as link clicks — no full-page round trip.
GET forms — the form's fields are serialized into the destination URL's query string, and the result is a plain GET soft navigation; no request body is sent.
POST (or any other non-GET method) forms — the submitted
FormDatais carried through tonavigate()'sformDataoption and sent as the body of a real POST request, encoded asapplication/x-www-form-urlencodedwhen the form'senctyperequests it, or as multipartFormDataotherwise.The submitter's
formaction/formmethodwin — a<button formaction="...">/formmethod="..."on the element that triggered the submit overrides the<form>'s ownaction/method, matching standard HTML form semantics.A submitted request is never sent twice — a
formDatanavigation writes its history entry once, afterzfb:before-swaphas settled the final URL and before the old page is torn down, so it always runs through the simulated transition (the page'szfb-view-transitions-fallbacksetting) rather than a native View Transition. If the browser rejects that history write, the navigation ends withzfb:navigation-abortedand the current page left as it was; the router does not resubmit the form or reload its response URL.
Opting out of a soft form submission
A submit falls through to a normal, full browser form submission when any of the following are true:
the
<form>carriesdata-zfb-reload(same opt-out attribute as links — see Opting a link out of the router above)method="dialog"— the special HTML keyword used inside<dialog>elementsthe resolved action URL is cross-origin
the submit was triggered by a modifier-key click (Cmd/Ctrl/Shift/Alt) or a non-primary mouse button on the submitter
another handler already called
event.preventDefault()on thesubmitevent
event.formData on zfb:before-preparation is set only for a form-triggered navigation (undefined for link clicks and for programmatic navigate() calls that don't pass one) — listen there to inspect or react to a submission before it's sent.
Deep-linking transient UI state with syncHistoryEntry()
Modals, dialogs, and viewers often want their own URL — so Back closes them, and the state is shareable and bookmarkable — without performing a real page navigation. Don't reach for raw history.pushState() / history.replaceState() for this: the router tracks its own history index and the URL it navigated from to detect Back/Forward direction and same-page traversals (see Same-page traversal below), and a hand-rolled history entry desyncs that bookkeeping.
syncHistoryEntry() writes a router-managed history entry without navigating — no fetch, no swap, no DOM changes, and it never scrolls the viewport (though it does stamp the entry with the current scroll position — see below):
import { syncHistoryEntry } from "@takazudo/zfb-runtime/client-router";
function openModal(id: string) {
syncHistoryEntry(`#photo-${id}`);
}function syncHistoryEntry(url: string | URL, options?: SyncHistoryEntryOptions): void;
type SyncHistoryEntryOptions = {
replace?: boolean; // use replaceState instead of pushState (no new Back entry)
state?: any; // merged into history.state; the router's own keys win on collision
};Push by default — creates a new history entry, so Back returns to whatever the user was looking at before. Pass
{ replace: true }to overwrite the current entry instead (no new Back stop).stateis merged, router keys win — your own keys survive, but the router's bookkeeping keys (index,scrollX,scrollY) always take precedence if yourstateobject happens to reuse those names.Throws on a cross-origin URL — never silently falls back to a full-page load; a mistaken cross-origin call is a bug you want to see immediately.
Rethrows a rejected History write — when the browser refuses the URL write (for example WebKit's History-write rate limit), the browser's own error is rethrown unchanged. The router's tracked history index and "from" URL stay on the entry that is still current, and nothing navigates, fetches, or scrolls. Inside an
about:srcdocdocument the browser's History restriction is tolerated instead: the call returns without writing.No-ops during SSR, emitting the same one-time console warning as calling
navigate()on the server.Never scrolls the viewport or touches the DOM /
document.title— it is pure history bookkeeping. Your component is responsible for rendering the modal/dialog itself based on the current URL.Stamps the entry with the CURRENT scroll position, not
(0, 0). A pushed (or replaced) entry recordsscrollX/scrollYas they are right now, not the top of the page. This matters for the traverse fast-path: if the user later Forward-navigates back to this entry (see Same-page traversal below), the page restores to the scroll position it actually had when you calledsyncHistoryEntry()— not a jarring snap to the top under a reopened dialog.
Example: a hash-based modal
import { computed, getScope, signal } from "@takazudo/zfb/zudo-react";
import { syncHistoryEntry } from "@takazudo/zfb-runtime/client-router";
function PhotoModal({ id }: { id: string }) {
const open = signal(false);
const hidden = computed(() => !open.value);
getScope().onActivate(() => {
const syncFromUrl = () => { open.value = location.hash === `#photo-${id}`; };
window.addEventListener("popstate", syncFromUrl);
window.addEventListener("hashchange", syncFromUrl);
syncFromUrl();
return () => {
window.removeEventListener("popstate", syncFromUrl);
window.removeEventListener("hashchange", syncFromUrl);
};
});
return <div class="modal" hidden={hidden}>{/* photo content */}</div>;
}
function openPhoto(id: string) {
syncHistoryEntry(`${location.pathname}#photo-${id}`);
}Because the pushed entry shares the current pathname and search (only the hash changes), pressing Back afterwards is served by the same-page traversal fast-path (see below): no fetch, no swap, no full-page remount. The browser still fires its normal popstate event, though — syncHistoryEntry() does not close the modal for you. The modal listens for popstate (or hashchange) itself and closes when the hash no longer matches; without that listener the URL would change but the modal would stay open.
Example: a dialog with its own pathname
Some viewers deep-link to a real path segment instead of a hash — a photo lightbox at /, say, reachable from a shareable link:
import { syncHistoryEntry } from "@takazudo/zfb-runtime/client-router";
function openPhotoLightbox(slug: string) {
syncHistoryEntry(`/photos/${slug}/`);
}This changes pathname, so the pushed entry no longer matches the page the user came from — pressing Back falls through to a normal cross-page traversal (fetch + swap of the previous page), not the fast-path. syncHistoryEntry() only edits the URL bar and the router's bookkeeping; it never fetches or renders anything itself, so an island reading location.pathname is what actually shows the lightbox contents while the URL carries it.
Raw History entries and ownership
Every Back/Forward press lands on an entry that either the router or your app owns. The router decides by comparing the entry's URL with the document the DOM actually shows — not with the address bar, which syncHistoryEntry() can point at a URL nothing was rendered for.
App-owned same-document entries use
syncHistoryEntry(). It keeps the router's bookkeeping in sync, so Back from a hash or same-searchentry is served by the fast-path. Back from asyncHistoryEntry()entry with another pathname is the cross-page swap described above. Back over such an entry onto a fragment entry of the page still in the DOM does not refetch that page.Fragment entries of the rendered page belong to the browser. A hash link the router does not handle, or a
location.hashwrite, creates an entry with anullhistory.state. Back/Forward among those entries only scrolls — no fetch, no swap, no lifecycle event.A raw
history.pushState(null, "", "?q=…")on the rendered pathname belongs to your app. The router leaves such filter or search entries to your ownpopstatehandler and does not fetch or swap.Router-rendered documents are router-owned, even through a
nullentry. When the router soft-navigated from/tolist? page= 1# item /, Back to thelist? page= 2 #itementry fetches and swaps page 1 back in, so the rendered page always matches the URL.A raw
null-state entry on another pathname is router-owned document navigation.history.names a document the DOM does not show, so Back/Forward onto it fetches and swaps it like any other page, and islands remount. A raw entry with your own non-null state on another pathname has always been handled this way. There is no opt-out: an island that routes between pathnames itself should write its entries withpushState(null, "", "/ app/ item/ 2") syncHistoryEntry()and expect the cross-page swap on Back.
One edge remains: the router remembers which queries it rendered on a pathname, so a raw push that writes exactly such a query — for example ?page=1 after the router swapped ?page=1 out for ?page=2 — is treated as that document and refetched on Back/Forward. Use syncHistoryEntry() for app-owned entries to avoid it.
Prefetch strategies
The router can warm up the browser cache before the user clicks a link. Each link is opted in via the data-zfb-prefetch attribute.
Per-link attribute
<!-- Prefetch when the link enters the viewport -->
<a href="/docs/api" data-zfb-prefetch="viewport">API docs</a>
<!-- Prefetch on pointer hover (default when prefetchAll is true) -->
<a href="/blog" data-zfb-prefetch="hover">Blog</a>
<!-- Prefetch on touchstart / mousedown (just before the click) -->
<a href="/pricing" data-zfb-prefetch="tap">Pricing</a>
<!-- Prefetch immediately after DOMContentLoaded (idle callback) -->
<a href="/contact" data-zfb-prefetch="load">Contact</a>
<!-- Opt this link out even when prefetchAll is true -->
<a href="/heavy-page" data-zfb-prefetch="false">Heavy page</a>Available strategies
| Strategy | Trigger |
|---|---|
"hover" | pointerenter / focusin (keyboard focus) — both with idle-callback delay; cancelled on pointerleave / focusout |
"viewport" | IntersectionObserver — fires when the link scrolls into view |
"tap" | touchstart / mousedown — fires just before the click event |
"load" | requestIdleCallback after DOMContentLoaded |
Keyboard-focus prefetch mirrors hover: tabbing onto a "hover"-strategy link queues the same idle-callback prefetch a pointer hover would, and moving focus away cancels it before it fires — so keyboard-only navigation gets the same prefetch benefit as pointer hover, with no separate data-zfb-prefetch value needed for it.
The prefetch module uses <link rel="prefetch"> where supported and falls back to fetch() with priority: "low". Prefetches are deduplicated per URL; slow connections (Save-Data header, 2G/slow-2G) are skipped unless the caller passes ignoreSlowConnection: true.
prefetchAll
Setting prefetchAll on <ClientRouter /> opts every same-origin link that does not carry data-zfb-prefetch="false" into the "hover" strategy:
<ClientRouter prefetchAll />This is equivalent to adding data-zfb-prefetch="hover" to every link on every page.
Disabling prefetch site-wide
Set prefetch.disabled in zfb.config.ts to suppress the prefetch wiring for the entire site:
// zfb.config.ts
import { defineConfig } from "zfb/config";
export default defineConfig({
prefetch: { disabled: true },
});When this flag is set, the bundler emits a <meta name="zfb-prefetch-disabled" content="true"> tag on every page and the prefetch module becomes a no-op at runtime. See defineConfig for the full config reference.
Imperative prefetch API
For triggers data-zfb-prefetch can't express — warming a link right before it's inserted programmatically, or firing a prefetch from a custom event — call prefetch() directly:
import { prefetch } from "@takazudo/zfb-runtime/client-router";
prefetch("/docs/api", { ignoreSlowConnection: true });function prefetch(url: string, opts?: PrefetchOptions): void;
type PrefetchOptions = {
ignoreSlowConnection?: boolean; // bypass the Save-Data / 2G skip described above
with?: "link" | "fetch"; // force a transport instead of feature-detecting <link rel="prefetch"> support
};prefetch() resolves url against the current origin (a cross-origin href is silently skipped) and is idempotent per URL — a second call for an already-prefetched or in-flight href is a no-op.
If you call the router's init() yourself instead of mounting <ClientRouter /> (see the warning above), wire up the prefetch listeners with the matching prefetchInit(). Unlike the router's init, this one is re-exported from the root @takazudo/zfb-runtime barrel, not just the / subpath:
import { prefetchInit } from "@takazudo/zfb-runtime";
prefetchInit({ defaultStrategy: "viewport" });function prefetchInit(options?: PrefetchInitOptions): void;
type PrefetchInitOptions = {
prefetchAll?: boolean; // same effect as <ClientRouter prefetchAll />
defaultStrategy?: PrefetchStrategy; // strategy prefetchAll opts links into; defaults to "hover"
};Navigation lifecycle events
The router dispatches six custom events on document during each navigation. Listen with document.addEventListener:
document.addEventListener("zfb:before-preparation", (e) => {
// e is a TransitionBeforePreparationEvent
console.log("navigating from", e.from.href, "to", e.to.href);
});One exception: none of these six events fire for a same-page Back/Forward traversal — see Same-page traversal below.
Event reference
| Event name | Cancellable | When it fires |
|---|---|---|
zfb:before-preparation | Yes | Before the next page is fetched. Cancel to abort the navigation and fall back to a full browser load. |
zfb:after-preparation | No | After the next page has been fetched and parsed into event.newDocument. |
zfb:before-swap | No | Just before <head> and <body> are swapped. |
zfb:after-swap | No | Immediately after the DOM swap; the new body is live but scripts have not yet re-executed. |
zfb:page-load | No | After new-page scripts have re-executed and islands have been re-mounted. Equivalent to DOMContentLoaded for the incoming page. |
zfb:navigation-aborted | No | The navigation was cancelled (e.g. e.preventDefault() on zfb:before-preparation, or an in-flight navigation was superseded by a newer one). |
Typed event access
Each event name above is also exported as a TRANSITION_* string constant, and the two events that carry extra properties (zfb:before-preparation, zfb:before-swap) are backed by a concrete class plus a type guard — so a listener can narrow Event to the typed event without a manual cast:
import {
TRANSITION_BEFORE_PREPARATION,
isTransitionBeforePreparationEvent,
} from "@takazudo/zfb-runtime/client-router";
document.addEventListener(TRANSITION_BEFORE_PREPARATION, (e) => {
if (!isTransitionBeforePreparationEvent(e)) return;
console.log("navigating from", e.from.href, "to", e.to.href); // fully typed
});Available exports: the six TRANSITION_* constants (one per row above), the TransitionBeforePreparationEvent and TransitionBeforeSwapEvent classes, and their matching isTransitionBeforePreparationEvent / isTransitionBeforeSwapEvent type guards.
zfb:before-preparation event properties
TransitionBeforePreparationEvent extends Event with:
event.from // URL — current page URL
event.to // URL — destination URL (writable)
event.direction // "forward" | "back"
event.navigationType // "push" | "replace" | "traverse"
event.sourceElement // Element | undefined — the <a> or <form> that triggered navigation
event.info // any — value passed to navigate() options.info
event.newDocument // Document — starts as the current page; overwritten with the fetched document after event.loader() resolves (writable)
event.signal // AbortSignal — aborted if a newer navigation supersedes this one
event.formData // FormData | undefined — set for POST form submissions
event.loader // () => Promise<void> — call to execute the default fetch; replace to use a custom loaderCall event.preventDefault() to stop the navigation; the router will then trigger a full browser load to the destination.
zfb:before-swap event properties
TransitionBeforeSwapEvent extends the same base and additionally exposes:
event.viewTransition // ViewTransition — the active View Transition object
event.swap // () => void — call to execute the default head/body swap; replace to implement a custom swapExample: compose a custom swap from swapFunctions
event.swap defaults to the router's own swap(), which is itself just five granular steps called in sequence. Both swap and the swapFunctions object bundling those steps (deselectScripts, swapRootAttributes, swapHeadElements, swapBodyElement, saveFocus) are importable, so event.swap can be reassigned to a variant that reuses some of the same steps:
import { swapFunctions } from "@takazudo/zfb-runtime/client-router";
document.addEventListener("zfb:before-swap", (e) => {
// e is a TransitionBeforeSwapEvent — reuse every default step except the head swap.
e.swap = () => {
swapFunctions.deselectScripts(e.newDocument);
swapFunctions.swapRootAttributes(e.newDocument);
const restoreFocus = swapFunctions.saveFocus();
swapFunctions.swapBodyElement(e.newDocument.body, document.body);
restoreFocus();
};
});Example: run code after every SPA navigation
document.addEventListener("zfb:page-load", () => {
// Re-initialise analytics, syntax highlighters, etc.
initHighlighter();
});Example: intercept and redirect a navigation
document.addEventListener("zfb:before-preparation", (e) => {
if (e.to.pathname.startsWith("/beta/")) {
e.to = new URL(e.to.href.replace("/beta/", "/stable/"));
}
});Same-page traversal
A Back or Forward press that lands on a history entry sharing the current page's pathname and search — only the hash or router-tracked state differs — is served entirely from the live DOM. The router does not fetch, does not swap <head>/<body>, and does not remount anything: it restores the entry's tracked scroll position and leaves the page exactly as it is. Island/client state (open dropdowns, form input, video playback, etc.) survives untouched.
This is the mechanism behind the hash-modal example above: pressing Back after syncHistoryEntry() is handled with no round trip to the server and no disruption to the rest of the page — the modal component itself still has to react to the URL change (via popstate/hashchange) to close. (The router already skipped the fetch for a plain same-page #anchor link before a traversal was even involved — this fast-path extends the same idea to Back/Forward.)
Lifecycle events and the route announcer are skipped
Because nothing is fetched or swapped, none of the six zfb:* lifecycle events — from zfb:before-preparation through zfb:page-load — fire for a same-page traversal. The ARIA route announcer (the offscreen .zfb-route-announcer element that speaks the new page's title to screen readers after every SPA navigation) is not updated either, since it's only written as part of the fetch/swap flow.
If you hook lifecycle events to run per-navigation side effects (analytics, re-initializing a widget, etc.), those hooks will not run for a Back/Forward press between two same-page history entries.
Opting back in with traverseRefetch
Static and prerendered pages don't need to know about any of this — their content can't change between visits, so serving the fast-path from the live DOM is always correct. A per-request SSR page (export const prerender = false) is different: its server-rendered output can legitimately differ between two visits to the same URL — session-dependent markup, a CSRF token, live data — so pinning the first-render content on every subsequent traversal would be wrong.
Opt such a page back into the fetch with the traverseRefetch prop:
<ClientRouter traverseRefetch />This emits <meta name="zfb-traverse-refetch" content="true">, which the router checks on the current (target) page before taking the fast-path — when present, a same-page traversal falls through to a normal fetch and swap instead. Mount <ClientRouter /> with the same traverseRefetch value on every page that participates in SPA navigation, since the meta is read from whichever page is currently live.
Back/Forward across bfcache
Putting the whole picture together, a Back or Forward press is served one of three ways:
Same-page fast-path — described above: the destination shares
pathname/searchwith the current page, so the router serves it entirely from the live DOM. No fetch, no swap, no remount; island state survives untouched.SPA cross-page swap — the destination is a different page (or the current page opted out via
traverseRefetch), so the router fetches and swaps like a link click:<head>/<body>are replaced and islands remount. This includes Back from asyncHistoryEntry()entry with another pathname and a rawnull-state entry that names another document (see Raw History entries and ownership); an app's own raw same-pathname query entries are left to the app.Browser bfcache restore — Safari and other WebKit-based browsers can serve a Back/Forward navigation straight from the browser's own back/forward cache instead of re-running any JavaScript on the page, including this router. That restore fires the standard
pageshowevent withevent.persisted: true; the router listens for it and re-syncs its internal bookkeeping — the tracked history index, the "navigated from" URL, and the scroll position — from the restoredhistory.state, so the next Back/Forward press is still computed correctly. This is a browser-level restore, not a router mechanism, so — like case 1 — none of the sixzfb:*lifecycle events fire for it.
View Transitions
When the browser supports document.startViewTransition, eligible cross-page swaps use a native View Transition. Naming a region creates independently styled snapshots; view-transition-name alone does not remove the browser's default fade. Not every navigation takes this path: same-page traversal can avoid a swap, a browser-provided visual traversal uses the simulated swap path, and navigations carrying formData use the simulated transition for history safety.
Three independent controls
| Choice | Mechanism | What it controls |
|---|---|---|
| Visual identity | CSS view-transition-name | Which old/new snapshots represent the same region. Names must be unique within each rendered page. |
| DOM and island persistence | data-zfb-transition-persist, <Island persist> | Whether a node or live island instance survives. Changed props still follow the existing remount contract. |
| Animation policy | Native pseudo-element CSS | Native defaults, authored motion, or an explicitly stationary region. |
A replaced static element or remounted island can remain visually stationary. Conversely, preserving a DOM node does not remove it from an animated snapshot or suppress animation. Keep incoming content fresh; persistProps is not an animation fix.
Stationary regions with animated content
Apply this example-owned CSS on every participating page. Keep the region's layout and dimensions stable across navigation:
header { view-transition-name: global-region; }
main { view-transition-name: content-region; }
/* Disable geometry interpolation AND both snapshot animations. */
::view-transition-group(global-region),
::view-transition-old(global-region),
::view-transition-new(global-region) {
animation: none;
mix-blend-mode: normal;
}
/* Consumer-owned example motion; zfb supplies no motion preset. */
::view-transition-old(content-region) {
animation: content-out 300ms ease both;
}
::view-transition-new(content-region) {
animation: content-in 300ms ease both;
}
@keyframes content-out {
to { opacity: 0; transform: translateX(24px); }
}
@keyframes content-in {
from { opacity: 0; transform: translateX(-24px); }
}The group normally interpolates size and position; disabling only old/new fades does not disable that interpolation. The old/new rules also cover one-sided entry and exit. This recipe assumes opaque region backgrounds. When both snapshots exist, normal blending lets opaque incoming pixels cover the outgoing image immediately; transparent pixels can still reveal stale outgoing content. Consumers with transparent regions must design and verify their snapshot compositing separately. When only the old snapshot exists, it remains stationary until the transition ends, then disappears.
view-transition-name: none means “no separate named snapshot,” not “no animation.” That content remains in an ancestor or root snapshot and follows its motion. Give protected regions their own names, and account for any consumer-authored ancestor/group animation or nested transition grouping. Suppressing interpolation does not promise fixed screen coordinates when the destination layout changes.
Context-keyed secondary regions
Consumers decide what constitutes the same context. For example, render view-transition-name: context-alpha on both pages within one section, and context-beta in another. A matching name produces paired old/new images; changed names produce one-sided exit/entry images. A consumer can keep paired images stationary and explicitly animate only the one-sided case:
::view-transition-group(context-alpha),
::view-transition-group(context-beta),
::view-transition-old(context-alpha),
::view-transition-new(context-alpha),
::view-transition-old(context-beta),
::view-transition-new(context-beta) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(context-alpha):only-child,
::view-transition-old(context-beta):only-child {
animation: content-out 300ms ease both;
}
::view-transition-new(context-alpha):only-child,
::view-transition-new(context-beta):only-child {
animation: content-in 300ms ease both;
}These are example names, not reserved zfb regions or category rules. DOM persistence keys are a separate choice. Use unique, valid CSS identifiers when generating names.
Honor reduced-motion preferences in the authored animation policy, including rules with more specific named selectors:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}Observing the active transition
Use the existing zfb:before-swap event's viewTransition. Register asynchronous observers and return; the native update callback must finish before ready can fulfill. Do not await ready from an overridden event.swap or other work that the update callback waits for.
import { isTransitionBeforeSwapEvent } from "@takazudo/zfb-runtime/client-router";
document.addEventListener("zfb:before-swap", (event) => {
if (!isTransitionBeforeSwapEvent(event)) return;
const transition = event.viewTransition;
void transition.ready.then(
() => console.log("Snapshot animations ready"),
(error) => console.log("Visual transition skipped or unavailable", error),
);
void transition.updateCallbackDone.then(
() => console.log("DOM update completed"),
(error) => console.error("DOM update failed", error),
);
void transition.finished.then(
() => console.log("Transition finished"),
(error) => console.error("Transition failed", error),
);
});A rejected native ready does not by itself mean the DOM update failed: a skipped animation can still fulfill updateCallbackDone and finished. The simulated path exposes similar promises, but has no native snapshot tree and different timing; its ready follows the DOM update. zfb:page-load is not proof that native animation has finished.
The regression example in tests/ covers static replacement, an island inside a persisted ancestor, and public <Island persist> with fresh props. Its browser spec inspects existing native group/old/new layers and captures active frames with animations allowed, with animated-content and deliberately broken-rule controls. check-native-regions-packed.mjs repeats that proof using isolated local tarballs and public exports. These checks establish the recipe without a new runtime API; they do not establish a historical version regression or validate any consumer's complete application.
Feature detection
The router exposes two helpers:
import {
supportsViewTransitions,
transitionEnabledOnThisPage,
} from "@takazudo/zfb-runtime/client-router";
// true if the browser has document.startViewTransition
console.log(supportsViewTransitions);
// true if the current page has <ClientRouter /> mounted
console.log(transitionEnabledOnThisPage());When supportsViewTransitions is false, the fallback prop controls the degraded experience (see Fallback modes).
Persisting elements across navigations
Add data-zfb-transition-persist="<id>" to an element you want to keep alive across soft navigations instead of discarding and re-creating it. Both the old and new body must carry the attribute with the same id:
<!-- Keeps this video player alive across page navigations -->
<video data-zfb-transition-persist="promo-video" src="/intro.mp4" autoplay />The router lifts persisted elements to <html> before the body swap (using the zero-detachment moveBefore() API on Chrome 133+ or a fallback appendChild) and reattaches them to their matching target in the new body. This is the mechanism Astro uses to keep <canvas> and <video> alive without losing WebGL context or playback state.
Persisting an island with <Island>
For islands, use the SDK's persist prop. The SDK writes the persistence key onto its owned wrapper alongside the component, build, transport, protocol, and props metadata the client runtime checks:
// A layout component — the sidebar tree keeps its expand/collapse state
// and its scroll position across every SPA navigation.
<Island persist="sidebar-tree">
<SidebarTree {...props} />
</Island>Use the same nonempty persist key on each page where the island should survive. Keys must be unique within each document; duplicate keys make the navigation fail before the DOM swap. When the key matches, the wrapper and live component instance survive, so state such as an open menu or a focused input is not reset.
By default, navigation refreshes the persisted island's props from the incoming page. Identical props keep the current instance. Changed props dispose it once and mount a fresh instance with the incoming props, so the island does not keep showing stale page data. Any internal state held by the old instance is lost during that remount.
For an island still waiting on when="visible", "idle", or "media", changed props do not start its first mount early. When its condition fires, the runtime renders the latest props over the retained DOM. A live island with changed props still remounts immediately.
Set persistProps to keep the current props and state when the key matches:
<Island persist="sidebar-tree" persistProps>
<SidebarTree {...props} />
</Island>Use persistProps for islands with page-invariant props, such as a site-wide sidebar. It is a boolean SDK option and defaults to false. A changed component or build identity still recreates the root, even when persistProps is enabled. Keeping old props on a page-specific table of contents or "last updated" widget can leave it showing the previous page's data.
Islands inside a persisted element
An island nested anywhere inside a data-zfb-transition-persist element keeps its live component instance and internal state across a navigation when the incoming page has the same island under that element. The islands must have the same component, root kind, transport, protocol, and build. Their data-props must also be identical unless data-zfb-transition-persist-props opts out of the props refresh.
If any of those values differ, the old island is cleaned up and a fresh instance is created in render mode. It uses the incoming props when props refresh is enabled; with data-zfb-transition-persist-props opting out, it keeps the old props. Its internal state is lost. If the incoming page has no matching island under the persisted element, the old island is cleaned up and removed. An island present only on the incoming page is not added: the persisted element keeps its existing DOM rather than adopting the incoming markup.
When several islands of the same component sit under one persisted element, they are matched in document order, so changing their order can associate an old instance with a different incoming island. A persisted element nested inside another persisted element travels with its parent.
<ViewTransitions /> — deprecated
@takazudo/zfb-runtime also exports <ViewTransitions />. This component is a typed no-op kept for backward compatibility only; it renders nothing and registers nothing.
// Old code — compiles but does nothing
import { ViewTransitions } from "@takazudo/zfb-runtime";
<ViewTransitions />
// Use this instead
import { ClientRouter } from "@takazudo/zfb-runtime";
<ClientRouter fallback="animate" />Cross-document (MPA) View Transitions — where clicking a link triggers a native browser animation without any JavaScript router — are enabled via the CSS @view-transition at-rule on both pages, not via any component:
/* global.css — applies to every page */
@view-transition {
navigation: auto;
}See MDN: @view-transition for browser support and animation customisation options.