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

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

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

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

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

After every successful swap, dispatch mc:navigate on window.

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

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.

html
<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") then dispatchEvent(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.

See also