# spa-route

Build a full SPA from HTML alone — screens, links, and view transitions.

> This site is a live demo... inspect its HTML! Other live demos of spa-route coming soon.

```html
<spa-manager>
  <spa-route route-href="/" template-ref="/views/home.html"></spa-route>
  <spa-route route-href="/about" template-ref="/views/about.html"></spa-route>
</spa-manager>

<nav>
  <spa-a route-href="/">Home</spa-a>
  <spa-a route-href="/about">About</spa-a>
</nav>
```

## Features

- **Pure CSS View Transitions** Write CSS, get beautiful animations between routes
- **Active / was-active** Style current and outgoing links & screens (nav chrome, card expansion)
- **Same-route reuse / refresh** Keep or rebuild the view when only params change
- **Scroll reset / restore** Per-axis control for push, replace, back, forward
- **Nested layouts** Keep a parent route mounted under child paths
- **404 fallbacks** Catch-alls that only fire when nothing else matched
- **Per-route document title** `document.title` follows the active route
- **History actions** Push, replace, back, forward from a link

## Installation


`@excom/spa-route` v0.5.0

```bash
pnpm add @excom/spa-route
```

```bash
npm install @excom/spa-route
```

```bash
yarn add @excom/spa-route
```

### Import

```ts
import "@excom/spa-route";
```



## Usage

Wrap screens in `<spa-manager>`, give each `<spa-route>` a `route-href`, and link with `<spa-a>`.

```html
<!-- Optional SPA manager for View Transitions and batched router config -->
<spa-manager>
  <!-- SPA routing -->
  <spa-route route-href="/" template-ref="/views/home.html"></spa-route>
  <spa-route route-href="/about" template-ref="/views/about.html"></spa-route>
</spa-manager>
<nav>
  <!-- SPA links -->
  <spa-a route-href="/">Home</spa-a>
  <spa-a route-href="/about">About</spa-a>
</nav>
```

Tests in Vitest on happy-dom import the router helpers from `@excom/spa-route/testing`: `resetRouter`, `navigate`, `popstate`, `installViewTransition`, `trackUnhandledRejections`.

For [prerendering](/docs/prerendering), `@excom/spa-route/server` exports the router's hooks: `beforeRender` starts each page from a cold load of its URL, `afterRender` fails a soft 404 (a page only the `is-fallback` route matches) and a not-found page that route does not render. Only the outermost routes decide: a nested layout's own fallback is part of an ordinary page. `@excom/nucleus-kit/server` already has both.

### API Reference


#### Attributes

| Name | Surface | Type | Default | Values | Description | Inherited from |
| --- | --- | --- | --- | --- | --- | --- |
| `same-route` | option | `string` | `"reuse"` | `"reuse"` \| `"refresh"` | When this route matches while already active, `reuse` keeps the rendered tree and updates route data; `refresh` tears down and re-renders once params, the matched path or the query change. Use `refresh` for param-driven screens (e.g. `/users/:id` → `/users/2`); `reuse` when only route data should change (e.g. `/logs/:view`). Pair with `scroll-set-disabled` to leave the viewport untouched. |  |
| `no-transition` | option | `boolean` |  |  | This route's render / unrender never starts a `<spa-manager>` View Transition. It still takes part in one another route starts. |  |
| `scroll-reset-behavior` | option | `string` | `"instant"` | `"auto"` \| `"instant"` \| `"smooth"` | `window.scrollTo` behavior when `<spa-manager>` resets scroll for this route. Restores are instant. |  |
| `scroll-reset-x` | option | `tokenlist` | `"push replace"` | `"push"` \| `"replace"` \| `"back"` \| `"forward"` | Navigation moves that reset scroll X to `0` once a route renders (a query-only move keeps its place; a `#fragment` target wins). Moves omitted here restore the saved X for that history entry instead. |  |
| `scroll-reset-y` | option | `tokenlist` | `"push replace"` | `"push"` \| `"replace"` \| `"back"` \| `"forward"` | Navigation moves that reset scroll Y to `0` once a route renders (a query-only move keeps its place; a `#fragment` target wins). Moves omitted here restore the saved Y for that history entry instead. |  |
| `scroll-set-disabled` | option | `boolean` |  |  | While this route is active, `<spa-manager>` neither resets nor restores scroll. |  |
| `is-fallback` | option | `boolean` |  |  | Only activate when this route matches *and* no other `<spa-route>` in its closest `<spa-manager>` (nested routes included; without one, its document or shadow root) matches the current path. Other fallbacks, and routes that contain it or that it contains, never count. Place it last. Pair with a permissive `route-regex` (e.g. `.*`) for 404 catch-alls. |  |
| `document-title` | option | `string` |  |  | `document.title` while this route is active. The outermost `<spa-manager>` applies the last active route carrying one — so a nested route beats its ancestor — and restores its `default-title` (the page's own `<title>`) once no active route has one. Cold loads and back / forward retitle too: it keys off activation, not clicks. |  |
| `was-active` | state | `boolean` |  |  | Set briefly while navigating away. Style outgoing screens / card-expansion exits with `spa-route[was-active]`. |  |
| `template-ref` | option | `string` | `":scope > template"` | `<CSS Selector>` \| `<URL>` | Source `<template>` — in-document selector or remote URL. Changing mid-flight aborts and reloads. Can use `:scope` to relatively select elements: e.g. `main:has(:scope) > template` | `@excom/renderable-element` |
| `bypass-cache` | option | `boolean` |  |  | Skip the in-memory response cache (URL `template-ref` only). | `@excom/renderable-element` |
| `pre-fetch` | option | `string` | `"lazy"` | `""` \| `"eager"` \| `"idle"` \| `"lazy"` | When to fetch the template, independent of when it renders. `""` aliases `eager`. `idle` never runs in a prerender. | `@excom/renderable-element` |
| `persist-content` | option | `boolean` |  |  | Reuse the same live nodes across unrender / render (held on `_persistedTree`) so form values, scroll position, and subtree state survive toggles. | `@excom/renderable-element` |
| `host-ref` | option | `string` |  | `"shadow"` \| `"iframe"` \| `<CSS Selector>` | Where rendered children land. Unset = this element's light DOM. `shadow` attaches an open shadow root. `iframe` paints into a child `<iframe data-render-host>` body (you supply the iframe — useful for sandboxed / third-party document isolation). Any other value is a portal selector. | `@excom/renderable-element` |
| `ready-on` | option | `string` |  | `<Event Name>` | Event name that marks rendered children "ready". Until it fires, `delaying-ready` is set so CSS can hide the host for a coordinated paint / view transition. Prerendered content kept at hydration is ready at once, never hidden. | `@excom/renderable-element` |
| `is-active` | hybrid | `boolean` |  |  | Master switch. Set to load (if needed) and render; unset to unrender. Drive from visibility, route match, hover, etc. | `@excom/renderable-element` |
| `is-loading` | state | `boolean` |  |  | Template fetch in flight. | `@excom/renderable-element` |
| `did-load` | state | `boolean` |  |  | Template resolved at least once. Stays set across `is-active` toggles so consumers know later paints are warm (URL refs reuse the shared fetch cache in kit-utils). Cleared when `template-ref` changes or `--reload` forces a fresh resolve. | `@excom/renderable-element` |
| `is-error` | state | `boolean` |  |  | Latest template fetch rejected (excluding abort). Fires with the `error` event. | `@excom/renderable-element` |
| `delaying-ready` | state | `boolean` |  |  | Between `render` and the matching `ready-on` event (or a failed load). Hook with CSS for coordinated paints / view transitions. | `@excom/renderable-element` |
| `route-href` | option | `string` |  | `<path pattern>` | URL pattern to match. Supports named placeholders (`/users/:id`) and wildcards: `*rest` matches one path segment, a bare `*` across segments. A relative pattern (`checkout`) resolves against the page's `<base>`; without one, against the page URL at registration. Set this or `route-regex`; with both, this one wins. | `@excom/routable-element` |
| `route-regex` | option | `string` |  | `<RegExp source>` | RegExp source string matched against the pathname (never the query or hash) — alternative to `route-href` for catch-alls / advanced patterns, ignored when `route-href` is set. Here and in a `route-href`, named groups become `params` (`(?<id>\d+)` → `params.id`); unnamed groups stay in `match`. | `@excom/routable-element` |
| `match-nested` | option | `boolean` |  |  | Also match child paths of `route-href`: `/users` matches `/users` and `/users/42`, never `/usersx`. Essential for layout routes and nested SPAs. | `@excom/routable-element` |

#### Provision

| Name | Type | Description |
| --- | --- | --- |
| `provision` | `SpaRouteProvision` (`{ routeHref: string; matchNested: boolean; scrollResetY: string[]; scrollResetX: string[]; scrollResetBehavior: ScrollBehavior; noTransition: boolean; active: { id: string; url: string; title?: string; isInit?: boolean; scrollX?: number; scrollY?: number; ttypes?: string[]; }; event: { hasUAVisualTransition: boolean; }; match: Array<any> \| null; move: null \| "push" \| "replace" \| "back" \| "forward"; next: { id: string; url: string; title?: string; isInit?: boolean; scrollX?: number; scrollY?: number; ttypes?: string[]; } \| null; previous: { id: string; url: string; title?: string; isInit?: boolean; scrollX?: number; scrollY?: number; ttypes?: string[]; } \| null; params: Record<string, string> \| null; query: Record<string, string>; }`) | Active route payload for this activation (`null` when inactive). Not reflected as an attribute. |

#### Recognized Elements

| Relationship | Selector | Required | Description | Inherited from |
| --- | --- | --- | --- | --- |
| `template` | child | yes | Screen content. Cloned (or reused with `persist-content`) on activation. |  |
| `iframe[data-render-host]` | child | no | Required when `host-ref="iframe"`. Content paints into `iframe.contentDocument.body`. Provide your own iframe (e.g. with `srcdoc`); the element will not create one. | `@excom/renderable-element` |

#### Fires

| Name | Type | Description | Inherited from |
| --- | --- | --- | --- |
| `spa-route-provision` | `SpaRouteProvisionEvent` (`CustomEvent & { type: "spa-route-provision"; detail: () => void; bubbles: true; cancelable: true; composed: true }`) | Cancelable. On (de)activation, and whenever the path or query changes while active; a hash-only move does neither. `event.detail` is a thunk that updates route data. `<spa-manager>` batches this into its update like render/unrender. |  |
| `spa-route-render` | `RenderableRenderEvent` (`CustomEvent & { type: "{tag}-render"; detail: () => Promise<void>; bubbles: true; cancelable: true; composed: true }`) | Cancelable. Dispatched when the element becomes active and is about to place template content into the host. `event.detail` is a thunk that performs the load (if not already loaded) and renders the children, returning a Promise that resolves once the corresponding `ready-on` event fires (or immediately if `ready-on` is unset). The promise rejects if the template fails to load, `host-ref` resolves to no host (nor an author iframe still loading), or the element is torn down mid-flight (`startTeardown` while loading / `delaying-ready`). Call `preventDefault()` to defer rendering and invoke `event.detail()` later. | `@excom/renderable-element` |
| `spa-route-unrender` | `RenderableUnrenderEvent` (`CustomEvent & { type: "{tag}-unrender"; detail: () => void; bubbles: true; cancelable: true; composed: true }`) | Cancelable. Dispatched when the element becomes inactive and content is already painted. `event.detail` is a thunk that removes the rendered children. Call `preventDefault()` to defer the removal. Not fired when teardown cancels an in-flight load — that path emits `aborted` instead. | `@excom/renderable-element` |
| `spa-route-did-render` | `RenderableDidRenderEvent` (`CustomEvent & { type: "{tag}-did-render"; detail: void; bubbles: true; cancelable: true; composed: true }`) | Dispatched after the template content has actually been placed into the host. | `@excom/renderable-element` |
| `spa-route-did-unrender` | `RenderableDidUnrenderEvent` (`CustomEvent & { type: "{tag}-did-unrender"; detail: void; bubbles: true; cancelable: true; composed: true }`) | Dispatched after rendered children have been removed from the host. | `@excom/renderable-element` |
| `spa-route-error` | `RenderableErrorEvent` (`CustomEvent & { type: "{tag}-error"; detail: void; bubbles: true; cancelable: true; composed: true }`) | Dispatched when the template promise rejects with anything other than an `AbortError`. | `@excom/renderable-element` |
| `spa-route-aborted` | `RenderableAbortedEvent` (`CustomEvent & { type: "{tag}-aborted"; detail: void; bubbles: true; cancelable: true; composed: true }`) | Dispatched when an in-flight load / ready wait is canceled because `is-active` was unset (via `startTeardown`). | `@excom/renderable-element` |

#### Commands

| Command | Action | Inherited from |
| --- | --- | --- |
| `--reload` | Stops waiting for any in-flight load and re-resolves the template, bypassing the cache for URL refs (useful after remote content changes). A URL request is shared by the page, so the one in flight is not cancelled. | `@excom/renderable-element` |

#### Default actions

| Event | Default behavior (unless preventDefault() is called) | Inherited from |
| --- | --- | --- |
| `spa-route-provision` | Invokes `event.detail()` to apply the new provision. |  |
| `spa-route-render` | Invokes `event.detail()` to load (if needed) and render the template into the host. | `@excom/renderable-element` |
| `spa-route-unrender` | Invokes `event.detail()` to remove rendered children from the host. | `@excom/renderable-element` |

#### CSS Aliases

| Alias | Kind | Matches | Description |
| --- | --- | --- | --- |
| `:--spa-route` | element | `spa-route`, `.tag-spa-route` |  |



### Examples

#### Minimal SPA

Three routes, three links. Active links style via `spa-a[is-active]`.

```html
<spa-manager>
  <nav>
    <spa-a route-href="/home">Home</spa-a>
    <spa-a route-href="/users">Users</spa-a>
    <spa-a route-href="/about">About</spa-a>
    <spa-a route-href="/contact">Contact</spa-a>
  </nav>
  <spa-route route-href="/home">
    <template>
      <h3>Welcome</h3>
      <p>Mounted because the URL matched <code>/home</code>.</p>
    </template>
  </spa-route>
  <spa-route route-href="/users">
    <template>
      <h3>Users</h3>
      <ul>
        <li>Adam</li>
        <li>Linus</li>
        <li>Grace</li>
      </ul>
    </template>
  </spa-route>
  <spa-route route-href="/about" template-ref="/this/view/is/remote.html"></spa-route>
  <spa-route route-href="/contact" template-ref="#this-view-is-dom-selected"></spa-route>
</spa-manager>

<template id="this-view-is-dom-selected">foo@bar.com</template>
```

```css
spa-a[is-active] {
  font-weight: bold;
  pointer-events: none;
  text-decoration: none;
}
```

#### Nested layout & 404

`match-nested` keeps a layout mounted at its own path and under child paths. `is-fallback` with `route-regex=".*"` is a 404 that only activates when no other route inside its `<spa-manager>` matches the current path, nested routes included; place it last, since on a cold load it does not see siblings that mount after it.

```html
<spa-manager>
  <nav>
    <spa-a route-href="/users">Users list</spa-a>
    <spa-a route-href="/users/42">User 42</spa-a>
    <spa-a route-href="/missing">Missing page</spa-a>
  </nav>
  <spa-route route-href="/users" match-nested>
    <template>
      <section>
        <h3>Users layout</h3>
        <spa-manager>
          <spa-route route-href="/users">
            <template><p>List of users.</p></template>
          </spa-route>
          <spa-route route-href="/users/:id">
            <template><p>Detail for a single user.</p></template>
          </spa-route>
        </spa-manager>
      </section>
    </template>
  </spa-route>
  <spa-route route-regex="^/(?:admin|staff)(?:/|$)" template-ref="/views/admin-sidebar.html"></spa-route>
  <spa-route route-regex=".*" is-fallback>
    <template>
      <section>
        <h3>404</h3>
        <p>Catch-all — only when no other route matches.</p>
      </section>
    </template>
  </spa-route>
</spa-manager>
```

#### Document title

`document-title` sets `document.title` while its route is active. It keys off activation, not clicks, so cold loads and back / forward retitle too. The outermost `<spa-manager>` applies the last active route carrying one — a nested route beats its ancestor — and applies its `default-title` (unless authored, the page's own `<title>`, recorded when a route first retitles the page) once no active route has a title.

```html
<title>Nucleus · docs</title>

<spa-manager>
  <spa-route route-href="/" document-title="My company">
    <template><p>The company page.</p></template>
  </spa-route>
  <!-- untitled: the page's own <title> comes back -->
  <spa-route route-href="/docs">
    <template><p>The docs.</p></template>
  </spa-route>
</spa-manager>
```

#### History actions

`route-action="back"` / `"forward"` walk in-app history only: with none to walk, the link pushes its `route-href`, and without a `route-href` it logs an error and does nothing. `"replace"` swaps the current entry instead of pushing.

```html
<spa-manager>
  <nav>
    <spa-a route-action="back">‹ Back</spa-a>
    <spa-a route-action="forward">Forward ›</spa-a>
    <spa-a route-href="/one">Push /one</spa-a>
    <spa-a route-href="/two">Push /two</spa-a>
    <spa-a route-href="/login" route-action="replace">
      Replace with /login
    </spa-a>
  </nav>
  <spa-route route-href="/one">
    <template><p>You're on <code>/one</code>.</p></template>
  </spa-route>
  <spa-route route-href="/two">
    <template><p>You're on <code>/two</code>.</p></template>
  </spa-route>
  <spa-route route-href="/login">
    <template>
      <p>You're on <code>/login</code> — this entry replaced the
        previous one in history.</p>
    </template>
  </spa-route>
</spa-manager>
```

#### View Transitions

The outermost `<spa-manager>` wraps each navigation in one `document.startViewTransition()`; nested managers join it. None runs when the API is missing, with reduced motion, in a hidden page, on the first paint (unless `transition-first-render`) or a [prerendered page](/docs/prerendering)'s first update, or when the update only changes provisions. `document.title` follows every update all the same, and `spa-manager-rendered` fires once per update chain: a navigation that arrives during a running update joins or follows it and shares its event. Style with `::view-transition-*`; set per-link types via `transition-types` (e.g. card expansion); opt a route out with `no-transition`.

```css
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 0.25s;
}
```

#### View Transitions - Localized
If you had a list of cards, and clicking on one expanded it to the detail view (and vice versa, contracting), you would achieve it similarly to the code example below. This technique relies on styling the `<spa-a>` with its `[is-active]` (incoming view) and `[was-active]` (outgoing view).

```html
<spa-manager>
  <spa-route id="route-list" route-href="/list">
    <template>
      <spa-a route-href="/detail/123" transition-types="card-morph" class="mini-card">
        Go to detail
      </spa-a>
    </template>
  </spa-route>
  <spa-route id="route-detail" route-href="/detail/:id">
    <template>
      <article id="detail-card" class="card">
        <!-- other content here -->
      </article>
    </template>
  </spa-route>
</spa-manager>
```

```css
html:active-view-transition-type(card-morph) {
  #route-list spa-a[transition-types="card-morph"][was-active], /* outgoing list card (forward) */
  #route-list spa-a[transition-types="card-morph"][is-active], /* incoming list card (back) */
  #detail-card /* detail card (forward & back) */ {
    contain: layout;
    height: fit-content;
    view-transition-name: card-morph;
  }
}
::view-transition-old(card-morph),
::view-transition-new(card-morph) {
  mix-blend-mode: normal;
  height: 100%;
  width: 100%;
  will-change: opacity;
  animation-fill-mode: both;
}
::view-transition-old(card-morph) {
  animation-name: fade-out 1s ease;
}
::view-transition-new(card-morph) {
  animation-name: fade-in 1s ease;
}
@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}
@keyframes fade-out {
  from { opacity: 1; }
  to { opacity: 0; }
}
```

#### Touch edge-swipe
On touch devices, horizontal drags from within `overscroll-x-threshold` of an edge trigger back / forward. Use `"none"` to block overscroll without navigating. Useful for preventing native swipes in Safari, which visually break SPAs.

```html
<spa-manager overscroll-behavior-x="navigate"></spa-manager>
```


#### Scroll reset / restore

The outermost `<spa-manager>` owns scroll: while it is connected the browser's own scroll restoration is off, and a `<spa-route>` without a `<spa-manager>` ancestor does not touch scroll. By default it:
- resets scroll to top-left on `push` / `replace`, only when a route rendered — a move that renders nothing (a param or query change on a `reuse` route) keeps its position, and a `#fragment` target wins over the reset
- restores the saved scroll position on `back` / `forward` / reload, and holds it for about 2 seconds against late content, or until the person scrolls, taps or types, or the app scrolls

The write lands once the routes are ready (capped by `render-timeout`), so `ready-on` remains the way to get late data into the restored view. An update settled by `render-timeout` logs one warning (`spa-manager: update settled by render-timeout (2000 ms); a route is still pending`), visible at log level 2 or higher, and shows a route still waiting for its `ready-on` event. On a prerendered page (`has-rendered` in its markup) a reload or back / forward restores its saved position as the manager mounts, unless the person has scrolled already, and a fresh visit keeps the browser's.

Override per axis with `scroll-reset-y` / `scroll-reset-x` — space-separated moves that should reset to `0` (omitted moves restore instead):

```html
<!-- also reset Y when the user hits back -->
<spa-route
  route-href="/article/:id"
  scroll-reset-y="push replace back"
></spa-route>
```

Animate a reset with `scroll-reset-behavior="smooth"`; restores are instant. Disable all scroll handling with `scroll-set-disabled`. With several active routes (a layout and its child), the last in document order decides the reset.

#### Same-route params

When the matched route stays the same but its path or query changes (e.g. `/users/1` → `/users/2`, `?page=1` → `?page=2`), the route provisions again; its provision carries `params` (path placeholders and named groups: `(?<id>\d+)` gives `params.id`, unnamed groups stay in `match`) and `query`:

- `same-route="reuse"` (default) — keep the rendered tree and update route data, without a View Transition
- `same-route="refresh"` — tear down and re-render the view when params or the query change

```html
<spa-route route-href="/users/:id" same-route="refresh">
  <template><!-- fresh tree per user id --></template>
</spa-route>

<spa-route
  route-href="/logs/:view"
  same-route="reuse"
  scroll-set-disabled
>
  <template><!-- preserve content + scroll across view tabs --></template>
</spa-route>
```

#### Transition delay

`transition-delay` on `<spa-manager>` waits N ms before starting the batched View Transition — useful when sibling routes need a beat to queue their render/unrender callbacks, or for last-second DOM work. An update that does not animate, and the first paint, start at once.

```html
<spa-manager transition-delay="50">
  <!-- routes -->
</spa-manager>
```

## Release notes

### 0.5.0 (2026-10-08)

- Add `@excom/spa-route/server`, the router's prerender hooks: `beforeRender` starts each page from a cold load of its URL, `afterRender` fails a soft 404 (a page only the `is-fallback` route matches) and a not-found page that route does not render

### 0.4.0 (2026-10-07)

- Add `default-title` to `<spa-manager>`: the title a route without `document-title` falls back to, recorded from the page's `<title>` when a route first retitles the page
- Add hydration of prerendered pages: the first update runs without a View Transition or `transition-delay`, a reload restores its saved scroll position as the manager mounts, and a page served for a URL another route matches drops its content and renders the matching route
- Fix an issue where `render-timeout` left a route hidden while it waited for its `ready-on` event: it now reveals the route
- Fix an issue where a `same-route="refresh"` route tore down on its first registration match

### 0.3.0 (2026-10-01)

- Add `@excom/spa-route/testing`, the router helpers for Vitest on happy-dom: `resetRouter()`, `navigate()`, `popstate()`, `installViewTransition()` and `trackUnhandledRejections()`
- Fix an issue where an `is-fallback` route stayed off after leaving a nested route for a URL no route matches: the fallback now checks which routes match the path instead of reading their `is-active` state, so its position among its siblings no longer matters while navigating, a page without a `spa-manager` gets a working fallback, a fallback inside a layout can activate on the layout's unmatched child paths, and other fallbacks never hold it off
- Add a warning when a `spa-manager` update settles by `render-timeout` while a route is still pending, so an empty first paint has a cause in the console at log level 2

Older releases: https://nucleus.excom.dev/packages/spa-route
