@use "/shell" as *; @use "quark:list" as list; #release-notice { /* opens once: sheet re-runs restart a delay, so gate it on the fact it writes */ &:not([data-did-open]) { @delay 7000 { is-open: ""; data-did-open: ""; } } /* prevent clicks from bubbling to the sheet */ @on mouseup, click (stop-propagation); } provider-fetch[api-url*="package-metas/index.json"][is-success] { $package-indices: prop("provision").body; /* `excom.navGroup` moves a package out of its type's list, into that group */ $ungrouped: list.reject($package-indices.packages, "navGroup"); .package-links { [bind-elements] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "kit-element")); } [bind-element-bases] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "element-base")); } [bind-tools] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "tool")); } /* the standalone libraries: same rows, rendered flat (shell.css) */ [bind-libraries] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "library")); } [bind-library-group] ul:not(ul ul) { content: iterate(list.filter($package-indices.packages, "navGroup", "libraries")); } ul:not(ul ul) > li { $pkg: item.shortName; > spa-a { route-href: "/packages/#{item.shortName}"; } } /* one `content` rule per link: a second one would rewrite the first's text on every pass */ ul:not(ul ul, [bind-libraries] ul) > li > spa-a { content: item.shortName; } [bind-libraries] ul:not(ul ul) > li > spa-a { content: displayName(item.shortName); } /* packages whose docs span several pages: one collapsible group per section */ [bind-sections] { content: iterate(item.docSections); summary { content: item.title; } details > ul { content: iterate(item.docs); spa-a { route-href: "/packages/#{$pkg}/#{item.name}"; content: item.title; } } details:not(:has(spa-a[is-active])) { open: none; } } } /* page titles: the page, then the site. The docs home keeps the title in the document head; the other routes carry theirs in markup */ spa-route[is-active] { /* a site guide */ &[route-href$=":name"] { $title-guide: list.find($package-indices.docs, "name", $route.params.name); document-title: "#{$title-guide.title or $route.params.name} · Nucleus · docs"; } /* a package README */ &[route-href$=":packageName"] { document-title: "#{displayName($route.params.packageName)} · Nucleus · docs"; } /* one page of a package's docs */ &[route-href$=":docName"] { $title-package: list.find($package-indices.packages, "shortName", $route.params.packageName); document-title: "#{docTitle($title-package, $route.params.docName)} · #{displayName($route.params.packageName)} · Nucleus · docs"; } } /* the page's markdown file, for the footer link: the docs home and each guide the index lists, and a package's page whose index entry says `markdown` (build-docs-index sets it; the dev server's index has none, so no link there). siteDocHref and the path check say which URL is the page's own: the 404 and a package's doc pages have none */ > spa-manager[active-url] { $page-path: attr("active-url").split("#").at(0).split("?").at(0); $page-name: if($page-path == SITE_HOME: SITE_HOME_DOC; else: $page-path.split("/").at(-1)); $page-guide: list.find($package-indices.docs, "name", $page-name); $page-package: list.find($package-indices.packages, "shortName", $page-name); $page-markdown: if($page-guide and siteDocHref($page-guide.name) == $page-path: "/docs/#{$page-guide.name}.md"; $page-package.markdown and "#{SITE_BASE}/packages/#{$page-package.shortName}" == $page-path: "/#{$page-package.shortName}.md"); footer [bind-page-markdown] { href: $page-markdown; content: ternary($page-markdown, "Markdown version of this page"); } } } spa-route { $route: prop("provision"); /* paramless routes name their guide in markup (the docs home, `/`); /docs/:name gets it from params */ $route-doc-name: attr("data-doc-name"); } /* the desktop aside and the mobile sheet stamp the same nav template */ [data-site-nav] { details:has(spa-a[is-active]) { /* not kosher */ open: ""; } spa-a[is-active] { aria-current: ""; } spa-a:not([is-active]) { aria-current: none; } } /* mobile sheet: Escape / back gesture while open; a tapped link closes it */ #site-menu { &[is-open] dismiss-watcher { is-active: ""; } &:not([is-open]) dismiss-watcher { is-active: none; } @on click (target: "spa-a") { is-open: none; } } #site-menu-gesture { &:has(> #site-menu[is-open]) { progress-offset: 1; } &:not(:has(> #site-menu[is-open])) { progress-offset: 0; } @on gesture-handler-start { #site-menu { is-scrubbing: ""; } } @on gesture-handler-end { #site-menu { is-open: event.detail.snap == 1; is-scrubbing: none; } } } main { @on copy-source (handle: copySource); } #search-dialog[open] { > include-content { is-active: ""; /* for some reason this is necessary on first render */ @on include-content-did-render (handle: focusInput); } input[type="search"] { /* works on all subsequent opens */ autofocus: ""; } } #search-dialog:not([open]) input[type="search"] { autofocus: none; }

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.title follows the active route
  • History actions Push, replace, back, forward from a link

Installation

This package is available in the NucleusKit. Or it can be used by itself:

CDN Package Manager
<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
HTML Imports JS / CSS Imports
<!-- 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
View Dist Files

All exports:

.
  • types: ./dist/index.d.ts
  • import: ./dist/index.js
  • default: ./dist/index.js
./index
  • types: ./dist/index.d.ts
  • import: ./dist/index.js
  • default: ./dist/index.js
./index.bundle.min.css
  • default: ./dist/index.bundle.min.css
./index.css
  • default: ./dist/index.css
./index.js
  • types: ./dist/index.d.ts
  • import: ./dist/index.js
  • default: ./dist/index.js
./index.min
  • types: ./dist/index.d.ts
  • import: ./dist/index.min.js
  • default: ./dist/index.min.js
./index.min.css
  • default: ./dist/index.min.css
./index.min.js
  • types: ./dist/index.d.ts
  • import: ./dist/index.min.js
  • default: ./dist/index.min.js
./index.umd.min
  • types: ./dist/index.d.ts
  • default: ./dist/index.umd.min.js
./index.umd.min.js
  • types: ./dist/index.d.ts
  • default: ./dist/index.umd.min.js
./server
  • types: ./dist/server.d.ts
  • import: ./dist/server.js
  • default: ./dist/server.js
./server.js
  • types: ./dist/server.d.ts
  • import: ./dist/server.js
  • default: ./dist/server.js
./server.min
  • types: ./dist/server.d.ts
  • import: ./dist/server.min.js
  • default: ./dist/server.min.js
./server.min.js
  • types: ./dist/server.d.ts
  • import: ./dist/server.min.js
  • default: ./dist/server.min.js
./spa-a
  • types: ./dist/spa-a.d.ts
  • import: ./dist/spa-a.js
  • default: ./dist/spa-a.js
./spa-a.js
  • types: ./dist/spa-a.d.ts
  • import: ./dist/spa-a.js
  • default: ./dist/spa-a.js
./spa-a.min
  • types: ./dist/spa-a.d.ts
  • import: ./dist/spa-a.min.js
  • default: ./dist/spa-a.min.js
./spa-a.min.js
  • types: ./dist/spa-a.d.ts
  • import: ./dist/spa-a.min.js
  • default: ./dist/spa-a.min.js
./spa-a.umd.min
  • types: ./dist/spa-a.d.ts
  • default: ./dist/spa-a.umd.min.js
./spa-a.umd.min.js
  • types: ./dist/spa-a.d.ts
  • default: ./dist/spa-a.umd.min.js
./spa-manager
  • types: ./dist/spa-manager.d.ts
  • import: ./dist/spa-manager.js
  • default: ./dist/spa-manager.js
./spa-manager.js
  • types: ./dist/spa-manager.d.ts
  • import: ./dist/spa-manager.js
  • default: ./dist/spa-manager.js
./spa-manager.min
  • types: ./dist/spa-manager.d.ts
  • import: ./dist/spa-manager.min.js
  • default: ./dist/spa-manager.min.js
./spa-manager.min.js
  • types: ./dist/spa-manager.d.ts
  • import: ./dist/spa-manager.min.js
  • default: ./dist/spa-manager.min.js
./spa-manager.umd.min
  • types: ./dist/spa-manager.d.ts
  • default: ./dist/spa-manager.umd.min.js
./spa-manager.umd.min.js
  • types: ./dist/spa-manager.d.ts
  • default: ./dist/spa-manager.umd.min.js
./spa-route
  • types: ./dist/spa-route.d.ts
  • import: ./dist/spa-route.js
  • default: ./dist/spa-route.js
./spa-route.js
  • types: ./dist/spa-route.d.ts
  • import: ./dist/spa-route.js
  • default: ./dist/spa-route.js
./spa-route.min
  • types: ./dist/spa-route.d.ts
  • import: ./dist/spa-route.min.js
  • default: ./dist/spa-route.min.js
./spa-route.min.js
  • types: ./dist/spa-route.d.ts
  • import: ./dist/spa-route.min.js
  • default: ./dist/spa-route.min.js
./spa-route.umd.min
  • types: ./dist/spa-route.d.ts
  • default: ./dist/spa-route.umd.min.js
./spa-route.umd.min.js
  • types: ./dist/spa-route.d.ts
  • default: ./dist/spa-route.umd.min.js
./testing
  • types: ./dist/testing.d.ts
  • import: ./dist/testing.js
  • default: ./dist/testing.js
./testing.js
  • types: ./dist/testing.d.ts
  • import: ./dist/testing.js
  • default: ./dist/testing.js
./testing.min
  • types: ./dist/testing.d.ts
  • import: ./dist/testing.min.js
  • default: ./dist/testing.min.js
./testing.min.js
  • types: ./dist/testing.d.ts
  • import: ./dist/testing.min.js
  • default: ./dist/testing.min.js

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

spa-route

Attributes (23) Role — option is configurable, state is managed by the element (read-only), or hybrid which is both. Role Attribute Type Values Prop Default option bypass-cache
Skip the in-memory response cache (URL template-ref only). bypassCache renderable-element
boolean false
option document-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 null
option host-ref
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. hostRef renderable-element
string "shadow" | "iframe" | <CSS Selector> null
option is-fallback
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. isFallback
boolean false
option match-nested
Also match child paths of route-href: /users matches /users and /users/42, never /usersx. Essential for layout routes and nested SPAs. matchNested routable-element
boolean false
option no-transition
This route's render / unrender never starts a <spa-manager> View Transition. It still takes part in one another route starts. noTransition
boolean false
option persist-content
Reuse the same live nodes across unrender / render (held on _persistedTree) so form values, scroll position, and subtree state survive toggles. persistContent renderable-element
boolean false
option pre-fetch
When to fetch the template, independent of when it renders. "" aliases eager. idle never runs in a prerender. preFetch renderable-element
string "" | "eager" | "idle" | "lazy" "lazy"
option ready-on
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. readyOn renderable-element
string <Event Name> null
option route-href
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. routeHref routable-element
string <path pattern> null
option route-regex
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. routeRegex routable-element
string <RegExp source> null
option same-route
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. sameRoute
string "reuse" | "refresh" "reuse"
option scroll-reset-behavior
window.scrollTo behavior when <spa-manager> resets scroll for this route. Restores are instant. scrollResetBehavior
string "auto" | "instant" | "smooth" "instant"
option scroll-reset-x
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. scrollResetX
tokenlist "push" | "replace" | "back" | "forward" "push replace"
option scroll-reset-y
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. scrollResetY
tokenlist "push" | "replace" | "back" | "forward" "push replace"
option scroll-set-disabled
While this route is active, <spa-manager> neither resets nor restores scroll. scrollSetDisabled
boolean false
option template-ref
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 templateRef renderable-element
string <CSS Selector> | <URL> ":scope > template"
hybrid is-active
Master switch. Set to load (if needed) and render; unset to unrender. Drive from visibility, route match, hover, etc. isActive renderable-element
boolean false
state delaying-ready
Between render and the matching ready-on event (or a failed load). Hook with CSS for coordinated paints / view transitions. delayingReady renderable-element
boolean false
state did-load
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. didLoad renderable-element
boolean false
state is-error
Latest template fetch rejected (excluding abort). Fires with the error event. isError renderable-element
boolean false
state is-loading
Template fetch in flight. isLoading renderable-element
boolean false
state was-active
Set briefly while navigating away. Style outgoing screens / card-expansion exits with spa-route[was-active]. wasActive
boolean false
Provision (1) This provision property lives on the DOM node. Read / watch it with Quark’s prop("provision"), or listen for the neutron-provision event from app JS. Property Type provision
Active route payload for this activation (null when inactive). Not reflected as an attribute. provision
SpaRouteProvision
Events (8)
Dispatches — events that spa-route fires and their default actions. Dispatch Default Action
A "default action" is subsequent logic executed by the element if e.preventDefault() is not synchronously called on the event.
Name spa-route-aborted
Dispatched when an in-flight load / ready wait is canceled because is-active was unset (via startTeardown). renderable-element

Type RenderableAbortedEvent
Name spa-route-did-render
Dispatched after the template content has actually been placed into the host. renderable-element

Type RenderableDidRenderEvent
Name spa-route-did-unrender
Dispatched after rendered children have been removed from the host. renderable-element

Type RenderableDidUnrenderEvent
Name spa-route-error
Dispatched when the template promise rejects with anything other than an AbortError. renderable-element

Type RenderableErrorEvent
Name spa-route-provision
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.

Type SpaRouteProvisionEvent
Invokes event.detail() to apply the new provision.
Name spa-route-render
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. renderable-element

Type RenderableRenderEvent
Invokes event.detail() to load (if needed) and render the template into the host.
Name spa-route-unrender
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. renderable-element

Type RenderableUnrenderEvent
Invokes event.detail() to remove rendered children from the host.
Listeners — events that spa-route listens for which will trigger subsequent actions. Listener Type Action
Commands — verbs spa-route accepts as a command event (HTML Command API): from a <button command commandfor>, an <event-handler command-name>, or a CommandEvent. Command Action --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. renderable-element
Recognized Elements (3) Child or descendant elements are recognized by spa-route and relevant to its functionality. Element Relationship Required iframe[data-render-host]
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. renderable-element
child false
template
Screen content. Cloned (or reused with persist-content) on activation.
child true
template
Optional immediate <template> child used when template-ref is the default ":scope > template". Not required when template-ref points at a selector or URL elsewhere. renderable-element
child false
Styles (1)
Classes — optional classes that change the appearance of spa-route. Class Description
Variables — public CSS variables for theming spa-route. Opt-out:
If you wish to opt-out of these styles on a case-to-case basis, use property all: revert-layer. Valence.css ships this as .unstyled and .unstyled-all for the entire tree.
Variable Syntax Default
Aliases — @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). Alias Kind Matches :--spa-route element spa-route, .tag-spa-route

spa-a

Attributes (22) Role — option is configurable, state is managed by the element (read-only), or hybrid which is both. Role Attribute Type Values Prop Default option delay-ms
Delay handling by this many milliseconds. delayMs listenable-element
number null
option document-title
Sets document.title after navigation. documentTitle
string null
option host-ref
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. hostRef listenable-element
string <CSS Selector> | "window" | "document" | "html" | "body" | "head" null
option is-debounced
With delay-ms, coalesce bursts into one trailing call (debounce). isDebounced listenable-element
boolean false
option keycode-filter
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. keycodeFilter listenable-element
tokenlist <key | mod+key>… null
option listen-for
Space-separated event names to listen for. Defaults to click when unset (and no lifecycle list is set). listenFor listenable-element
tokenlist <EventName>… null
option listen-for-lifecycle
Space-separated element lifecycles to handle. listenForLifecycle listenable-element
tokenlist "connected" | "disconnected" | "adopted" null
option listen-once
Handle each distinct event name / lifecycle at most once. listenOnce listenable-element
boolean false
option match-hash
Require the URL hash to match when setting is-active. matchHash
boolean false
option match-nested
Also match child paths of route-href: /users matches /users and /users/42, never /usersx. Essential for layout routes and nested SPAs. matchNested routable-element
boolean false
option pathname-filter
Only handle when location.pathname is one of these values — route-aware behaviors without a separate router element. pathnameFilter listenable-element
tokenlist <pathname>… null
option prevent-default
Call preventDefault() on matched events (ignored for lifecycles). preventDefault listenable-element
boolean false
option route-action
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. routeAction
string "push" | "replace" | "back" | "forward" "push"
option route-href
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. routeHref routable-element
string <path pattern> null
option route-regex
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. routeRegex routable-element
string <RegExp source> null
option selector-filter
Only handle events whose event.target matches this CSS selector. Does not support :scope in the selector. selectorFilter listenable-element
string <CSS Selector> null
option stop-immediate-propagation
Call stopImmediatePropagation() on matched events (ignored for lifecycles). stopImmediatePropagation listenable-element
boolean false
option stop-propagation
Call stopPropagation() on matched events (ignored for lifecycles). stopPropagation listenable-element
boolean false
option transition-types
Space-separated View Transition types for this navigation (CSS View Transitions API). Useful for shared-element morphs like card expansion. transitionTypes
tokenlist <token>… null
option vibrate-ms
Vibrate on handle (navigator.vibrate). Empty / 0 uses a 20ms pulse. vibrateMs listenable-element
number "20 (when attribute is present with no value)"
state is-active
Current URL matches route-href. Style with spa-a[is-active]. isActive
boolean false
state was-active
Previous URL matched route-href. Style outgoing links / card-expansion exits with spa-a[was-active]. wasActive
boolean false
Provision (0) This provision property lives on the DOM node. Read / watch it with Quark’s prop("provision"), or listen for the neutron-provision event from app JS. Property Type
Events (0)
Dispatches — events that spa-a fires and their default actions. Dispatch Default Action
A "default action" is subsequent logic executed by the element if e.preventDefault() is not synchronously called on the event.
Listeners — events that spa-a listens for which will trigger subsequent actions. Listener Type Action
Commands — verbs spa-a accepts as a command event (HTML Command API): from a <button command commandfor>, an <event-handler command-name>, or a CommandEvent. Command Action
Recognized Elements (0) Child or descendant elements are recognized by spa-a and relevant to its functionality. Element Relationship Required
Styles (1)
Classes — optional classes that change the appearance of spa-a. Class Description
Variables — public CSS variables for theming spa-a. Opt-out:
If you wish to opt-out of these styles on a case-to-case basis, use property all: revert-layer. Valence.css ships this as .unstyled and .unstyled-all for the entire tree.
Variable Syntax Default
Aliases — @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). Alias Kind Matches :--spa-a element spa-a, .tag-spa-a

spa-manager

Attributes (15) Role — option is configurable, state is managed by the element (read-only), or hybrid which is both. Role Attribute Type Values Prop Default option match-nested
Also match child paths of route-href: /users matches /users and /users/42, never /usersx. Essential for layout routes and nested SPAs. matchNested routable-element
boolean false
option max-states
Cap retained router history states (scroll positions, transition types, etc.). maxStates
number null
option no-transition
Disable View Transitions for every child route. noTransition
boolean false
option overscroll-behavior-x
Touch edge-swipe on touch devices. none blocks horizontal overscroll; navigate also calls back / forward past the threshold. overscrollBehaviorX
string "none" | "navigate" null
option overscroll-x-threshold
Edge inset (px) where a touch start counts as an edge swipe. overscrollXThreshold
number 40
option render-timeout
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. renderTimeout
number 2000
option route-href
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. routeHref routable-element
string <path pattern> null
option route-regex
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. routeRegex routable-element
string <RegExp source> null
option transition-delay
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. transitionDelay
number null
option transition-first-render
Animate the very first activation (cold load) with a View Transition. Off by default so the initial paint is instant. transitionFirstRender
boolean false
hybrid default-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 null
state active-url
URL of the currently active route. activeUrl
string null
state has-rendered
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). hasRendered
boolean false
state is-transitioning
A View Transition is in flight. isTransitioning
boolean false
state last-move
Last navigation direction (push / replace / back / forward). Pick CSS transition styles from this. lastMove
string "push" | "replace" | "back" | "forward" null
Provision (1) This provision property lives on the DOM node. Read / watch it with Quark’s prop("provision"), or listen for the neutron-provision event from app JS. Property Type provision
Current route payload. Not reflected as an attribute. provision
KitRouteData
Events (12)
Dispatches — events that spa-manager fires and their default actions. Dispatch Default Action
A "default action" is subsequent logic executed by the element if e.preventDefault() is not synchronously called on the event.
Name spa-manager-back
On back navigations.

Type SpaManagerBackEvent
Name spa-manager-error
Re-emitted when a child fires spa-route-error. event.detail mirrors the source error.

Type SpaManagerErrorEvent
Name spa-manager-forward
On forward navigations.

Type SpaManagerForwardEvent
Name spa-manager-push
On pushState navigations.

Type SpaManagerPushEvent
Name spa-manager-rendered
After the update (and its transition) finishes: routes settled, title and scroll applied. Nested managers whose routes took part fire it too, without bubbling.

Type SpaManagerRenderedEvent
Name spa-manager-replace
On replaceState navigations.

Type SpaManagerReplaceEvent
Name spa-manager-transition
Outermost manager, after document.startViewTransition() is called.

Type SpaManagerTransitionEvent
Name spa-manager-will-transition
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).

Type SpaManagerWillTransitionEvent
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.
Listeners — events that spa-manager listens for which will trigger subsequent actions. Listener Type Action spa-route-error RenderableErrorEvent
Re-emits as spa-manager-error.
spa-route-provision SpaRouteProvisionEvent
Queues the child's route data update into the next batched update.
spa-route-render RenderableRenderEvent
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
Queues the child's unrender callback (runs before renders so the outgoing route leaves first).
Commands — verbs spa-manager accepts as a command event (HTML Command API): from a <button command commandfor>, an <event-handler command-name>, or a CommandEvent. Command Action
Recognized Elements (1) Child or descendant elements are recognized by spa-manager and relevant to its functionality. Element Relationship Required spa-route
Routes this manager coordinates. Their render lifecycle is intercepted and batched into one view transition per navigation.
descendant true
Styles (0)
Classes — optional classes that change the appearance of spa-manager. Class Description
Variables — public CSS variables for theming spa-manager. Opt-out:
If you wish to opt-out of these styles on a case-to-case basis, use property all: revert-layer. Valence.css ships this as .unstyled and .unstyled-all for the entire tree.
Variable Syntax Default
Aliases — @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). Alias Kind Matches
Release notes (5)

0.5.0

  • 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

  • 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

  • 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

0.2.0

  • Update match-nested on spa-route and spa-a to 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-active and was-active on spa-a with match-nested for child paths, given the query of its route-href is part of the URL's; a plain link still matches exactly
  • Add relative route-href values to spa-route and spa-a: checkout resolves against the page's <base> for matching, is-active and the pushed URL
  • Update a reload to report move: null in the route data: no spa-manager-<move> event fires for it, and the saved scroll offset is restored
  • Update scroll handling so the outermost spa-manager owns it: the browser's own scroll restoration is off while one is connected (the page's value returns after the last one disconnects), and a spa-route without a spa-manager no longer resets or restores scroll
  • Remove spa-route.setScroll(): spa-manager writes 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 reuse route) keeps its position, and a #fragment target wins over the reset
  • Update nested spa-manager elements to join the outermost manager's View Transition: one transition per navigation, and only the outermost manager fires spa-manager-will-transition and spa-manager-transition and carries is-transitioning
  • Update spa-manager-will-transition and spa-manager-rendered to 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.title follows each update; a nested manager's spa-manager-rendered no longer bubbles, so a listener on the outermost manager or above gets one per chain, and preventDefault() on spa-manager-will-transition holds the update, the first paint included, until updateRoutes(true) resumes it (with a View Transition where the page allows one) or updateRoutes() 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-provision fires, the provision carries a new query object, and a same-route="refresh" route renders again
  • Update has-rendered on spa-manager to 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 #fragment target rendered by a route was not scrolled to on a push, a replace or a fresh load
  • Fix an issue where a no-transition route 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-rendered listener was dropped
  • Fix an issue where spa-manager hung in a browser whose startViewTransition() throws: the update now runs without a transition
  • Improve spa-route readiness: 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 attemptTransitionDebounced property from spa-manager

0.1.1

  • Ship only dist (and declared extras) in the npm tarball; drop build logs, tests and sources
View Source

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-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 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):

<!-- 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
<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>

Beta. The Nucleus Stack is in beta for a few weeks until features are stabilized and optimized.

Thanks — we'll email you when it ships.

Something went wrong. Please try again.