Smooth Navigation
CSS View Transitions + hover-based link prefetch, auto-injected into every SSR response. Closes the perceived-latency gap against Next.js / Astro / SvelteKit without requiring a client-side SPA runtime.
On this page
- What gets injected
- Defaults
- Global opt-out (mandu.config.ts)
- Per-link opt-out (data-no-prefetch)
- How it works
- View Transitions (@view-transition)
- Hover prefetch
- Known limits
- Link prefetch ≠ SPA navigation
- Prefetch doesn't compose with CSP script-src
- View transitions and fixed-position elements
- Performance characteristics
- 🤖 Agent Prompt
- Related
- For Agents
Smooth Navigation
Out of the box, Mandu makes cross-document navigation feel native with two tiny additions to every SSR response:
- CSS View Transitions — supported browsers (Chrome/Edge ≥ 111, Safari 18.2+) play a default crossfade between the outgoing and incoming pages. Non-supporting browsers (Firefox, older Safari) silently ignore the at-rule.
- Hover prefetch — a ~500-byte inline script listens for
mouseoveron same-origin<a>anchors (href starting with/) and issues a one-shot<link rel="prefetch" as="document">. The browser cache services the follow-up click with no extra network round trip.
Together they close most of the perceived gap against Next.js, Astro, and SvelteKit defaults — without requiring a client-side SPA runtime.
What gets injected
For every SSR response, Mandu adds this to the top of <head> (after
your CSS link, before any user-provided head content):
<style>@view-transition{navigation:auto}</style>
<script>(function(){var s=new WeakSet();document.addEventListener("mouseover",...);})();</script>
Both blocks are inert unless the browser opts in:
| Feature | Chrome/Edge 111+ | Safari 18.2+ | Firefox | Older Safari |
|---|---|---|---|---|
@view-transition |
Crossfade | Crossfade | Ignored | Ignored |
<link rel=prefetch> |
HTTP cache | HTTP cache | HTTP cache | HTTP cache |
No build step is required — the tags come for free with mandu dev
and mandu start.
Defaults
transitions: enabled (defaulttrue)prefetch: enabled (defaulttrue)
You can opt out at two granularities.
Global opt-out (mandu.config.ts)
// mandu.config.ts
import type { ManduConfig } from "@mandujs/core";
export default {
// Disable view transitions (e.g. if your app ships a custom
// navigation animation that conflicts)
transitions: false,
// Disable hover prefetch (e.g. if your server is latency-bound
// or you want to keep bandwidth tight on mobile)
prefetch: false,
} satisfies ManduConfig;
Either flag can be toggled independently.
Per-link opt-out (data-no-prefetch)
For most apps the hover prefetch is a net win, but a specific link might point to a large document, a download, or an expensive server-rendered page you don't want pre-warmed speculatively:
// Never prefetch this link — even on hover
<a href="/reports/annual-2024.pdf" data-no-prefetch>
Annual Report (12 MB)
</a>
The helper honors three additional escape hatches automatically:
<a download>— skipped (downloads shouldn't be cached)<a target="_blank">— skipped (opens in a new tab)<a href="https://...">— skipped (same-origin only,href^="/")
How it works
View Transitions (@view-transition)
This is a CSS spec, not JavaScript. The browser sees the
@view-transition { navigation: auto } at-rule during SSR hydration
and, on the next cross-document (i.e. full-reload) navigation,
takes a snapshot of the current page, renders the next page, and
crossfades between them.
Because the behavior is entirely in the browser, there is no runtime cost — no JS to evaluate, no DOM observers, no MutationObserver juggling. The at-rule itself is ~70 bytes in the HTML.
No per-route customization is currently supported. If you need
per-route animations or custom easing, stay tuned — a
transitions.perRoute sub-block is on the roadmap.
Hover prefetch
The helper is a self-contained IIFE. It installs exactly one
document-level capture-phase mouseover listener and stamps each
anchor it has seen into a WeakSet, so the overhead per hover event
is O(1).
When it finds an eligible anchor it creates
<link rel="prefetch" as="document"> and appends it to <head>.
The browser then issues a low-priority background GET for the target
URL. If you click the link within a few seconds, the HTTP cache
serves the navigation from memory.
The helper is injected inline (not as an external bundle) because:
- At ~500 bytes compressed, an extra HTTP round trip would cost more than the helper saves.
- Keeping it in
<head>means it runs during parse, before the first paint — so hovers during initial page render are caught. - No module graph change — zero impact on the bundler's caching invariants.
Known limits
Link prefetch ≠ SPA navigation
Prefetch makes the follow-up GET instant, but clicking still triggers a full document reload. You'll still see a brief white flash in browsers that don't implement View Transitions, and your scroll position / focus ring will reset on navigate.
If you need true SPA-style navigation (persistent scroll, component state across routes, zero flash even on Firefox), you have two options today:
- Wrap navigable links in
<Link>— Mandu's@mandujs/core/clientLinkcomponent performs an explicit client-side fetch and uses the built-in router to swap the route tree without a full reload. This is currently opt-in per link. - Mark routes as islands — hydrated routes participate in client-side router transitions automatically.
A future release will reverse this default (opt-in → opt-out), making SPA navigation the baseline. That change is tracked as a follow-up to issue #192 — it's a breaking change that will land in a dedicated release note when scheduled.
Prefetch doesn't compose with CSP script-src
The prefetch helper is an inline script, so if you ship a strict Content-Security-Policy you need either:
'unsafe-inline'(not recommended), or- A nonce that Mandu's SSR layer attaches (currently only the Fast Refresh dev preamble receives a nonce; prefetch CSP wiring is tracked as a follow-up).
If CSP conflicts are blocking you, the safe workaround today is to
set prefetch: false in mandu.config.ts and use Mandu's explicit
prefetch() API from @mandujs/core/client in code.
View transitions and fixed-position elements
Browsers without a view-transition-name on fixed-position elements
(headers, sidebars) will crossfade them along with the rest of the
page — which can look janky. If you notice a header flicker:
/* app/globals.css or a layout stylesheet */
header {
view-transition-name: site-header;
}
Mandu does not emit these rules automatically — they're application-specific. See the MDN docs on view-transition-name for the full taxonomy.
Performance characteristics
Measured on a 1-page "Hello World" SSR response (dev mode, Bun 1.3.12, Windows 10 + local Chrome 128):
| Metric | Before #192 | After #192 | Delta |
|---|---|---|---|
| HTML response bytes | 1 486 | 2 041 | +555 B |
| HTML response bytes (gzip) | 748 | 1 011 | +263 B |
| TTFB | 4 ms | 4 ms | ±0 ms |
| First hover → prefetch fire | n/a | ~2 ms | new |
| Prefetch → cache hit window | n/a | ~20 s default (browser) | new |
The +555 B uncompressed cost is paid once per SSR response. In
production with gzip/brotli the effective overhead is under 300 bytes
— a rounding error next to a typical index.html that already ships
multiple KB of meta tags.
🤖 Agent Prompt
Apply the guidance from the Mandu docs page at https://mandujs.com/docs/architect/smooth-navigation to my project.
Summary of the page:
Smooth navigation adds two things to every SSR response: (1) `<style>@view-transition{navigation:auto}</style>` for supported browsers, and (2) a ~500-byte inline hover-prefetch IIFE that injects `<link rel=prefetch as=document>` on same-origin anchor hover. Opt out globally via `transitions: false` / `prefetch: false` in `mandu.config.ts`; per-link via `data-no-prefetch`.
Required invariants — must hold after your changes:
- Both `transitions` and `prefetch` default to enabled (`true`)
- View Transitions is a CSS at-rule — zero JS runtime cost, ~70 bytes HTML overhead
- Hover prefetch is a ~500-byte inline IIFE in `<head>` — CSP `unsafe-inline` or a future nonce required for strict CSP
- Prefetch helper only acts on same-origin anchors (`href^='/'`) that are not `<a download>`, not `target=_blank`, and not marked `data-no-prefetch`
- Prefetch ≠ SPA navigation — click still triggers a full document reload; use `<Link>` from `@mandujs/core/client` for SPA-style routing
Then:
1. Make the change in my codebase consistent with the page.
2. Run `bun run guard` and `bun run check` to verify nothing
in src/ or app/ breaks Mandu's invariants.
3. Show me the diff and any guard violations.
Related
- Issue #192 — origin thread for this feature.
- Follow-up: opt-in → opt-out SPA nav reversal — breaking change
to make
<a>clicks perform client-side navigation by default (tracked separately; requires a major release note). @mandujs/core/clientprefetch API — programmatic prefetch for route IDs (lower-level; wires through the router).- Architect — Island — hydrated routes participate in client-side router transitions automatically.
For Agents
Machine-readable contract for the smooth-navigation feature.
{
"feature": "smooth-navigation",
"stable_since": "v0.24",
"config": {
"transitions": { "default": true, "type": "boolean" },
"prefetch": { "default": true, "type": "boolean" }
},
"injected_into_every_ssr_response": [
"<style>@view-transition{navigation:auto}</style>",
"<script>/* ~500B hover-prefetch IIFE */</script>"
],
"prefetch_eligibility": {
"required": ["same-origin (href starts with /)", "not <a download>", "not target=_blank", "not data-no-prefetch"],
"scope": "document-level capture-phase mouseover",
"dedup": "WeakSet per anchor seen"
},
"opt_out": {
"global_transitions": "`transitions: false` in mandu.config.ts",
"global_prefetch": "`prefetch: false` in mandu.config.ts",
"per_link_prefetch": "data-no-prefetch attribute on the <a> tag"
},
"performance": {
"html_bytes_added": 555,
"html_bytes_added_gzip": 263,
"ttfb_delta_ms": 0
},
"known_limits": [
"Prefetch is not SPA — click still triggers a full document reload",
"Inline script needs CSP `unsafe-inline` or a nonce on strict CSP",
"Fixed-position elements (header, sidebar) may need `view-transition-name` to avoid crossfade flicker"
],
"forbidden_actions": [
"Do not rely on prefetch for SPA-style navigation — use <Link> from @mandujs/core/client",
"Do not inject additional mouseover listeners on the same document — they compound with the builtin"
],
"references": [
"/docs/architect/island",
"/docs/architect/prerender",
"https://github.com/konamgil/mandu/issues/192"
]
}For Agents
Smooth navigation adds two things to every SSR response: (1) `<style>@view-transition{navigation:auto}</style>` for supported browsers, and (2) a ~500-byte inline hover-prefetch IIFE that injects `<link rel=prefetch as=document>` on same-origin anchor hover. Opt out globally via `transitions: false` / `prefetch: false` in `mandu.config.ts`; per-link via `data-no-prefetch`.
- Both `transitions` and `prefetch` default to enabled (`true`)
- View Transitions is a CSS at-rule — zero JS runtime cost, ~70 bytes HTML overhead
- Hover prefetch is a ~500-byte inline IIFE in `<head>` — CSP `unsafe-inline` or a future nonce required for strict CSP
- Prefetch helper only acts on same-origin anchors (`href^='/'`) that are not `<a download>`, not `target=_blank`, and not marked `data-no-prefetch`
- Prefetch ≠ SPA navigation — click still triggers a full document reload; use `<Link>` from `@mandujs/core/client` for SPA-style routing