Client-Side Navigation
The @ecopages/browser-router package enables Single Page Application (SPA) navigation behavior in your Ecopages application. It intercepts link clicks, fetches the next page via fetch, and uses morphdom to efficiently diff and update only the parts of the DOM that changed.
This preserves element state (like audio players, web component internals, or form values) and enables native View Transitions.
Installation
Please run the following command to install the package:
npm install @ecopages/browser-routerpnpm add @ecopages/browser-routerbun add @ecopages/browser-routerSetup
To enable client-side routing, initialize the router in a script that every page loads: a global Layout script (for example src/layouts/base-layout.script.ts), or for HTML Pages, a module referenced from src/includes/html.html.
The simplest approach is to use createRouter, which creates and starts the router in one call:
import { createRouter } from '@ecopages/browser-router/client';
const router = createRouter({
viewTransitions: true,
});For manual control over when the router starts and stops, use the EcoRouter class directly:
import { EcoRouter } from '@ecopages/browser-router/client';
const router = new EcoRouter({
viewTransitions: true,
});
router.start();Configuration
You can customize the router behavior by passing an options object:
| Option | Type | Default | Description |
|---|---|---|---|
linkSelector | string | 'a[href]' | Selector for links to intercept. |
documentElementAttributesToSync | string[] | ['lang', 'dir', 'data-eco-document-owner'] | <html> attributes to sync from the incoming document. Attributes not listed here are preserved on the live page. |
persistAttribute | string | 'data-eco-persist' | Attribute to mark elements that should persist across navigations. |
reloadAttribute | string | 'data-eco-reload' | Attribute (on link or element) to force a full hard reload. |
scrollBehavior | 'top' | 'preserve' | 'auto' | 'top' | Controls scroll behavior after navigation. |
smoothScroll | boolean | false | Enables smooth scrolling. |
viewTransitions | boolean | true | Enables View Transitions API. Disables UA root crossfade by default. false disables. |
prefetch | PrefetchConfig | false | Object | Configuration for prefetching behavior. |
reloadDocument | (href: string) => void | (href) => window.location.assign(href) | Loads a URL as a full document when the router hands a navigation back to the browser. |
View Transitions
The browser router supports the native View Transitions API.
When enabled (default), the router opts the document out of the UA root view-transition group so dark themes do not flash lighter. startViewTransition runs only when the current or incoming page has matching data-view-transition elements for shared-element morphs.
Visual check: dark background → SPA navigate → no lighter flash with defaults.
To create a shared element transition, simply add the data-view-transition attribute to matching elements on different pages:
<!-- Page 1 -->
<div data-view-transition="hero-1">...</div>
<!-- Page 2 -->
<div data-view-transition="hero-1">...</div>The router automatically handles the transition names and ensures a clean "morph" animation (hiding the old snapshot to prevent ghosting) by default.
Directives
You can control the transition behavior using data-view-transition-animate:
| Value | Description |
|---|---|
morph | (Default) Disables the cross-fade animation, resulting in a clean geometric morph. Ideal for shared element transitions to prevent "ghosting". |
fade | Uses the standard browser cross-fade animation. Useful if you specifically want the old and new elements to fade into each other. |
<!-- Example: Opt-out of the default morph -->
<div data-view-transition="hero-1" data-view-transition-animate="fade">...</div>Duration
You can also control the speed of a specific transition using data-view-transition-duration:
<div data-view-transition="hero-1" data-view-transition-duration="500ms">...</div>Update History
updateHistory (default true) controls whether each client-side navigation pushes a new entry to the browser history.
Example with Options
const router = createRouter({
viewTransitions: true,
scrollBehavior: 'preserve',
linkSelector: 'a:not([data-no-route])',
documentElementAttributesToSync: ['lang', 'dir', 'data-theme'],
});Use documentElementAttributesToSync to formalize ownership of root <html> attributes. The default keeps navigation metadata in sync while preserving client-managed state such as theme classes or data attributes.
If you need lower-level control without overriding the router pipeline, import the document sync helpers directly:
import {
createRouter,
DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC,
syncDocumentElementAttributes,
} from '@ecopages/browser-router';
const router = createRouter();
document.addEventListener('eco:before-swap', (event) => {
syncDocumentElementAttributes(document, event.detail.newDocument, [
...DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC,
'data-theme',
]);
});Features
Persistence
Elements marked with data-eco-persist are never recreated during navigation. morphdom recognizes these elements by their persist ID and skips updating them entirely, preserving their internal state (event listeners, web component state, form values, audio playback, etc.).
Add the data-eco-persist attribute with a unique ID:
<audio controls src="/music.mp3" data-eco-persist="global-player" />Development saves
When a page, layout, include or view is saved under ecopages dev, HMR asks the router to reload the current page in place. The router compares each element's server-rendered markup before and after the save and keeps every element whose markup did not change, so custom elements keep their state and stay connected, and classes or attributes added at runtime stay. Changed custom elements are replaced. Elements are not refreshed for data-eco-rerun scripts on this reload, so a rerun script must register its handlers idempotently. Elements pair in tree order, so inserting or removing an earlier sibling with the same markup moves state to its neighbour. See HMR.
Script Re-execution
After a navigation:
- A head script runs only if the previous page's head did not have it.
- Body scripts run on every navigation, as in Turbo, because the body is new content even when the previous page had the same script. That covers inline scripts and external classic scripts. An external module script (
<script type="module" src>) runs only once per URL in a tab, because the browser evaluates each module URL once; adddata-eco-rerunto run it again. - A classic script's top-level
const,letandclassdeclarations live in the page's global scope, so when an external classic body script runs again they are declared twice and throwSyntaxError: Identifier '…' has already been declared. Write such a script without them (usevaror functions, or wrap the code in a block or an IIFE), or make it a module script, which runs once per URL, and listen foreco:page-loadfor code that must run on each visit. - A script inside an element marked
data-eco-persistis kept as it was and does not run again; use that for a body script that must run once. A body script that addswindowordocumentlisteners adds them again on each navigation, so make it idempotent or persist it. - Scripts run in one sequence: new head scripts, then
data-eco-rerunscripts, then body scripts, each group in document order. - Each one waits until earlier external classic scripts (without
async,defer, ornomodule) have loaded, as during page parsing, so an inline script that uses a library the page loads runs after it. - External
deferand module scripts also keep insertion order; only scripts markedasyncrun as soon as they load. - If the next navigation swaps the page while a script is still loading, the rest of the previous sequence does not run.
Some head scripts, like hydration logic or analytics, need to run on every page load instead. To force a script to re-execute on navigation, add data-eco-rerun="true":
<script type="module" data-eco-rerun="true">
// This runs on initial load AND every navigation
initAnalytics(window.location.pathname);
</script>Optionally, add data-eco-script-id="unique-id" to identify the script. When present, the router removes the previous instance before re-adding the new one, preventing accumulation of duplicate script tags in the head.
<script type="module" data-eco-rerun="true" data-eco-script-id="analytics-tracker">
// This runs on initial load AND every navigation
initAnalytics(window.location.pathname);
</script>How it works:
- Any script with
data-eco-rerunis re-executed on every navigation. - If
data-eco-script-idis also present, the previous instance is removed first to avoid accumulating duplicate tags.
Prefetching
The router includes a smart prefetching system to speed up navigation. By default, it uses the intent strategy, which prefetches pages when the user hovers over a link or when a link enters the viewport.
Configuration
You can configure prefetching in the createRouter options:
const router = createRouter({
prefetch: {
// 'intent' (default) | 'hover' | 'viewport'
strategy: 'intent',
// Delay in ms before prefetching on hover (default: 65)
delay: 65,
// Attribute to disable prefetching on specific links
noPrefetchAttribute: 'data-eco-no-prefetch',
// Whether to respect data-saver mode (default: true)
respectDataSaver: true,
},
});To disable prefetching entirely, set prefetch: false.
Strategies
| Strategy | Description |
|---|---|
intent | (Recommended) Prefetches on hover (with delay) and acts as a fallback for viewport. Balances speed and bandwidth. |
hover | Prefetches only when the user hovers over a link. |
viewport | Prefetches links as soon as they enter the viewport using IntersectionObserver. |
Per-Link Control
You can override prefetching behavior on individual links:
<!-- Disable prefetching for this link -->
<a href="/heavy-page" data-eco-no-prefetch>Heavy Page</a>
<!-- Force eager prefetching (immediately on load) -->
<a href="/next-page" data-eco-prefetch="eager">Next Page</a>
<!-- Use a specific strategy for this link -->
<a href="/about" data-eco-prefetch="hover" data-eco-prefetch-delay="200">About</a>View Transitions
If viewTransitions is enabled (default true), the router opts the document out of the UA root group and only uses startViewTransition when pages define data-view-transition shared elements. Set false to disable. Optional presets: @import 'ecopages/css/view-transitions.css' and data-eco-transition on elements.
Using Included Styles
The ecopages npm package includes default view transition animations. Import them in your CSS:
@import 'ecopages/css/view-transitions.css';Then use the data-eco-transition attribute on elements to apply specific animations:
| Value | Description |
|---|---|
fade | Fades the element in and out during transition. |
slide | Slides the element horizontally during transition. |
zoom | Scales the element in and out during transition. |
slide-up | Slides the element vertically upward during transition. |
slide-down | Slides the element vertically downward during transition. |
<main data-eco-transition="slide">
<!-- Content slides in -->
</main>Lifecycle Events
The router emits lifecycle custom events on the document object, allowing you to hook into the navigation process.
| Event | Detail | Description |
|---|---|---|
eco:before-swap | { url, direction, newDocument, reload } | Fired after fetching new content but before updating the DOM. Use newDocument to inspect upcoming content. Call reload() to force a hard reload. |
eco:after-swap | { url, direction } | Fired after the DOM has been updated. Useful for re-initializing scripts or analytics. |
eco:page-load | { url, direction } | Fired on both initial load and subsequent navigations. |
Example: Re-initializing Scripts
document.addEventListener('eco:after-swap', () => {
console.log('Page updated!');
// Re-run any page-specific logic here
});Client-Only Scripts: Scripts listening to client navigation events (
eco:after-swap,eco:page-load) should be registered as client scripts (e.g., usingscripts: ['./component.script.ts']) or guarded byisServerfrom@ecopages/radiant/is-serverwhen used in Radiant projects wheredocumentis defined during SSR.
Example: Navigation-Aware Components
For components that need to react to URL changes (like updating active states), listen for eco:page-load:
document.addEventListener('eco:page-load', () => {
// Update active states, re-highlight nav links, etc.
highlightActiveLink();
});