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><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
This package is available in the
<script src="https://unpkg.com/@excom/kit-utils@0.3.0/dist/index.umd.min.js"></script>
<script src="https://unpkg.com/@excom/neutron@0.2.0/dist/index.umd.min.js"></script>
<script src="https://unpkg.com/@excom/spa-route@0.5.0/dist/index.umd.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@excom/spa-route@0.5.0/dist/index.css">npm install @excom/spa-route<!-- import path to `node_modules` will depend on your build setup -->
<script type="module" src="/node_modules/@excom/spa-route"></script>
<link rel="stylesheet" href="/node_modules/@excom/spa-route">import "@excom/spa-route";@import "@excom/spa-route/index.css";Peer dependencies (0)
Packages a consumer must install alongside this one. Workspace deps are bundled.
| Package | Version |
|---|---|
|
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><!-- 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 @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
Role —option is configurable, state
is managed by the element (read-only), or hybrid which is both.
Provision
Thisprovision property lives on the DOM node.
Read / watch it with Quark’s prop("provision"), or listen
for the neutron-provision event from app JS.
Events
Type
command event (HTML Command API): from a
<button command commandfor>, an <event-handler
command-name>, or a CommandEvent.
Recognized Elements
Child or descendant elements are recognized by and relevant to its functionality.Styles
@custom-selector synonyms so custom elements can share
semantic styles. Element aliases match the host tag / .tag-* class; state
aliases match attributes (nested under the element alias).
spa-route
Attributes (23)
Role —option is configurable, state
is managed by the element (read-only), or hybrid which is both.
bypass-cache
template-ref only).
bypassCache
boolean
falsedocument-title
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.
documentTitle
string
nullhost-ref
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.
hostRef
string
"shadow" | "iframe" | <CSS Selector>nullis-fallback
<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.
isFallback
boolean
falsematch-nested
route-href: /users matches /users and /users/42, never /usersx. Essential for layout routes and nested SPAs.
matchNested
boolean
falseno-transition
<spa-manager> View Transition. It still takes part in one another route starts.
noTransition
boolean
falsepersist-content
_persistedTree) so form values, scroll position, and subtree state survive toggles.
persistContent
boolean
falsepre-fetch
"" aliases eager. idle never runs in a prerender.
preFetch
string
"" | "eager" | "idle" | "lazy""lazy"ready-on
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.
readyOn
string
<Event Name>nullroute-href
/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.
routeHref
string
<path pattern>nullroute-regex
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.
routeRegex
string
<RegExp source>nullsame-route
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.
sameRoute
string
"reuse" | "refresh""reuse"scroll-reset-behavior
window.scrollTo behavior when <spa-manager> resets scroll for this route. Restores are instant.
scrollResetBehavior
string
"auto" | "instant" | "smooth""instant"scroll-reset-x
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.
scrollResetX
tokenlist
"push" | "replace" | "back" | "forward""push replace"scroll-reset-y
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.
scrollResetY
tokenlist
"push" | "replace" | "back" | "forward""push replace"scroll-set-disabled
<spa-manager> neither resets nor restores scroll.
scrollSetDisabled
boolean
falsetemplate-ref
<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
templateRef
string
<CSS Selector> | <URL>":scope > template"is-active
isActive
boolean
falsedelaying-ready
render and the matching ready-on event (or a failed load). Hook with CSS for coordinated paints / view transitions.
delayingReady
boolean
falsedid-load
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.
didLoad
boolean
falseis-error
error event.
isError
boolean
falseis-loading
isLoading
boolean
falsewas-active
spa-route[was-active].
wasActive
boolean
falseProvision (1)
Thisprovision property lives on the DOM node.
Read / watch it with Quark’s prop("provision"), or listen
for the neutron-provision event from app JS.
provision
null when inactive). Not reflected as an attribute.
provision
SpaRouteProvision
Events (8)
e.preventDefault() is not
synchronously called on the event.
Type
spa-route-aborted
is-active was unset (via startTeardown).
Type
RenderableAbortedEvent
spa-route-did-render
Type
RenderableDidRenderEvent
spa-route-did-unrender
Type
RenderableDidUnrenderEvent
spa-route-error
AbortError.
Type
RenderableErrorEvent
spa-route-provision
event.detail is a thunk that updates route data. <spa-manager> batches this into its update like render/unrender.
Type
SpaRouteProvisionEvent
event.detail() to apply the new provision.spa-route-render
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.
Type
RenderableRenderEvent
event.detail() to load (if needed) and render the template into the host.spa-route-unrender
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.
Type
RenderableUnrenderEvent
event.detail() to remove rendered children from the host.
command event (HTML Command API): from a
<button command commandfor>, an <event-handler
command-name>, or a CommandEvent.
--reloadRecognized Elements (3)
Child or descendant elements are recognized by spa-route and relevant to its functionality.host-ref="iframe". Content paints into iframe.contentDocument.body. Provide your own iframe (e.g. with srcdoc); the element will not create one.
persist-content) on activation.
<template> child used when template-ref is the default ":scope > template". Not required when template-ref points at a selector or URL elsewhere.
Styles (1)
all: revert-layer.
Valence.css ships this as .unstyled and .unstyled-all for the entire tree.
@custom-selector synonyms so custom elements can share
semantic styles. Element aliases match the host tag / .tag-* class; state
aliases match attributes (nested under the element alias).
:--spa-routespa-route, .tag-spa-routespa-a
Attributes (22)
Role —option is configurable, state
is managed by the element (read-only), or hybrid which is both.
delay-ms
delayMs
number
nulldocument-title
document.title after navigation.
documentTitle
string
nullhost-ref
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.
hostRef
string
<CSS Selector> | "window" | "document" | "html" | "body" | "head"nullis-debounced
delay-ms, coalesce bursts into one trailing call (debounce).
isDebounced
boolean
falsekeycode-filter
+ (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.
keycodeFilter
tokenlist
<key | mod+key>…nulllisten-for
click when unset (and no lifecycle list is set).
listenFor
tokenlist
<EventName>…nulllisten-for-lifecycle
listenForLifecycle
tokenlist
"connected" | "disconnected" | "adopted"nulllisten-once
listenOnce
boolean
falsematch-hash
is-active.
matchHash
boolean
falsematch-nested
route-href: /users matches /users and /users/42, never /usersx. Essential for layout routes and nested SPAs.
matchNested
boolean
falsepathname-filter
location.pathname is one of these values — route-aware behaviors without a separate router element.
pathnameFilter
tokenlist
<pathname>…nullprevent-default
preventDefault() on matched events (ignored for lifecycles).
preventDefault
boolean
falseroute-action
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.
routeAction
string
"push" | "replace" | "back" | "forward""push"route-href
/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.
routeHref
string
<path pattern>nullroute-regex
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.
routeRegex
string
<RegExp source>nullselector-filter
event.target matches this CSS selector. Does not support :scope in the selector.
selectorFilter
string
<CSS Selector>nullstop-immediate-propagation
stopImmediatePropagation() on matched events (ignored for lifecycles).
stopImmediatePropagation
boolean
falsestop-propagation
stopPropagation() on matched events (ignored for lifecycles).
stopPropagation
boolean
falsetransition-types
transitionTypes
tokenlist
<token>…nullvibrate-ms
navigator.vibrate). Empty / 0 uses a 20ms pulse.
vibrateMs
number
"20 (when attribute is present with no value)"is-active
route-href. Style with spa-a[is-active].
isActive
boolean
falsewas-active
route-href. Style outgoing links / card-expansion exits with spa-a[was-active].
wasActive
boolean
falseProvision (0)
Thisprovision property lives on the DOM node.
Read / watch it with Quark’s prop("provision"), or listen
for the neutron-provision event from app JS.
Events (0)
e.preventDefault() is not
synchronously called on the event.
Type
command event (HTML Command API): from a
<button command commandfor>, an <event-handler
command-name>, or a CommandEvent.
Recognized Elements (0)
Child or descendant elements are recognized by spa-a and relevant to its functionality.Styles (1)
all: revert-layer.
Valence.css ships this as .unstyled and .unstyled-all for the entire tree.
@custom-selector synonyms so custom elements can share
semantic styles. Element aliases match the host tag / .tag-* class; state
aliases match attributes (nested under the element alias).
:--spa-aspa-a, .tag-spa-aspa-manager
Attributes (15)
Role —option is configurable, state
is managed by the element (read-only), or hybrid which is both.
match-nested
route-href: /users matches /users and /users/42, never /usersx. Essential for layout routes and nested SPAs.
matchNested
boolean
falsemax-states
maxStates
number
nullno-transition
noTransition
boolean
falseoverscroll-behavior-x
none blocks horizontal overscroll; navigate also calls back / forward past the threshold.
overscrollBehaviorX
string
"none" | "navigate"nulloverscroll-x-threshold
overscrollXThreshold
number
40render-timeout
ready-on event is shown. Raise for slow remote templates.
renderTimeout
number
2000route-href
/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.
routeHref
string
<path pattern>nullroute-regex
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.
routeRegex
string
<RegExp source>nulltransition-delay
null starts synchronously.
transitionDelay
number
nulltransition-first-render
transitionFirstRender
boolean
falsedefault-title
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.
defaultTitle
string
nullactive-url
activeUrl
string
nullhas-rendered
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).
hasRendered
boolean
falseis-transitioning
isTransitioning
boolean
falselast-move
push / replace / back / forward). Pick CSS transition styles from this.
lastMove
string
"push" | "replace" | "back" | "forward"nullProvision (1)
Thisprovision property lives on the DOM node.
Read / watch it with Quark’s prop("provision"), or listen
for the neutron-provision event from app JS.
provision
provision
KitRouteData
Events (12)
e.preventDefault() is not
synchronously called on the event.
Type
spa-manager-back
back navigations.
Type
SpaManagerBackEvent
spa-manager-error
spa-route-error. event.detail mirrors the source error.
Type
SpaManagerErrorEvent
spa-manager-forward
forward navigations.
Type
SpaManagerForwardEvent
spa-manager-push
pushState navigations.
Type
SpaManagerPushEvent
spa-manager-rendered
Type
SpaManagerRenderedEvent
spa-manager-replace
replaceState navigations.
Type
SpaManagerReplaceEvent
spa-manager-transition
document.startViewTransition() is called.
Type
SpaManagerTransitionEvent
spa-manager-will-transition
preventDefault() holds the update until updateRoutes(true) is called (updateRoutes() runs it without a View Transition).
Type
SpaManagerWillTransitionEvent
transition-first-render) or a prerendered page's first update, the browser already animated the navigation, the batch only provisions or routes opt out.
spa-route-errorRenderableErrorEvent
spa-manager-error.
spa-route-provisionSpaRouteProvisionEvent
spa-route-renderRenderableRenderEvent
spa-route-unrenderRenderableUnrenderEvent
command event (HTML Command API): from a
<button command commandfor>, an <event-handler
command-name>, or a CommandEvent.
Recognized Elements (1)
Child or descendant elements are recognized by spa-manager and relevant to its functionality.
Styles (0)
all: revert-layer.
Valence.css ships this as .unstyled and .unstyled-all for the entire tree.
@custom-selector synonyms so custom elements can share
semantic styles. Element aliases match the host tag / .tag-* class; state
aliases match attributes (nested under the element alias).
Release notes (5)
0.5.0
- 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
- 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
- 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
0.2.0
- Update
match-nestedonspa-routeandspa-ato match its own path as well as child paths:route-href="/users"is active at/users, so a separate route for that path renders beside it and a 404 fallback stays off there - Add
is-activeandwas-activeonspa-awithmatch-nestedfor child paths, given the query of itsroute-hrefis part of the URL's; a plain link still matches exactly - Add relative
route-hrefvalues tospa-routeandspa-a:checkoutresolves against the page's<base>for matching,is-activeand the pushed URL - Update a reload to report
move: nullin the route data: nospa-manager-<move>event fires for it, and the saved scroll offset is restored - Update scroll handling so the outermost
spa-managerowns it: the browser's own scroll restoration is off while one is connected (the page's value returns after the last one disconnects), and aspa-routewithout aspa-managerno longer resets or restores scroll - Remove
spa-route.setScroll():spa-managerwrites scroll once per navigation, after its routes are ready - Update scroll reset to happen only when a route rendered: a push or replace that renders nothing (a query change on a
reuseroute) keeps its position, and a#fragmenttarget wins over the reset - Update nested
spa-managerelements to join the outermost manager's View Transition: one transition per navigation, and only the outermost manager firesspa-manager-will-transitionandspa-manager-transitionand carriesis-transitioning - Update
spa-manager-will-transitionandspa-manager-renderedto fire when no View Transition runs too (reduced motion, no API,no-transition, first paint), once per update chain, as a navigation that arrives before the update settles joins it;document.titlefollows each update; a nested manager'sspa-manager-renderedno longer bubbles, so a listener on the outermost manager or above gets one per chain, andpreventDefault()onspa-manager-will-transitionholds the update, the first paint included, untilupdateRoutes(true)resumes it (with a View Transition where the page allows one) orupdateRoutes()runs it without one - Update a
same-route="reuse"param change to run no View Transition: an update that only changes provisions no longer animates - Update a query change to provision the route again:
spa-route-provisionfires, the provision carries a newqueryobject, and asame-route="refresh"route renders again - Update
has-renderedonspa-managerto be set once the routes of the first update have rendered, inside that update: a loading shell hidden on[has-rendered]stays until the first view is on screen (it was set when the first update started) - Fix scroll restoration on back and forward: it no longer snaps before the transition, a restore is re-applied while late content shifts the page, for about 2 seconds or until the user scrolls, taps, types or drags the scrollbar, and a hash-only back or forward restores too
- Fix an issue where a
#fragmenttarget rendered by a route was not scrolled to on a push, a replace or a fresh load - Fix an issue where a
no-transitionroute in the same update as an animating route changed before the View Transition captured the old view - Fix an issue where a navigation started inside a
spa-manager-renderedlistener was dropped - Fix an issue where
spa-managerhung in a browser whosestartViewTransition()throws: the update now runs without a transition - Improve
spa-routereadiness: an activation is ready once its view rendered and its provision applied, so scroll, title and the View Transition wait for a slow template - Remove the unused
attemptTransitionDebouncedproperty fromspa-manager
0.1.1
- Ship only dist (and declared extras) in the npm tarball; drop build logs, tests and sources
Release notes
e.preventDefault() is not
synchronously called on the event.
all: revert-layer.
Valence.css ships this as .unstyled and .unstyled-all for the entire tree.
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">foo@bar.com</template><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>spa-a[is-active] {
font-weight: bold;
pointer-events: none;
text-decoration: none;
}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><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><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><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 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-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><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; }
}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><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><!-- 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><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><spa-manager transition-delay="50">
<!-- routes -->
</spa-manager>