LangENKO

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.

since v0.24
On this page

Smooth Navigation

Out of the box, Mandu makes cross-document navigation feel native with two tiny additions to every SSR response:

  1. 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.
  2. Hover prefetch — a ~500-byte inline script listens for mouseover on 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 (default true)
  • prefetch: enabled (default true)

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.

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:

  1. At ~500 bytes compressed, an extra HTTP round trip would cost more than the helper saves.
  2. Keeping it in <head> means it runs during parse, before the first paint — so hovers during initial page render are caught.
  3. No module graph change — zero impact on the bundler's caching invariants.

Known limits

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:

  1. Wrap navigable links in <Link> — Mandu's @mandujs/core/client Link component 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.
  2. 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

🤖 Agent Prompt — Smooth Navigation
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.
  • 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/client prefetch 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

AI hint

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`.

Invariants
  • 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
Guard scope
smooth-navigation