@excom/spa-route
v0.5.0
Published
<spa-route> custom element
Maintainers
Readme
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.
<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.titlefollows the active route - History actions Push, replace, back, forward from a link
Installation
@excom/spa-route v0.5.0
pnpm add @excom/spa-routenpm install @excom/spa-routeyarn add @excom/spa-routeImport
import "@excom/spa-route";Usage
Wrap screens in <spa-manager>, give each <spa-route> a route-href, and link with <spa-a>.
<!-- 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, @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].
<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">[email protected]</template>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.
<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.
<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.
<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'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.
::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).
<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>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.
<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 areuseroute) keeps its position, and a#fragmenttarget 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):
<!-- 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 Transitionsame-route="refresh"— tear down and re-render the view when params or the query change
<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.
<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:beforeRenderstarts each page from a cold load of its URL,afterRenderfails a soft 404 (a page only theis-fallbackroute matches) and a not-found page that route does not render
0.4.0 (2026-10-07)
- Add
default-titleto<spa-manager>: the title a route withoutdocument-titlefalls 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-timeoutleft a route hidden while it waited for itsready-onevent: 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()andtrackUnhandledRejections() - Fix an issue where an
is-fallbackroute 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 theiris-activestate, so its position among its siblings no longer matters while navigating, a page without aspa-managergets 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-managerupdate settles byrender-timeoutwhile 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
<spa-a>
Declarative SPA link with active-state styling.
Tag: <spa-a>
API
Attributes
| Name | Surface | Type | Default | Values | Description | Inherited from |
| --- | --- | --- | --- | --- | --- | --- |
| route-action | option | string | "push" | "push" | "replace" | "back" | "forward" | Navigation mode when activated (default click). back / forward walk in-app history only: with none to walk, the link pushes its route-href; without one it logs an error and does nothing. | |
| document-title | option | string | | | Sets document.title after navigation. | |
| transition-types | option | tokenlist | | <token>… | Space-separated View Transition types for this navigation (CSS View Transitions API). Useful for shared-element morphs like card expansion. | |
| match-hash | option | boolean | | | Require the URL hash to match when setting is-active. | |
| is-active | state | boolean | | | Current URL matches route-href. Style with spa-a[is-active]. | |
| was-active | state | boolean | | | Previous URL matched route-href. Style outgoing links / card-expansion exits with spa-a[was-active]. | |
| host-ref | option | string | | <CSS Selector> | "window" | "document" | "html" | "body" | "head" | Listen on another element / window / document — e.g. Escape to dismiss a dialog from a global keydown. Defaults to :scope. Used with listen-for. Not compatible with listen-for-lifecycle. The selector MUST resolve when host-ref is set — it will not wait for a match to appear. | @excom/listenable-element |
| listen-for | option | tokenlist | | <EventName>… | Space-separated event names to listen for. Defaults to click when unset (and no lifecycle list is set). | @excom/listenable-element |
| listen-for-lifecycle | option | tokenlist | | "connected" | "disconnected" | "adopted" | Space-separated element lifecycles to handle. | @excom/listenable-element |
| listen-once | option | boolean | | | Handle each distinct event name / lifecycle at most once. | @excom/listenable-element |
| selector-filter | option | string | | <CSS Selector> | Only handle events whose event.target matches this CSS selector. Does not support :scope in the selector. | @excom/listenable-element |
| keycode-filter | option | tokenlist | | <key | mod+key>… | Space-separated key filters (OR). Join modifiers with + (AND, any order): shift+k tab → Shift+K or Tab. Modifiers: shift, alt, ctrl/control, meta/cmd. Name the space bar space / spacebar and the plus key plus (shift+space). Case-insensitive. | @excom/listenable-element |
| pathname-filter | option | tokenlist | | <pathname>… | Only handle when location.pathname is one of these values — route-aware behaviors without a separate router element. | @excom/listenable-element |
| prevent-default | option | boolean | | | Call preventDefault() on matched events (ignored for lifecycles). | @excom/listenable-element |
| stop-propagation | option | boolean | | | Call stopPropagation() on matched events (ignored for lifecycles). | @excom/listenable-element |
| stop-immediate-propagation | option | boolean | | | Call stopImmediatePropagation() on matched events (ignored for lifecycles). | @excom/listenable-element |
| vibrate-ms | option | number | "20 (when attribute is present with no value)" | | Vibrate on handle (navigator.vibrate). Empty / 0 uses a 20ms pulse. | @excom/listenable-element |
| delay-ms | option | number | | | Delay handling by this many milliseconds. | @excom/listenable-element |
| is-debounced | option | boolean | | | With delay-ms, coalesce bursts into one trailing call (debounce). | @excom/listenable-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 |
CSS Aliases
| Alias | Kind | Matches | Description |
| --- | --- | --- | --- |
| :--spa-a | element | spa-a, .tag-spa-a | |
<spa-manager>
SPA shell — view transitions, swipe nav, route orchestration.
Tag: <spa-manager>
API
Attributes
| Name | Surface | Type | Default | Values | Description | Inherited from |
| --- | --- | --- | --- | --- | --- | --- |
| transition-first-render | option | boolean | | | Animate the very first activation (cold load) with a View Transition. Off by default so the initial paint is instant. | |
| transition-delay | option | number | | | Delay (ms) before an update that animates (its View Transition) starts; an update that does not animate, and the first paint, never are. Gives late sibling render/unrender events time to queue, or room for last-second DOM work. Unset / null starts synchronously. | |
| no-transition | option | boolean | | | Disable View Transitions for every child route. | |
| render-timeout | option | number | 2000 | | Max wait (ms) for child routes to be ready before the update (title, scroll, its View Transition) moves on; a warning is logged when it settles the update, and a route still waiting for its ready-on event is shown. Raise for slow remote templates. | |
| overscroll-behavior-x | option | string | | "none" | "navigate" | Touch edge-swipe on touch devices. none blocks horizontal overscroll; navigate also calls back / forward past the threshold. | |
| overscroll-x-threshold | option | number | 40 | | Edge inset (px) where a touch start counts as an edge swipe. | |
| max-states | option | number | | | Cap retained router history states (scroll positions, transition types, etc.). | |
| last-move | state | string | | "push" | "replace" | "back" | "forward" | Last navigation direction (push / replace / back / forward). Pick CSS transition styles from this. | |
| active-url | state | string | | | URL of the currently active route. | |
| is-transitioning | state | boolean | | | A View Transition is in flight. | |
| has-rendered | state | boolean | | | The first update with a render has settled (rendered, failed or hit render-timeout). Set in that update, inside its View Transition if one runs, before spa-manager-rendered: hide a loading shell / splash screen on it. Gates transition-first-render. Leave it in prerendered markup: the page then loads without a View Transition and keeps the browser's scroll (a reload's saved position is restored). | |
| default-title | hybrid | string | | | document.title while no active route has a document-title. Unset, the page's own <title> is recorded here when a route first retitles the page, so a prerendered page keeps its shell title. | |
| 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 | KitRouteData ({ previous: { id: string; url: string; title?: string; isInit?: boolean; scrollX?: number; scrollY?: number; ttypes?: string[]; } \| null; active: { id: string; url: string; title?: string; isInit?: boolean; scrollX?: number; scrollY?: number; ttypes?: string[]; }; next: { id: string; url: string; title?: string; isInit?: boolean; scrollX?: number; scrollY?: number; ttypes?: string[]; } \| null; all: { id: string; url: string; title?: string; isInit?: boolean; scrollX?: number; scrollY?: number; ttypes?: string[]; }[]; params: Record<string, string> \| null; query: Record<string, string>; match: Array<any> \| null; move: null \| "push" \| "replace" \| "back" \| "forward"; event?: { hasUAVisualTransition: boolean; }; route: KitRoute; }) | Current route payload. Not reflected as an attribute. |
Recognized Elements
| Relationship | Selector | Required | Description |
| --- | --- | --- | --- |
| spa-route | descendant | yes | Routes this manager coordinates. Their render lifecycle is intercepted and batched into one view transition per navigation. |
Fires
| Name | Type | Description |
| --- | --- | --- |
| spa-manager-will-transition | SpaManagerWillTransitionEvent (CustomEvent & { type: "spa-manager-will-transition"; detail: void; bubbles: true; cancelable: true; composed: true }) | Cancelable. Outermost manager, once per update chain (the first paint included), before its update (and View Transition, if any) starts. preventDefault() holds the update until updateRoutes(true) is called (updateRoutes() runs it without a View Transition). |
| spa-manager-transition | SpaManagerTransitionEvent (CustomEvent & { type: "spa-manager-transition"; detail: { transition: ViewTransition; }; bubbles: true; cancelable: true; composed: true }) | Outermost manager, after document.startViewTransition() is called. |
| spa-manager-rendered | SpaManagerRenderedEvent (CustomEvent & { type: "spa-manager-rendered"; detail: void; bubbles: true; cancelable: true; composed: true }) | After the update (and its transition) finishes: routes settled, title and scroll applied. Nested managers whose routes took part fire it too, without bubbling. |
| spa-manager-error | SpaManagerErrorEvent (CustomEvent & { type: "spa-manager-error"; detail: unknown; bubbles: true; cancelable: true; composed: true }) | Re-emitted when a child fires spa-route-error. event.detail mirrors the source error. |
| spa-manager-push | SpaManagerPushEvent (CustomEvent & { type: "spa-manager-push"; detail: void; bubbles: true; cancelable: true; composed: true }) | On pushState navigations. |
| spa-manager-replace | SpaManagerReplaceEvent (CustomEvent & { type: "spa-manager-replace"; detail: void; bubbles: true; cancelable: true; composed: true }) | On replaceState navigations. |
| spa-manager-back | SpaManagerBackEvent (CustomEvent & { type: "spa-manager-back"; detail: void; bubbles: true; cancelable: true; composed: true }) | On back navigations. |
| spa-manager-forward | SpaManagerForwardEvent (CustomEvent & { type: "spa-manager-forward"; detail: void; bubbles: true; cancelable: true; composed: true }) | On forward navigations. |
Listens for
| Name | Type | Description |
| --- | --- | --- |
| spa-route-render | RenderableRenderEvent (CustomEvent & { type: "{tag}-render"; detail: () => Promise<void>; bubbles: true; cancelable: true; composed: true }) | Queues the child's render callback for the next batched update. A nested manager lets it bubble on to the outermost one. |
| spa-route-unrender | RenderableUnrenderEvent (CustomEvent & { type: "{tag}-unrender"; detail: () => void; bubbles: true; cancelable: true; composed: true }) | Queues the child's unrender callback (runs before renders so the outgoing route leaves first). |
| spa-route-provision | SpaRouteProvisionEvent (CustomEvent & { type: "spa-route-provision"; detail: () => void; bubbles: true; cancelable: true; composed: true }) | Queues the child's route data update into the next batched update. |
| spa-route-error | RenderableErrorEvent (CustomEvent & { type: "{tag}-error"; detail: void; bubbles: true; cancelable: true; composed: true }) | Re-emits as spa-manager-error. |
Default actions
| Event | Default behavior (unless preventDefault() is called) |
| --- | --- |
| spa-manager-will-transition | Starts the batched update, inside a View Transition unless the API is missing, reduced motion is on, the page is hidden, it is the first paint (without transition-first-render) or a prerendered page's first update, the browser already animated the navigation, the batch only provisions or routes opt out. |
Full documentation: https://nucleus.excom.dev/packages/spa-route
