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 (Firefox and older Safari at the time of writing):
"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.
{/* No animation, not even a simulated one */}
<ClientRouter fallback="none" />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.
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.
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 { syncHistoryEntry } from "@takazudo/zfb-runtime/client-router";
function PhotoModal({ id, onClose }: { id: string; onClose: () => void }) {
useEffect(() => {
const onPopState = () => {
if (location.hash !== `#photo-${id}`) onClose();
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, [id, onClose]);
return <div className="modal">{/* 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.
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.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 (Chrome 111+, Edge 111+), the router wraps every swap in a native View Transition. The transition plays the browser's default cross-fade unless you add CSS view-transition-name declarations.
CSS opt-in
Name the elements you want to animate independently:
/* Shared element transition — the header animates from its old position to its new one */
header {
view-transition-name: site-header;
}
/* Page content fades/slides as a named region */
main {
view-transition-name: page-content;
}Standard ::view-transition-old and ::view-transition-new pseudo-elements are available for custom animation keyframes.
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's component state with data-zfb-transition-persist-props
The same data-zfb-transition-persist attribute works on an island wrapper (a [data-zfb-island] marker), not just plain elements like <video>. When the marker survives the swap, its live component instance survives too — internal state (open/collapsed, scroll offset inside a nested list, a focused input) is not reset, because the framework never unmounted it.
// A layout component — the sidebar tree keeps its expand/collapse state
// and its scroll position across every SPA navigation.
<div data-zfb-island="SidebarTree" data-zfb-transition-persist="sidebar-tree" data-props={props}>
<SidebarTree {...props} />
</div>By default, a swap still refreshes the persisted island's props to match the incoming page (the router always knows what the new page would have rendered, even though it discards that markup). If the refreshed props are identical to what the live instance already has, nothing else happens — full continuity, silently. If they differ, the island is unmounted and remounted fresh with the new props, so it never gets stuck showing a stale render from the previous page — but any internal state not derived from props (like an open/closed toggle) is lost in that remount, same as an ordinary (non-persisted) island would be.
Set data-zfb-transition-persist-props to any value other than "false" (conventionally "true") to opt out of that props refresh entirely and keep the island's current props (and therefore its current rendered state) exactly as they were, regardless of what the incoming page's props would have been:
<div
data-zfb-island="SidebarTree"
data-zfb-transition-persist="sidebar-tree"
data-zfb-transition-persist-props="true"
data-props={props}
>
<SidebarTree {...props} />
</div>Use this for chrome-zone islands whose props are effectively invariant across pages (a sidebar, a header) where you want to guarantee zero re-render, ever — not for content-area islands whose props are expected to change with the page (a table of contents, a "last updated" widget), since opting out means the island would keep showing the previous page's data after a navigation whose incoming props actually differed.
Attribute default is the opposite of what the name suggests
data-zfb-transition-persist-props absent, or explicitly "false", means props ARE refreshed (the common case: state persists, but data stays current). Any other value opts OUT of the refresh — "true" by convention, but the check is only ever against the literal string "false". This mirrors Astro's data-astro-transition-persist-props behavior exactly.
<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.