Router
Optional companion (@monochrome-ui/router, ~1.2 kB gzipped). Turn a server-rendered site into a single-page app with one import: fetch destination HTML, swap data-area regions, restore scroll, keep the JS context across pages.
Import
import "@monochrome-ui/router"Self-initializes. Intercepts same-origin anchor clicks and handles back/forward via the History API.
Structure
Require one element with data-area="root" inside <body>, not on <body> itself.
<body>
<nav><!-- persists across navigation --></nav>
<div data-area="root">
<main>
<h1>Home</h1>
</main>
</div>
</body>Siblings of the root area stay mounted. Only content inside data-area="root" is replaced.
If the navigation target has no data-area="root", the router falls back to a full page reload.
Area keys
Regions inside root that should persist when their key matches across pages: wrap them in [data-area] and set data-key.
<body>
<div data-area="root" data-key="docs-app">
<aside data-area="sidebar" data-key="docs-sidebar">
<!-- stays mounted while data-key="docs-sidebar" -->
</aside>
<main data-area="content" data-key="docs-intro">
<!-- swaps when data-key changes -->
</main>
</div>
</body>| Area key change | Behavior |
|---|---|
Same data-key |
Element preserved (DOM, state, listeners) |
Different data-key |
Element replaced |
No data-key on current |
Always replaced |
Root follows the same rule. A stable root data-key preserves the shell and only replaces nested areas whose keys differ.
Shape matters. If the incoming page adds or removes an area name the current page does not share, the router treats root as structurally different and replaces it wholesale. Keep the same set of [data-area] names across related pages for partial swaps.
Head areas
data-area works inside <head> for per-page meta that should update without reloading the document.
<head>
<meta charset="UTF-8" />
<title>Home</title>
<meta name="description" content="Home page" data-area="head-meta" data-key="home">
</head>Head areas swap with the body root. <title> updates from the incoming document's <title> with no data-area required.
Prefetching
Prefetch on hover and keyboard focus; cache in memory; reuse on click.
| Trigger | Behavior |
|---|---|
mouseover on a link |
Fetch and cache target HTML |
focusin on a link |
Fetch and cache target HTML |
| Click on a prefetched link | Swap from cache |
No prefetch before the first user interaction.
Scroll restoration
Link navigation scrolls the new page to the top. Back/forward restores history.state.scrollY.
Router owns scrolling (history.scrollRestoration = "manual"). In-page #hash jumps stamp the current position before the browser jumps so Back returns to the prior offset and Forward re-scrolls to the fragment.
Navigation event
After every successful swap, dispatch mc:navigate on window.
addEventListener("mc:navigate", () => {
// active nav styling, close open menus, analytics, etc.
})Scripts are not re-executed on swap. Register setup once on initial load; react to later pages via mc:navigate.
Ignored links
Only plain left-clicks on same-origin anchors are intercepted:
| Condition | Result |
|---|---|
Ctrl, Meta, Shift, or Alt held |
Browser handles |
| Middle-click or right-click | Browser handles |
target="_blank" or non-_self |
Browser handles |
download present |
Browser handles |
rel="external" |
Browser handles |
| Different origin | Browser handles |
| Same path + search, hash-only change | Browser handles (in-page) |
Use rel="external" to opt one same-origin link out of the router.
Fallbacks
When a swap is unsafe, full page navigation instead of a broken DOM:
| Scenario | Fallback |
|---|---|
| Non-OK fetch status (404, 500) | Full reload |
| Response redirects cross-origin | Full reload |
Target HTML has no data-area="root" |
Full reload |
| Network error | Full reload |
Concurrent navigations use a monotonic token; stale fetches are discarded.
API
Attributes
| Attribute | Required | Description |
|---|---|---|
data-area="root" |
Yes | Root region replaced on navigation. Required on every routable page. |
data-area="<name>" |
No | Named region inside root. Swaps when its key changes. |
data-key="<string>" |
No | Preservation key. Matching keys keep the element mounted. |
Events
| Event | Target | When |
|---|---|---|
mc:navigate |
window |
After a successful DOM swap, before the next paint |
Ignored anchor conditions
Modifier keys; target other than _self; download; rel="external"; cross-origin; same-path hash-only change.
FAQ
Does it work with Next.js, Astro, or Nuxt?
Yes. Framework-agnostic: HTML with data-area="root" is enough. Server-rendered apps get instant navigation without the framework's client router.
If the framework already has a client router, use rel="external" where you want a full reload, or skip the @monochrome-ui/router import.
Are <script> tags re-run on navigation?
No. Regions swap; scripts do not re-execute. Globals, listeners, and component state persist.
Register initialization on first load; subscribe to mc:navigate for later pages.
How do I opt a single link out of the router?
<a href="/legacy-page" rel="external">Legacy page</a>Useful for a fresh JS context or a separate app on the same origin.
Can I trigger navigation programmatically?
No public navigate() yet. Options:
history.pushState(null, "", "/target")thendispatchEvent(new PopStateEvent("popstate"))- Programmatically click a real
<a>(the click handler intercepts it)
location.href does a full load and bypasses the router.
What happens to open popovers and menus on navigation?
Open popovers, menus, and dialogs inside swapped DOM go away with it. Use mc:navigate to close UI that lives outside the root area (for example a persistent mobile menu).
Does it support view transitions?
Not yet. Swaps are synchronous without document.startViewTransition.