···1717| v0.2.0 | ✓ | [Reactive Attributes & Event Modifiers](#reactive-attributes--event-modifiers) |
1818| v0.3.0 | ✓ | [Global State](#global-state) |
1919| v0.4.0 | ✓ | [Animation & Transitions](#animation--transitions) |
2020-| v0.5.0 | | [History API Routing Plugin](#history-api-routing-plugin) |
2121-| | | [Navigation & History Management](#navigation--history-management) |
2020+| v0.5.0 | ✓ | [Navigation & History API Routing](#navigation--history-api-routing) |
2221| | ✓ | [Refactor](#evaluator--binder-hardening) |
2323-| v0.6.0 | | [Background Requests & Reactive Polling](#background-requests--reactive-polling) |
2424-| v0.7.0 | | [Streaming & Patch Engine](#streaming--patch-engine) |
2525-| v0.8.0 | | PWA Capabilities |
2626-| | | [Persistence & Offline](#persistence--offline) |
2222+| | | Update demo to be a multi page application with routing plugin |
2323+| v0.5.1 | | Support `voltx-` & `vx-` attributes: recommend `vx-` |
2424+| v0.5.2 | | Switch to `data-voltx` |
2525+| v0.5.3 | | [Background Requests & Reactive Polling](#background-requests--reactive-polling) |
2626+| v0.5.4 | | [Streaming & Patch Engine](#streaming--patch-engine) |
2727+| v0.5.5 | | PWA Capabilities |
2828+| v0.5.6 | | [Persistence & Offline](#persistence--offline) |
2929+| | | |
2730| v0.9.0 | | [Inspector & Developer Tools](#inspector--developer-tools) |
2831| v1.0.0 | | [Stable Release](#stable-release) |
2932···5861### Backend Integration & HTTP Actions
59626063**Goal:** Provide backend integration with declarative HTTP requests and responses.
6161-**Outcome:** Volt.js can make backend requests and update the DOM
6262-**Summary:** Declarative HTTP directives (data-volt-get|post|put|patch|delete) with swap strategies, loading indicators, error handling, and form serialization integrate Volt.js seamlessly with backend APIs.
6464+**Outcome:** VoltX.js can make backend requests and update the DOM
6565+**Summary:** Declarative HTTP directives (data-volt-get|post|put|patch|delete) with swap strategies, loading indicators, error handling, and form serialization integrate VoltX.js seamlessly with backend APIs.
63666467### Markup Based Reactivity
6568···75787679### Reactive Attributes & Event Modifiers
77807878-**Goal:** Extend Volt.js with expressive attribute patterns and event options for fine-grained control.
7979-**Outcome:** Volt.js supports rich declarative behaviors and event semantics built entirely on standard DOM APIs.
8181+**Goal:** Extend VoltX.js with expressive attribute patterns and event options for fine-grained control.
8282+**Outcome:** VoltX.js supports rich declarative behaviors and event semantics built entirely on standard DOM APIs.
8083**Summary:** Introduced expressive attribute patterns and event modifiers for precise DOM and input control, for fine-grained declarative behavior entirely through standard DOM APIs.
81848285### Global State
83868487**Goal:** Implement store/context pattern
8585-**Outcome:** Volt.js provides intuitive global state management
8888+**Outcome:** VoltX.js provides intuitive global state management
8689**Summary:** The scope injects helpers like `$origin`, `$scope`, `$pulse`, `$store`, `$uid`, `$probe`, `$pins`, and `$arc`, giving templates access to global state, microtask scheduling, deterministic IDs, element refs, and custom event dispatch without leaving declarative markup.
87908891### Animation & Transitions
89929093**Goal:** Add animation primitives for smooth UI transitions with Alpine/Datastar parity.
9191-**Outcome:** Volt.js enables declarative animations and view transitions alongside reactivity.
9494+**Outcome:** VoltX.js enables declarative animations and view transitions alongside reactivity.
9295**Summary:** The surge directive ships fade/slide/scale/blur presets with duration and delay overrides, per-phase enter/leave control, and easing helpers, while the shift plugin applies reusable keyframe animations—both composable with `data-volt-if`/`data-volt-show` as showcased in the animations demo.
93969797+### Navigation & History API Routing
9898+9999+**Goal:** Provide seamless client-side navigation with a first-class History API router.
100100+**Outcome:** VoltX.js delivers accessible, stateful navigation with clean URLs and signal-driven routing.
101101+**Summary:** Added seamless client-side navigation through a History API–powered router, enabling declarative routing with `data-volt-navigate` and `data-volt-url`, reactive URL synchronization, smooth transitions, scroll and focus restoration, dynamic route parsing, and full integration with signals and the View Transition API for accessible, stateful navigation and clean URLs.
102102+94103## To-Do
9510496105### Streaming & Patch Engine
9710698107**Goal:** Enable real-time updates via SSE/WebSocket streaming with intelligent DOM patching.
9999-**Outcome:** Volt.js can receive and apply live updates from the server
108108+**Outcome:** VoltX.js can receive and apply live updates from the server
100109**Deliverables:**
101110 - Server-Sent Events (SSE) integration
102111 - `data-volt-flow` attribute for SSE endpoints
···109118### Persistence & Offline
110119111120**Goal:** Introduce persistent storage and offline-first behaviors.
112112-**Outcome:** Resilient state persistence and offline replay built into Volt.js.
121121+**Outcome:** Resilient state persistence and offline replay built into VoltX.js.
113122**Deliverables:**
114123 - ✓ Persistent signals (localStorage, sessionStorage, indexedDb)
115124 - ✓ Storage plugin (`data-volt-persist`)
···126135127136### Background Requests & Reactive Polling
128137129129-**Goal:** Enable declarative background data fetching and periodic updates within the Volt.js runtime.
130130-**Outcome:** Volt.js elements can fetch or refresh data automatically based on time, visibility, or reactive conditions.
138138+**Goal:** Enable declarative background data fetching and periodic updates within the VoltX.js runtime.
139139+**Outcome:** VoltX.js elements can fetch or refresh data automatically based on time, visibility, or reactive conditions.
131140**Deliverables:**
132141 - `data-volt-visible` for fetching when an element enters the viewport (`IntersectionObserver`)
133142 - `data-volt-fetch` attribute for declarative background requests
···137146 - Integration hooks for loading and pending states
138147 - Background task scheduler with priority management
139148140140-### Navigation & History Management
141141-142142-**Goal:** Introduce seamless client-side navigation and stateful history control using web standards.
143143-**Outcome:** Volt.js provides enhanced navigation behavior with minimal overhead and full accessibility support.
144144-**Deliverables:**
145145- - `data-volt-navigate` for intercepting link and form actions
146146- - Integration with the History API (`pushState`, `replaceState`, `popState`)
147147- - Reactive synchronization of route and signal state
148148- - Smooth page and fragment transitions coordinated with Volt’s signal system
149149- - Native back/forward button support
150150- - Scroll position persistence and restoration
151151- - Preloading of linked resources on hover or idle
152152- - `data-volt-url` for declarative history updates
153153- - View Transition API integration for animated route changes
154154-155155-### History API Routing Plugin
156156-157157-**Goal:** Deliver a first-class path-based router that leverages the History API while staying signal-driven.
158158-**Outcome:** Volt apps can opt into clean URLs (no hash) with back/forward support, nested segments, and SSR-friendly hydration.
159159-**Deliverables:**
160160- - `data-volt-url="history:signal"` mode with path + search preservation and optional base path configuration
161161- - Route parsing utilities for dynamic params (e.g. `/blog/:slug`) and programmatic redirects
162162- - Scroll restoration hooks and focus management aligned with `navigation` and `popstate` events
163163- - Integration tests covering pushState navigation, deep links, and server-rendered bootstraps
164164- - Documentation updates in `docs/usage/routing.md` contrasting hash vs. history strategies
165165-166149### Inspector & Developer Tools
167150168151**Goal:** Improve developer experience and runtime introspection.
169169-**Outcome:** First-class developer ergonomics; Volt.js is enjoyable to debug and extend.
152152+**Outcome:** First-class developer ergonomics; VoltX.js is enjoyable to debug and extend.
170153**Deliverables:**
171154 - Developer overlay for inspecting signals, subscriptions, and effects
172155 - Dev logging toggle (`Volt.debug = true`)
···180163### Stable Release
181164182165**Goal:** Prepare & ship the stable release
183183-**Outcome:** Volt.js 1.0 is stable, documented, performant, and ready for production.
166166+**Outcome:** VoltX.js 1.0 is stable, documented, performant, and ready for production.
184167**Deliverables:**
185168 - ✓ Documentation site (VitePress)
186169 - Full API reference with examples
+82
docs/plugins/navigate.md
···11+---
22+outline: deep
33+---
44+55+# Navigate Plugin
66+77+The navigate plugin upgrades plain links and forms with client-side navigation, History API integration, and optional
88+View Transition animations. It keeps your DOM-driven pages feeling app-like without giving up regular hyperlinks.
99+1010+## Quick Start
1111+1212+```html
1313+<!-- Link-based navigation -->
1414+<a href="/about" data-volt-navigate>About</a>
1515+1616+<!-- Form submissions (GET only) -->
1717+<form action="/search" method="get" data-volt-navigate>
1818+ <input name="q" placeholder="Search..." />
1919+ <button type="submit">Go</button>
2020+</form>
2121+```
2222+2323+`data-volt-navigate` applies to `<a>` and `<form>` elements. Links use their `href`; forms default to `action` (or the current pathname) and serialize inputs into the query string for GET submissions.
2424+2525+## Modifiers
2626+2727+Attach modifiers with dot notation or suffixed attribute names (`data-volt-navigate-replace`).
2828+2929+- `replace` - call `history.replaceState` instead of `pushState`; good for redirects or idempotent flows.
3030+- `prefetch` - issue a `<link rel="prefetch">` when the element is hovered or focused to warm the cache.
3131+- `notransition` - skip View Transition API usage, falling back to an immediate DOM swap.
3232+3333+```html
3434+<a href="/settings" data-volt-navigate-notransition>Settings</a>
3535+<a href="/pricing" data-volt-navigate-prefetch>Pricing</a>
3636+<a href="/welcome" data-volt-navigate-replace>Skip intro</a>
3737+```
3838+3939+## View Transitions
4040+4141+By default the plugin wraps navigations in `startViewTransition` using the `"page-transition"` name. Use the
4242+`notransition` modifier to disable it per element, or switch names when navigating imperatively:
4343+4444+```ts
4545+import { navigate } from "volt/plugins/navigate";
4646+4747+await navigate("/projects/42", { transitionName: "project-detail" });
4848+```
4949+5050+## Programmatic APIs
5151+5252+Import helpers straight from `lib/src/plugins/navigate.ts` (re-exported by the runtime build):
5353+5454+```ts
5555+import { goBack, goForward, initNavigationListener, navigate, redirect } from "volt/plugins/navigate";
5656+5757+await navigate("/projects/123", { replace: false, transitionName: "detail" });
5858+redirect("/login"); // always uses replace
5959+goBack();
6060+goForward();
6161+6262+// Restore scroll on history navigation
6363+const stop = initNavigationListener();
6464+// Later: stop();
6565+```
6666+6767+`initNavigationListener` should run once during boot to restore scroll positions when users hit the back/forward
6868+buttons. It also emits a `volt:popstate` event mirroring the browser’s `popstate`.
6969+7070+## Events
7171+7272+Every navigation dispatches:
7373+7474+- `volt:navigate` after the History API call, with `{ url, replace }` in `event.detail`.
7575+- `volt:popstate` from the history listener, with `{ state }` in `event.detail`.
7676+7777+Use these to re-fetch data, invalidate caches, or sync routing signals for plugins such as `url`.
7878+7979+## Handling External Links
8080+8181+Navigation only intercepts same-origin URLs and primary-button clicks without modifier keys.
8282+External links, middle clicks, and `target="_blank"` continue to behave like normal browser navigation, preserving accessibility expectations.
+91
docs/plugins/shift.md
···11+---
22+outline: deep
33+---
44+55+# Shift Plugin
66+77+The shift plugin applies reusable CSS keyframe animations to any element. Use it for attention-grabbing nudges, loading
88+states, or signal-driven feedback without writing imperative animation code.
99+1010+## Quick Start
1111+1212+```html
1313+<!-- Run bounce once on mount -->
1414+<button data-volt-shift="bounce">Click me</button>
1515+1616+<!-- Infinite pulse animation -->
1717+<span data-volt-shift="pulse">Loading…</span>
1818+```
1919+2020+When an element mounts the plugin pulls a preset from the animation registry and calls the Web Animations API with the
2121+configured keyframes, duration, iterations, and easing. Users with `prefers-reduced-motion` skip the animation entirely.
2222+2323+## Built-in Presets
2424+2525+Volt ships with several presets you can reference immediately:
2626+2727+- `bounce`-snappy vertical movement for call-to-action buttons.
2828+- `shake`-horizontal wiggle, ideal for error indicators.
2929+- `pulse`-scale and opacity pulse that repeats forever.
3030+- `spin`-continuous 360° rotation.
3131+- `flash`-blinking opacity effect.
3232+3333+## Custom Duration and Iterations
3434+3535+Add dot-separated numbers after the preset to override timing settings.
3636+3737+```html
3838+<!-- 1 second bounce repeated three times -->
3939+<div data-volt-shift="bounce.1000.3">Triple bounce</div>
4040+```
4141+4242+The first number is duration in milliseconds; the optional second number controls iteration count. Omitted values fall
4343+back to the preset configuration.
4444+4545+## Reacting to Signals
4646+4747+Prefix the binding with a signal path to trigger the animation whenever the signal changes from its previous value to a
4848+truthy value.
4949+5050+```html
5151+<div data-volt-shift="form.error:shake">Please fix the highlighted fields</div>
5252+```
5353+5454+For the snippet above:
5555+5656+- `form.error` is resolved via `ctx.findSignal`.
5757+- The element animates the first time the signal evaluates truthy.
5858+- Subsequent updates run the animation whenever the value toggles and remains truthy.
5959+6060+## Registering Custom Animations
6161+6262+Use the programmatic API to add, inspect, or remove presets.
6363+6464+```ts
6565+import { getRegisteredAnimations, registerAnimation } from "volt/plugins/shift";
6666+6767+registerAnimation("wiggle", {
6868+ keyframes: [
6969+ { offset: 0, transform: "rotate(0deg)" },
7070+ { offset: 0.25, transform: "rotate(-5deg)" },
7171+ { offset: 0.75, transform: "rotate(5deg)" },
7272+ { offset: 1, transform: "rotate(0deg)" },
7373+ ],
7474+ duration: 300,
7575+ iterations: 2,
7676+ timing: "ease-in-out",
7777+});
7878+7979+console.log(getRegisteredAnimations()); // ["bounce", "shake", ..., "wiggle"]
8080+```
8181+8282+Other helpers:
8383+8484+- `getAnimation(name)` - fetch the preset definition.
8585+- `hasAnimation(name)` - check existence.
8686+- `unregisterAnimation(name)` - remove custom presets (built-ins cannot be deleted).
8787+8888+## Cleanup
8989+9090+Shift automatically cancels the underlying `element.animate` call on completion and removes subscriptions registered via signals.
9191+No additional teardown is required beyond VoltX’s normal plugin lifecycle.
+76
docs/plugins/surge.md
···11+---
22+outline: deep
33+---
44+55+# Surge Plugin
66+77+The surge plugin powers enter/leave transitions for conditional DOM. It combines CSS property interpolation, optional
88+View Transitions, and a signal-aware state machine to animate elements appearing or disappearing.
99+1010+## Quick Start
1111+1212+```html
1313+<section data-volt-surge="isOpen:fade">
1414+ <p>Panel content...</p>
1515+</section>
1616+```
1717+1818+- `isOpen` resolves to a signal. Falsy values hide the element (`display: none`).
1919+- The `fade` preset runs when the signal flips to truthy and again in reverse when it returns to falsy.
2020+2121+## Presets and Overrides
2222+2323+`data-volt-surge="presetName"` attaches a preset from the transition registry without watching a signal. Combine with
2424+granular variants when you need independent enter/leave control:
2525+2626+```html
2727+<article
2828+ data-volt-surge="show:slide-down.400"
2929+ data-volt-surge:enter="fade.200"
3030+ data-volt-surge:leave="scale-down.250">
3131+ ...
3232+</article>
3333+```
3434+3535+- `data-volt-surge:enter` and `:leave` use the same parsing logic as the core transition helpers (`duration.delay`
3636+ suffixes honored via `parseTransitionValue` and `applyOverrides`).
3737+- When both shorthand and phase-specific attributes exist, the phase-specific value wins.
3838+3939+## Signal Lifecycle
4040+4141+When bound to a signal the plugin:
4242+4343+1. Checks the initial signal value. Falsy values hide the element immediately.
4444+2. Subscribes to the signal and debounces concurrent transitions so rapid toggles stay smooth.
4545+3. Uses `execEnter`/`execLeave` helpers to apply styles, classes, delays, and easing.
4646+4. Cleans up the subscription when the element unmounts.
4747+4848+The underlying transition promise resolves before the element is marked visible or hidden, ensuring sequential updates
4949+remain ordered.
5050+5151+## View Transitions Integration
5252+5353+Surge participates in the View Transition API whenever the preset’s config opts in (default). Calling variants like
5454+`slide-down.400` runs inside `withViewTransition`, making swapping sections feel native. Add `:notransition` to your
5555+navigate bindings if you need to avoid double animations when combining plugins.
5656+5757+## Manual Execution
5858+5959+Volt’s runtime calls the internal helpers automatically for keyed iterations and DOM diffs. If you render content
6060+manually, you can trigger the same behavior:
6161+6262+```ts
6363+import { executeSurgeEnter, executeSurgeLeave, hasSurge } from "volt/plugins/surge";
6464+6565+if (hasSurge(el)) {
6666+ await executeSurgeEnter(el);
6767+}
6868+```
6969+7070+`hasSurge` checks whether the element owns any surge metadata (signal config or phase overrides) before attempting an
7171+explicit enter/leave.
7272+7373+## Reduced Motion
7474+7575+When the user prefers reduced motion the plugin skips transitions, applies the `to` styles or classes immediately, and
7676+avoids firing View Transition effects. This keeps the animation accessible without extra work on your part.
+78
docs/plugins/url.md
···11+---
22+outline: deep
33+---
44+55+# URL Plugin
66+77+The url plugin bridges VoltXsignals with the browser’s address bar. Use it to hydrate page state from query parameters, mirror form inputs into the URL, or power hash/history based routing.
88+99+## Quick Start
1010+1111+```html
1212+<!-- Populate a signal on mount -->
1313+<div data-volt-url="read:filters.category"></div>
1414+1515+<!-- Two-way sync between location.search and a signal -->
1616+<input name="q" data-volt-url="sync:searchQuery" />
1717+```
1818+1919+Each binding follows `mode:signalPath[:basePath]`. The plugin resolves the signal via `ctx.findSignal` and wires it to one of the strategies below.
2020+2121+## Modes
2222+2323+### `read`
2424+2525+One-way hydration. On mount the plugin reads `?signalPath=value` and assigns it to the signal. Later signal updates do not modify the URL.
2626+2727+```html
2828+<div data-volt-url="read:filters.status"></div>
2929+```
3030+3131+### `sync`
3232+3333+Bidirectional query-string sync. The plugin:
3434+3535+1. Seeds the signal from `?signalPath=...`.
3636+2. Subscribes to the signal and pushes URL updates (debounced) via `history.pushState`.
3737+3. Listens for `popstate` to keep the signal in sync when the user navigates back/forward.
3838+3939+The `serializeValue`/`deserializeValue` helpers support strings, numbers, booleans, JSON payloads, and empty values.
4040+4141+```html
4242+<input placeholder="Search…" data-volt-url="sync:search" />
4343+```
4444+4545+When the input changes the URL updates to `?search=...`. Clearing the input removes the parameter.
4646+4747+### `hash`
4848+4949+Two-way binding to `window.location.hash`. Useful for simple client-side routing or tab selection:
5050+5151+```html
5252+<nav data-volt-url="hash:activeTab"></nav>
5353+```
5454+5555+- Updates to the signal call `history.pushState` with the new hash.
5656+- `hashchange` events hydrate the signal when users edit the URL manually.
5757+5858+### `history`
5959+6060+Full routing synchronization with `pathname + search`. Optionally trim a base path when syncing:
6161+6262+```html
6363+<main data-volt-url="history:route:/app"></main>
6464+```
6565+6666+- The signal receives `/` when the user is at `/app`.
6767+- Pushing a new value updates the URL and dispatches `volt:navigate`.
6868+- Browser back/forward emits `volt:popstate` and refreshes the signal.
6969+7070+Combine this mode with the navigate plugin to keep a global router signal in lockstep with address bar changes.
7171+7272+## Handling Missing Signals
7373+7474+If the plugin cannot resolve `signalPath` it logs a descriptive error and aborts. Ensure your scope exports naming matches when wiring bindings.
7575+7676+## Cleanup
7777+7878+Each mode registers the necessary event listeners (`popstate`, `hashchange`, `volt:navigate`) and unsubscribes during cleanup, so no manual teardown is required. All timers are also cleared to prevent stale updates.
+282-20
docs/usage/routing.md
···2233Client-side routing lets VoltX applications feel like multi-page sites without full page reloads.
44The `url` plugin keeps a signal in sync with the browser URL so your application can react declaratively to route changes.
55-This guide walks through building a hash-based router that swaps entire page sections while preserving the advantages
66-of VoltX's signal system.
55+This guide walks through building both hash-based and History API routers that swap entire page sections while preserving the advantages of VoltX's signal system.
7687## Why?
98109- **Zero reloads:** Route changes update `window.location.hash` via `history.pushState`, so the browser history stack is maintained while the document stays mounted and stateful widgets keep their values.
1110- **Shareable URLs:** Users can refresh or share a link such as `/#/pricing` and land directly on the same view.
1211- **Declarative rendering:** Routing is just another signal; templates choose what to display with conditional bindings like `data-volt-if` or `data-volt-show`.
1313-- **Simple integration:** No extra router dependency is required—register the plugin once and opt-in per signal.
1212+- **Simple integration:** No extra router dependency is required. Register the plugin once and opt-in per signal.
14131514> The plugin also supports synchronising signals with query parameters (`read:` and `sync:` modes).
1615> For multi-page navigation the `hash:` mode is the simplest option because it avoids server configuration and works on static hosting.
17161818-## How?
1717+## Getting Started
19182020-1. Install Volt normally (see [Installation](../installation.md)).
2121-2. Register the plugin before calling `charge()` or `mount()`:
1919+1. Install Volt normally (see [Installation](../installation)).
2020+2. Register the plugin before calling `charge()` or `mount()`.
2121+ Choose the import style that matches your setup:
22222323 ```html
2424+ <!-- CDN / script-tag usage -->
2425 <script type="module">
2525- import {
2626- charge,
2727- registerPlugin,
2828- urlPlugin,
2929- } from 'https://unpkg.com/voltx.js@latest/dist/volt.js';
2626+ import { charge, registerPlugin, urlPlugin } from "https://unpkg.com/voltx.js@latest/dist/volt.js";
30273131- registerPlugin('url', urlPlugin);
2828+ registerPlugin("url", urlPlugin);
3229 charge();
3330 </script>
3431 ```
35323636-3. In your markup, opt a signal into hash synchronisation with `data-volt-url="hash:signalName"`.
3333+ ```ts
3434+ // src/main.ts — bundled projects
3535+ import { charge, initNavigationListener, registerPlugin, urlPlugin } from "voltx.js";
3636+3737+ registerPlugin("url", urlPlugin);
3838+ initNavigationListener(); // restores scroll/focus when using history routing
3939+4040+ charge();
4141+ ```
4242+4343+3. In your markup, opt a signal into URL synchronisation, for example `data-volt-url="hash:route"` or `data-volt-url="history:path"`.
4444+4545+## URL modes at a glance
4646+4747+| Mode | Binding example | Sync direction | Use Case |
4848+| -------- | -------------------------------------- | --------------------------- | ------------------------------------------------------------------------------ |
4949+| `read` | `data-volt-url="read:filter"` | URL ➝ signal on first mount | Hydrate initial state from a query param without mutating the URL afterwards. |
5050+| `sync` | `data-volt-url="sync:sort"` | Bidirectional | Mirror a filter, tab, or feature flag in the query string. |
5151+| `hash` | `data-volt-url="hash:route"` | Bidirectional | Build hash-based navigation that works on static hosts. |
5252+| `history`| `data-volt-url="history:path:/app"` | Bidirectional | Reflect clean History API routes; strip a base path such as `/app` when needed.|
5353+5454+> Mix and match bindings inside the same scope.
5555+> It's common to pair `history:path` for the main route with `sync:` bindings for search filters or sort order.
37563857## Building a multi-page shell
39584040-The example below delivers a three-page marketing site entirely on the client. Each "page" is a section that only renders
4141-when the current route matches its slug.
5959+The example below delivers a three-page marketing site entirely on the client.
6060+Each "page" is a section that only renders when the current route matches its slug.
42614362```html
4463<main
···6584 <section data-volt-if="route === 'pricing'">
6685 <h1>Pricing</h1>
6786 <ul>
6868- <li>Starter — $0</li>
6969- <li>Team — $29</li>
7070- <li>Enterprise — Contact us</li>
8787+ <li>Starter - $0</li>
8888+ <li>Team - $29</li>
8989+ <li>Enterprise - Contact us</li>
7190 </ul>
7291 </section>
7392···149168150169Now `#/pricing?preview=true` keeps both the route and a feature flag in sync with the URL.
151170Add the extra `data-volt-url="sync:preview"` binding on a child element when you need more than one signal to participate in URL synchronisation.
171171+Use `read:` instead of `sync:` when you only need to hydrate the initial value from the URL without mutating it.
172172+173173+## History API Routing
174174+175175+VoltX supports true History API routing via the `history:` mode on the url plugin and the `navigate` directive for SPA-style navigation with pushState/replaceState.
176176+177177+### Using history mode
178178+179179+The `history:` mode syncs a signal with the browser pathname and search params, updating the URL via `history.pushState()` without page reloads:
180180+181181+```html
182182+<div
183183+ data-volt
184184+ data-volt-state='{"currentPath": "/"}'
185185+ data-volt-url="history:currentPath">
186186+ <nav>
187187+ <a href="/about" data-volt-navigate>About</a>
188188+ <a href="/pricing" data-volt-navigate>Pricing</a>
189189+ </nav>
190190+</div>
191191+```
192192+193193+Make sure `initNavigationListener()` runs once during boot (see the bundler example above). It restores scroll positions and focus when users navigate with the browser controls.
194194+195195+Links with `data-volt-navigate` intercept clicks and use pushState instead of full navigation. The `currentPath` signal stays synchronized with the URL, enabling declarative rendering based on pathname.
196196+197197+### Base paths and nested apps
198198+199199+When your app is served from a subdirectory, provide the base path as the third argument:
200200+201201+```html
202202+<div
203203+ data-volt
204204+ data-volt-state='{"currentPath": "/"}'
205205+ data-volt-url="history:currentPath:/docs">
206206+ <!-- routes now read "/pricing" instead of "/docs/pricing" -->
207207+</div>
208208+```
209209+210210+Volt automatically strips `/docs` from the signal value while keeping the full URL intact.
211211+212212+### Link Interception
213213+214214+The navigate directive ships with VoltX so there is no extra plugin registration required. It handles:
215215+216216+- Click interception on anchor tags (respects Ctrl/Cmd+click for new tabs)
217217+- Form GET submission as navigation
218218+- Back/forward button support via popstate events
219219+- Automatic scroll position restoration
220220+- Optional View Transition API integration
221221+222222+Use modifiers for control:
223223+224224+- `data-volt-navigate.replace` - Use replaceState instead of pushState
225225+- `data-volt-navigate.prefetch` - Prefetch on hover or focus
226226+- `data-volt-navigate.prefetch.viewport` - Prefetch when entering viewport
227227+- `data-volt-navigate.notransition` - Skip View Transitions
228228+229229+### Programmatic navigation
230230+231231+Import `navigate()`, `redirect()`, `goBack()`, or `goForward()` for JavaScript-driven routing:
232232+233233+```typescript
234234+import { navigate, redirect } from "voltx.js";
235235+236236+await navigate("/dashboard"); // Pushes state
237237+await redirect("/login"); // Replaces state
238238+```
239239+240240+Both functions return Promises that resolve after navigation completes, supporting View Transitions when available.
241241+242242+### Navigation events
243243+244244+History navigations emit custom events you can react to without polling:
245245+246246+```ts
247247+globalThis.addEventListener("volt:navigate", (event) => {
248248+ const { url, route } = event.detail; // route is present when dispatched by the url plugin
249249+ console.debug("navigated to", url, route ?? "");
250250+});
251251+252252+globalThis.addEventListener("volt:popstate", (event) => {
253253+ refreshDataFor(event.detail.route);
254254+});
255255+```
256256+257257+Use these hooks to trigger data fetching, analytics, or other side effects whenever the active route changes.
258258+259259+## Hash vs History Routing
260260+261261+| Feature | Hash Mode | History Mode |
262262+| ------------------ | ------------------------ | ------------------------------ |
263263+| URL format | `/#/page` | `/page` |
264264+| Server config | None required | Requires catch-all route |
265265+| Browser history | Yes | Yes |
266266+| SEO friendly | Limited | Full |
267267+| Deep linking | Yes | Yes |
268268+| Static hosting | Perfect | Needs fallback to index.html |
269269+| Back/forward | Automatic via hashchange | Automatic via popstate |
270270+| Scroll restoration | Manual | Automatic with navigate plugin |
271271+272272+**Choose hash mode** when deploying to static hosting (GitHub Pages, Netlify without redirects) or when server configuration is unavailable.
273273+274274+**Choose history mode** when you control server routing and want cleaner URLs for SEO and user experience. Configure your server to serve `index.html` for all routes.
275275+276276+## Route Parameters
277277+278278+VoltX provides pattern matching utilities for extracting dynamic segments from URLs.
279279+280280+### Pattern syntax
281281+282282+Route patterns support:
283283+284284+- Named parameters: `/blog/:slug`
285285+- Optional parameters: `/blog/:category/:slug?`
286286+- Wildcard parameters: `/files/*path`
287287+- Multiple parameters: `/users/:userId/posts/:postId`
288288+289289+### Using route utilities
290290+291291+Import `matchRoute()`, `extractParams()`, or `buildPath()` from voltx.js to work with route patterns:
292292+293293+```typescript
294294+import { matchRoute, extractParams, buildPath } from "voltx.js";
295295+296296+const match = matchRoute("/blog/:slug", "/blog/hello-world");
297297+// { path: '/blog/hello-world', params: { slug: 'hello-world' }, pattern: '/blog/:slug' }
298298+299299+const params = extractParams("/users/:id", "/users/42"); // { id: '42' }
300300+const url = buildPath("/blog/:slug", { slug: "new-post" }); // '/blog/new-post'
301301+```
302302+303303+Combine these with computed signals to derive route information declaratively. For example, use `matchRoute()` in a computed signal that watches the url plugin's signal to extract parameters whenever the route changes.
304304+305305+### Declarative parameter extraction
306306+307307+Rather than calling route utilities in methods, create computed signals that derive route data:
308308+309309+```html
310310+<div
311311+ data-volt
312312+ data-volt-state='{"path": "/"}'
313313+ data-volt-url="history:path"
314314+ data-volt-computed:blogSlug="path.startsWith('/blog/') ? path.split('/')[2] : null">
315315+ <article data-volt-if="blogSlug">
316316+ <h1 data-volt-text="'Post: ' + blogSlug"></h1>
317317+ </article>
318318+</div>
319319+```
320320+321321+For more complex routing needs, register a custom method or use the programmatic API with the router utilities.
322322+323323+## Data fetching on navigation
324324+325325+Combine routing signals with `asyncEffect` to load data whenever the active path changes.
326326+Abort signals prevent stale responses from updating the UI if the user navigates away mid-request.
327327+328328+```ts
329329+import { asyncEffect, matchRoute, registerPlugin, signal, urlPlugin } from "voltx.js";
330330+331331+const path = signal("/");
332332+const blogPost = signal(null);
333333+const loading = signal(false);
334334+335335+registerPlugin("url", urlPlugin);
336336+337337+asyncEffect(
338338+ async (abortSignal) => {
339339+ const match = matchRoute("/blog/:slug", path.get());
340340+ if (!match) {
341341+ blogPost.set(null);
342342+ return;
343343+ }
344344+345345+ loading.set(true);
346346+ try {
347347+ const response = await fetch(`/api/posts/${match.params.slug}`, { signal: abortSignal });
348348+ if (!response.ok) throw new Error("Failed to load post");
349349+ blogPost.set(await response.json());
350350+ } finally {
351351+ loading.set(false);
352352+ }
353353+ },
354354+ [path],
355355+ { abortable: true },
356356+);
357357+```
358358+359359+Bind `blogPost` and `loading` into your template (`data-volt-if="blogPost"` etc.) to show the fetched content once it arrives.
360360+361361+## View Transitions
362362+363363+The navigate directive automatically integrates with the View Transitions API when available, providing smooth cross-fade animations between page navigations.
364364+365365+### Automatic transitions
366366+367367+By default, all navigations triggered via `data-volt-navigate` or the `navigate()` function use View Transitions with a transition name of `"page-transition"`. The browser handles the animation automatically.
368368+369369+### Customizing transitions
370370+371371+Control transition behavior with CSS using view-transition pseudo-elements:
372372+373373+```css
374374+::view-transition-old(root),
375375+::view-transition-new(root) {
376376+ animation-duration: 0.3s;
377377+}
378378+379379+::view-transition-old(root) {
380380+ animation-name: fade-out;
381381+}
382382+383383+::view-transition-new(root) {
384384+ animation-name: fade-in;
385385+}
386386+```
387387+388388+Disable transitions per-navigation using the `.notransition` modifier or pass `transition: false` to programmatic navigation functions.
389389+390390+## Focus Management & Accessibility
391391+392392+The navigate plugin includes automatic focus management for keyboard navigation and screen reader users.
393393+394394+On forward navigation, focus moves to the main content area (searches for `<main>`, `[role="main"]`, or `#main-content`) or the first `<h1>` heading. On back/forward navigation, focus is restored to the previously focused element when possible.
395395+396396+This ensures users navigating via keyboard don't lose their position in the document after navigation.
397397+398398+View Transitions are automatically skipped in browsers without support or when `prefers-reduced-motion` is enabled. Navigation continues to work normally without visual transitions.
399399+400400+## Scroll Restoration
401401+402402+Scroll positions are automatically saved before navigation and restored when using the browser back/forward buttons.
403403+The navigate plugin maintains a map of scroll positions keyed by pathname.
404404+405405+For custom scroll containers, use the scroll plugin's history mode:
406406+407407+```html
408408+<div data-volt-scroll="history" style="overflow-y: auto;">
409409+ <!-- scrollable content -->
410410+</div>
411411+```
412412+413413+This automatically saves and restores the scroll position of the container across navigations.
152414153415## Progressive Enhancement
154416155417- Always provide semantic HTML in each section so the site remains usable without JavaScript or when crawled.
156156-- Consider prefetching data when a link becomes visible: attach a watcher to `route` and trigger fetch logic from the programmatic API.
157157-- Use `scrollPlugin` for auto-scrolling on navigation if you have tall pages (`data-volt-scroll="route"`).
418418+- Prefetch data when a link becomes visible by combining navigation events with `asyncEffect` or the `data-volt-navigate.prefetch` modifier.
419419+- Use the scroll plugin's history mode for tall pages (`data-volt-scroll="history"`).
+3
lib/src/core/modifiers.ts
···4343 "number",
4444 "trim",
4545 "lazy",
4646+ "replace",
4747+ "prefetch",
4848+ "notransition",
4649 ]);
47504851 let i = 1;
+305
lib/src/core/router.ts
···11+/**
22+ * Route utilities for pattern matching and parameter extraction
33+ *
44+ * Provides utilities for dynamic route matching with support for:
55+ * - Named parameters: /blog/:slug
66+ * - Wildcard parameters: /files/*path
77+ * - Optional parameters: /blog/:slug?
88+ * - Multiple parameters: /users/:userId/posts/:postId
99+ */
1010+1111+import type { Optional } from "$types/helpers";
1212+1313+/**
1414+ * Route match result containing extracted parameters
1515+ */
1616+export type RouteMatch = { path: string; params: Record<string, string>; pattern: string };
1717+1818+/**
1919+ * Compiled route pattern for efficient matching
2020+ */
2121+type CompiledRoute = {
2222+ pattern: string;
2323+ regex: RegExp;
2424+ keys: Array<{ name: string; optional: boolean; wildcard: boolean }>;
2525+};
2626+2727+const routeCache = new Map<string, CompiledRoute>();
2828+2929+/**
3030+ * Compile a route pattern into a regex for efficient matching
3131+ *
3232+ * Supported patterns:
3333+ * - /blog/:slug - Named parameter
3434+ * - /blog/:slug? - Optional parameter
3535+ * - /files/*path - Wildcard (matches rest of path)
3636+ * - /users/:userId/posts/:postId - Multiple parameters
3737+ *
3838+ * @param pattern - Route pattern to compile
3939+ * @returns Compiled route with regex and parameter keys
4040+ *
4141+ * @example
4242+ * ```typescript
4343+ * const route = compileRoute('/blog/:slug');
4444+ * const match = route.regex.exec('/blog/hello-world');
4545+ * // match[1] === 'hello-world'
4646+ * ```
4747+ */
4848+export function compileRoute(pattern: string): CompiledRoute {
4949+ if (routeCache.has(pattern)) {
5050+ return routeCache.get(pattern)!;
5151+ }
5252+5353+ const keys: Array<{ name: string; optional: boolean; wildcard: boolean }> = [];
5454+5555+ // Build regex pattern by processing each part
5656+ let regexPattern = "";
5757+ let i = 0;
5858+5959+ while (i < pattern.length) {
6060+ // Check for parameter :name or :name?
6161+ if (pattern[i] === ":") {
6262+ const paramMatch = pattern.slice(i).match(/^:(\w+)(\?)?/);
6363+ if (paramMatch) {
6464+ const [fullMatch, name, optional] = paramMatch;
6565+ keys.push({ name, optional: Boolean(optional), wildcard: false });
6666+6767+ if (optional) {
6868+ // For optional params, include the preceding / in the optional group
6969+ // Remove trailing / from regexPattern if present
7070+ if (regexPattern.endsWith("/")) {
7171+ regexPattern = regexPattern.slice(0, -1);
7272+ }
7373+ regexPattern += "(?:/([^/?]+))?";
7474+ } else {
7575+ // Required params: just the capture group (/ already processed)
7676+ regexPattern += "([^/?]+)";
7777+ }
7878+7979+ i += fullMatch.length;
8080+ continue;
8181+ }
8282+ }
8383+8484+ // Check for wildcard *name
8585+ if (pattern[i] === "*") {
8686+ const wildcardMatch = pattern.slice(i).match(/^\*(\w+)/);
8787+ if (wildcardMatch) {
8888+ const [fullMatch, name] = wildcardMatch;
8989+ keys.push({ name, optional: false, wildcard: true });
9090+ regexPattern += "(.*)";
9191+ i += fullMatch.length;
9292+ continue;
9393+ }
9494+ }
9595+9696+ // Escape special regex characters for literal matching
9797+ const char = pattern[i];
9898+ if (".+?^${}()|[]\\".includes(char)) {
9999+ regexPattern += `\\${char}`;
100100+ } else {
101101+ regexPattern += char;
102102+ }
103103+ i++;
104104+ }
105105+106106+ // Create regex with anchors
107107+ const regex = new RegExp(`^${regexPattern}$`);
108108+109109+ const compiled: CompiledRoute = { pattern, regex, keys };
110110+ routeCache.set(pattern, compiled);
111111+112112+ return compiled;
113113+}
114114+115115+/**
116116+ * Match a path against a route pattern and extract parameters
117117+ *
118118+ * @param pattern - Route pattern (e.g., '/blog/:slug')
119119+ * @param path - Path to match (e.g., '/blog/hello-world')
120120+ * @returns RouteMatch with extracted params, or undefined if no match
121121+ *
122122+ * @example
123123+ * ```typescript
124124+ * const match = matchRoute('/blog/:slug', '/blog/hello-world');
125125+ * // { path: '/blog/hello-world', params: { slug: 'hello-world' }, pattern: '/blog/:slug' }
126126+ *
127127+ * const noMatch = matchRoute('/blog/:slug', '/about');
128128+ * // undefined
129129+ * ```
130130+ */
131131+export function matchRoute(pattern: string, path: string): Optional<RouteMatch> {
132132+ const compiled = compileRoute(pattern);
133133+ const match = compiled.regex.exec(path);
134134+135135+ if (!match) {
136136+ return undefined;
137137+ }
138138+139139+ const params: Record<string, string> = {};
140140+141141+ for (const [index, key] of compiled.keys.entries()) {
142142+ const value = match[index + 1];
143143+ if (value !== undefined) {
144144+ params[key.name] = decodeURIComponent(value);
145145+ }
146146+ }
147147+148148+ return { path, params, pattern };
149149+}
150150+151151+/**
152152+ * Match a path against multiple route patterns and return the first match
153153+ *
154154+ * @param patterns - Array of route patterns to try
155155+ * @param path - Path to match
156156+ * @returns First matching RouteMatch, or undefined if no match
157157+ *
158158+ * @example
159159+ * ```typescript
160160+ * const routes = ['/blog/:slug', '/users/:id', '/about'];
161161+ * const match = matchRoutes(routes, '/users/123');
162162+ * // { path: '/users/123', params: { id: '123' }, pattern: '/users/:id' }
163163+ * ```
164164+ */
165165+export function matchRoutes(patterns: string[], path: string): Optional<RouteMatch> {
166166+ for (const pattern of patterns) {
167167+ const match = matchRoute(pattern, path);
168168+ if (match) {
169169+ return match;
170170+ }
171171+ }
172172+ return undefined;
173173+}
174174+175175+/**
176176+ * Extract parameters from a path using a route pattern
177177+ *
178178+ * @param pattern - Route pattern with parameters
179179+ * @param path - Path to extract from
180180+ * @returns Object with extracted parameters, or empty object if no match
181181+ *
182182+ * @example
183183+ * ```typescript
184184+ * const params = extractParams('/blog/:slug', '/blog/hello-world');
185185+ * // { slug: 'hello-world' }
186186+ *
187187+ * const params2 = extractParams('/users/:userId/posts/:postId', '/users/42/posts/123');
188188+ * // { userId: '42', postId: '123' }
189189+ * ```
190190+ */
191191+export function extractParams(pattern: string, path: string): Record<string, string> {
192192+ const match = matchRoute(pattern, path);
193193+ return match ? match.params : {};
194194+}
195195+196196+/**
197197+ * Build a path from a pattern by replacing parameters
198198+ *
199199+ * @param pattern - Route pattern with parameters
200200+ * @param params - Parameters to insert
201201+ * @returns Built path with parameters replaced
202202+ *
203203+ * @example
204204+ * ```typescript
205205+ * const path = buildPath('/blog/:slug', { slug: 'hello-world' });
206206+ * // '/blog/hello-world'
207207+ *
208208+ * const path2 = buildPath('/users/:userId/posts/:postId', { userId: '42', postId: '123' });
209209+ * // '/users/42/posts/123'
210210+ * ```
211211+ */
212212+export function buildPath(pattern: string, params: Record<string, string>): string {
213213+ let path = pattern;
214214+215215+ // Replace named parameters
216216+ for (const [key, value] of Object.entries(params)) {
217217+ const encoded = encodeURIComponent(value);
218218+ path = path.replace(`:${key}?`, encoded).replace(`:${key}`, encoded);
219219+ }
220220+221221+ // Remove optional parameters that weren't provided
222222+ path = path.replaceAll(/:(\w+)\?/g, "");
223223+224224+ // Replace wildcards
225225+ for (const [key, value] of Object.entries(params)) {
226226+ path = path.replace(`*${key}`, value);
227227+ }
228228+229229+ return path;
230230+}
231231+232232+/**
233233+ * Check if a path matches a route pattern
234234+ *
235235+ * @param pattern - Route pattern
236236+ * @param path - Path to check
237237+ * @returns true if path matches pattern
238238+ *
239239+ * @example
240240+ * ```typescript
241241+ * isMatch('/blog/:slug', '/blog/hello-world'); // true
242242+ * isMatch('/blog/:slug', '/about'); // false
243243+ * ```
244244+ */
245245+export function isMatch(pattern: string, path: string): boolean {
246246+ return matchRoute(pattern, path) !== undefined;
247247+}
248248+249249+/**
250250+ * Normalize a path by removing trailing slashes and ensuring leading slash
251251+ *
252252+ * @param path - Path to normalize
253253+ * @returns Normalized path
254254+ *
255255+ * @example
256256+ * ```typescript
257257+ * normalizePath('/blog/'); // '/blog'
258258+ * normalizePath('about'); // '/about'
259259+ * normalizePath('/'); // '/'
260260+ * ```
261261+ */
262262+export function normalizePath(path: string): string {
263263+ // Ensure leading slash
264264+ if (!path.startsWith("/")) {
265265+ path = `/${path}`;
266266+ }
267267+268268+ // Remove trailing slash (except for root)
269269+ if (path.length > 1 && path.endsWith("/")) {
270270+ path = path.slice(0, -1);
271271+ }
272272+273273+ return path;
274274+}
275275+276276+/**
277277+ * Parse a URL into path and search params
278278+ *
279279+ * @param url - URL to parse (can be relative or absolute)
280280+ * @returns Object with path and searchParams
281281+ *
282282+ * @example
283283+ * ```typescript
284284+ * parseUrl('/blog?page=2&sort=date');
285285+ * // { path: '/blog', searchParams: URLSearchParams { 'page' => '2', 'sort' => 'date' } }
286286+ * ```
287287+ */
288288+export function parseUrl(url: string): { path: string; searchParams: URLSearchParams } {
289289+ try {
290290+ const urlObj = new URL(url, globalThis.location.origin);
291291+ return { path: urlObj.pathname, searchParams: urlObj.searchParams };
292292+ } catch {
293293+ // If URL parsing fails, treat as relative path
294294+ const [path, search] = url.split("?");
295295+ return { path: path || "/", searchParams: new URLSearchParams(search || "") };
296296+ }
297297+}
298298+299299+/**
300300+ * Clear the route compilation cache
301301+ * Useful for testing or when patterns change dynamically
302302+ */
303303+export function clearRouteCache(): void {
304304+ routeCache.clear();
305305+}
+13
lib/src/index.ts
···1919} from "$core/lifecycle";
2020export { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "$core/plugin";
2121export { isReactive, reactive, toRaw } from "$core/reactive";
2222+export {
2323+ buildPath,
2424+ clearRouteCache,
2525+ compileRoute,
2626+ extractParams,
2727+ isMatch,
2828+ matchRoute,
2929+ matchRoutes,
3030+ normalizePath,
3131+ parseUrl,
3232+} from "$core/router";
3333+export type { RouteMatch } from "$core/router";
2234export { getScopeMetadata } from "$core/scope-metadata";
2335export { computed, effect, signal } from "$core/signal";
2436export { deserializeScope, hydrate, isHydrated, isServerRendered, serializeScope } from "$core/ssr";
···4153 supportsViewTransitions,
4254 withViewTransition,
4355} from "$core/view-transitions";
5656+export { goBack, goForward, initNavigationListener, navigate, navigatePlugin, redirect } from "$plugins/navigate";
4457export { persistPlugin, registerStorageAdapter } from "$plugins/persist";
4558export { scrollPlugin } from "$plugins/scroll";
4659export {
+369
lib/src/plugins/navigate.ts
···11+/**
22+ * Navigate plugin for client-side navigation with History API
33+ *
44+ * Intercepts link clicks and form submissions. Integrates with the History API and View Transition API for smooth page transitions.
55+ */
66+77+import { registerDirective } from "$core/binder";
88+import { hasModifier, parseModifiers } from "$core/modifiers";
99+import { startViewTransition } from "$core/view-transitions";
1010+import type { Optional } from "$types/helpers";
1111+import type { BindingContext, Modifier, PluginContext } from "$types/volt";
1212+1313+type NavigationState = { scrollPosition?: { x: number; y: number }; focusSelector?: string; timestamp: number };
1414+1515+type NavigationOpts = { replace?: boolean; transition?: boolean; transitionName?: string };
1616+1717+const scrollPositions = new Map<string, { x: number; y: number }>();
1818+const focusSelectors = new Map<string, string>();
1919+2020+/**
2121+ * Navigate directive handler for client-side navigation
2222+ *
2323+ * Syntax: data-volt-navigate[.modifiers]="url" or data-volt-navigate[.modifiers] (uses href)
2424+ *
2525+ * Modifiers:
2626+ * - .replace - Use replaceState instead of pushState
2727+ * - .prefetch - Prefetch resources on hover/idle
2828+ * - .notransition - Disable view transitions
2929+ *
3030+ * @example
3131+ * ```html
3232+ * <a href="/about" data-volt-navigate>About</a>
3333+ * <a href="/home" data-volt-navigate-replace>Home</a>
3434+ * <a href="/blog" data-volt-navigate-prefetch>Blog</a>
3535+ * <a href="/settings" data-volt-navigate-notransition>Settings</a>
3636+ * ```
3737+ */
3838+export function bindNavigate(ctx: BindingContext, value: string, modifiers: Modifier[] = []): void {
3939+ const element = ctx.element;
4040+4141+ if (element instanceof HTMLAnchorElement) {
4242+ handleLinkNavigation(ctx, value, modifiers);
4343+ } else if (element instanceof HTMLFormElement) {
4444+ handleFormNavigation(ctx, value, modifiers);
4545+ } else {
4646+ console.warn("data-volt-navigate only works on <a> and <form> elements");
4747+ }
4848+}
4949+5050+/**
5151+ * Plugin-compatible wrapper for navigate directive
5252+ * @deprecated Use bindNavigate directly or register as a directive
5353+ */
5454+export function navigatePlugin(ctx: PluginContext, value: string): void {
5555+ const { baseName, modifiers } = parseModifiers(value || "");
5656+ const bindingCtx: BindingContext = { element: ctx.element, scope: ctx.scope, cleanups: [] };
5757+5858+ bindNavigate(bindingCtx, baseName, modifiers);
5959+6060+ for (const cleanup of bindingCtx.cleanups) {
6161+ ctx.addCleanup(cleanup);
6262+ }
6363+}
6464+6565+function handleLinkNavigation(ctx: BindingContext, value: string, modifiers: Modifier[]): void {
6666+ const link = ctx.element as HTMLAnchorElement;
6767+ const targetUrl = value || link.getAttribute("href");
6868+6969+ if (!targetUrl) {
7070+ console.warn("data-volt-navigate: no URL specified and no href found");
7171+ return;
7272+ }
7373+7474+ if (hasModifier(modifiers, "prefetch")) {
7575+ const viewportPrefetch = hasModifier(modifiers, "viewport");
7676+ setupPrefetch(link, targetUrl, { viewport: viewportPrefetch });
7777+ }
7878+7979+ const clickHandler = async (event: MouseEvent) => {
8080+ if (event.ctrlKey || event.metaKey || event.shiftKey || event.button !== 0) {
8181+ return;
8282+ }
8383+8484+ if (isExternalLink(targetUrl)) {
8585+ return;
8686+ }
8787+8888+ event.preventDefault();
8989+9090+ const useReplace = hasModifier(modifiers, "replace");
9191+ const useTransition = !hasModifier(modifiers, "notransition");
9292+9393+ await navigateTo(targetUrl, { replace: useReplace, transition: useTransition, transitionName: "page-transition" });
9494+ };
9595+9696+ link.addEventListener("click", clickHandler);
9797+ ctx.cleanups.push(() => link.removeEventListener("click", clickHandler));
9898+}
9999+100100+function handleFormNavigation(ctx: BindingContext, value: string, modifiers: Modifier[]): void {
101101+ const form = ctx.element as HTMLFormElement;
102102+ const targetUrl = value || form.getAttribute("action") || globalThis.location.pathname;
103103+104104+ const submitHandler = async (event: SubmitEvent) => {
105105+ event.preventDefault();
106106+107107+ const formData = new FormData(form);
108108+ const method = form.method.toLowerCase();
109109+ const useReplace = hasModifier(modifiers, "replace");
110110+ const useTransition = !hasModifier(modifiers, "notransition");
111111+112112+ if (method === "get") {
113113+ // TODO: serialize FormData
114114+ const params = new URLSearchParams(formData as any);
115115+ const url = `${targetUrl}?${params.toString()}`;
116116+ await navigateTo(url, { replace: useReplace, transition: useTransition, transitionName: "page-transition" });
117117+ } else {
118118+ console.warn("data-volt-navigate: POST/PUT/PATCH forms should use data-volt-post/put/patch");
119119+ }
120120+ };
121121+122122+ form.addEventListener("submit", submitHandler);
123123+ ctx.cleanups.push(() => form.removeEventListener("submit", submitHandler));
124124+}
125125+126126+async function navigateTo(url: string, options: NavigationOpts = {}): Promise<void> {
127127+ const { replace = false, transition = true, transitionName = "page-transition" } = options;
128128+ const currentKey = `${globalThis.location.pathname}${globalThis.location.search}`;
129129+ scrollPositions.set(currentKey, { x: window.scrollX, y: window.scrollY });
130130+131131+ const activeElement = document.activeElement;
132132+ const focusSelector = activeElement && activeElement !== document.body
133133+ ? getElementSelector(activeElement)
134134+ : undefined;
135135+ if (focusSelector) {
136136+ focusSelectors.set(currentKey, focusSelector);
137137+ }
138138+139139+ const state: NavigationState = {
140140+ scrollPosition: { x: window.scrollX, y: window.scrollY },
141141+ focusSelector,
142142+ timestamp: Date.now(),
143143+ };
144144+145145+ const performNavigation = async () => {
146146+ if (replace) {
147147+ globalThis.history.replaceState(state, "", url);
148148+ } else {
149149+ globalThis.history.pushState(state, "", url);
150150+ }
151151+152152+ globalThis.dispatchEvent(
153153+ new CustomEvent("volt:navigate", { detail: { url, replace }, bubbles: true, cancelable: false }),
154154+ );
155155+156156+ window.scrollTo(0, 0);
157157+158158+ resetFocusAfterNavigation();
159159+ };
160160+161161+ if (transition && typeof transitionName === "string") {
162162+ await startViewTransition(performNavigation, { name: transitionName });
163163+ } else {
164164+ await performNavigation();
165165+ }
166166+}
167167+168168+/**
169169+ * Generate a unique selector for an element (for focus restoration)
170170+ * Tries id, then name, then data attributes, then position-based selector
171171+ */
172172+function getElementSelector(element: Element): Optional<string> {
173173+ if (element.id) {
174174+ return `#${element.id}`;
175175+ }
176176+177177+ if (element.hasAttribute("name")) {
178178+ const name = element.getAttribute("name");
179179+ const tag = element.tagName.toLowerCase();
180180+ return `${tag}[name="${name}"]`;
181181+ }
182182+183183+ for (const attr of element.attributes) {
184184+ if (attr.name.startsWith("data-volt-")) {
185185+ return `[${attr.name}="${attr.value}"]`;
186186+ }
187187+ }
188188+189189+ if (element.hasAttribute("aria-label")) {
190190+ const label = element.getAttribute("aria-label");
191191+ return `[aria-label="${label}"]`;
192192+ }
193193+194194+ const parent = element.parentElement;
195195+ if (!parent) return undefined;
196196+197197+ const siblings = [...parent.children];
198198+ const index = siblings.indexOf(element);
199199+ const tag = element.tagName.toLowerCase();
200200+201201+ return `${tag}:nth-child(${index + 1})`;
202202+}
203203+204204+/**
205205+ * Reset focus to a sensible location after navigation
206206+ * Tries to focus main content area or first focusable element
207207+ */
208208+function resetFocusAfterNavigation(): void {
209209+ const main = document.querySelector("main, [role='main'], #main-content");
210210+ if (main instanceof HTMLElement && main.tabIndex < 0) {
211211+ main.tabIndex = -1;
212212+ }
213213+214214+ if (main instanceof HTMLElement) {
215215+ main.focus({ preventScroll: true });
216216+ return;
217217+ }
218218+219219+ const firstHeading = document.querySelector("h1");
220220+ if (firstHeading instanceof HTMLElement) {
221221+ if (firstHeading.tabIndex < 0) {
222222+ firstHeading.tabIndex = -1;
223223+ }
224224+ firstHeading.focus({ preventScroll: true });
225225+ return;
226226+ }
227227+228228+ document.body.focus({ preventScroll: true });
229229+}
230230+231231+/**
232232+ * Restore focus to the previously focused element (for back/forward navigation)
233233+ */
234234+function restoreFocus(selector: string): boolean {
235235+ try {
236236+ const element = document.querySelector(selector);
237237+ if (element instanceof HTMLElement) {
238238+ element.focus({ preventScroll: true });
239239+ return true;
240240+ }
241241+ } catch (error) {
242242+ console.warn(`Could not restore focus to selector: ${selector}`, error);
243243+ }
244244+ return false;
245245+}
246246+247247+function isExternalLink(url: string): boolean {
248248+ try {
249249+ const target = new URL(url, globalThis.location.origin);
250250+ return target.origin !== globalThis.location.origin;
251251+ } catch {
252252+ return false;
253253+ }
254254+}
255255+256256+/**
257257+ * Setup resource prefetching for a link
258258+ *
259259+ * By default, prefetches on hover/focus (interaction-based).
260260+ * With viewport option, prefetches when element enters viewport (IntersectionObserver).
261261+ */
262262+function setupPrefetch(element: HTMLElement, url: string, opts: { viewport?: boolean } = {}): void {
263263+ const { viewport = false } = opts;
264264+ let prefetched = false;
265265+266266+ const prefetch = () => {
267267+ if (prefetched) return;
268268+ prefetched = true;
269269+270270+ fetch(url, { method: "GET", priority: "low", credentials: "same-origin" } as RequestInit).catch(() => {
271271+ const link = document.createElement("link");
272272+ link.rel = "prefetch";
273273+ link.href = url;
274274+ document.head.append(link);
275275+ });
276276+ };
277277+278278+ if (viewport) {
279279+ const observer = new IntersectionObserver((entries) => {
280280+ for (const entry of entries) {
281281+ if (entry.isIntersecting) {
282282+ prefetch();
283283+ observer.disconnect();
284284+ }
285285+ }
286286+ }, { rootMargin: "50px" });
287287+288288+ observer.observe(element);
289289+ } else {
290290+ element.addEventListener("mouseenter", prefetch, { once: true, passive: true });
291291+ element.addEventListener("focus", prefetch, { once: true, passive: true });
292292+ }
293293+}
294294+295295+/**
296296+ * Initialize popstate listener for back/forward navigation
297297+ * Should be called once on app initialization
298298+ */
299299+export function initNavigationListener(): () => void {
300300+ const handlePopState = (event: PopStateEvent) => {
301301+ const state = event.state as NavigationState | null;
302302+303303+ const key = `${globalThis.location.pathname}${globalThis.location.search}`;
304304+ const savedPosition = scrollPositions.get(key);
305305+ const savedFocus = focusSelectors.get(key);
306306+307307+ if (savedPosition) {
308308+ window.scrollTo(savedPosition.x, savedPosition.y);
309309+ } else if (state?.scrollPosition) {
310310+ window.scrollTo(state.scrollPosition.x, state.scrollPosition.y);
311311+ }
312312+313313+ if (savedFocus) {
314314+ restoreFocus(savedFocus);
315315+ } else if (state?.focusSelector) {
316316+ restoreFocus(state.focusSelector);
317317+ } else {
318318+ resetFocusAfterNavigation();
319319+ }
320320+321321+ globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state }, bubbles: true, cancelable: false }));
322322+ };
323323+324324+ globalThis.addEventListener("popstate", handlePopState);
325325+326326+ return () => {
327327+ globalThis.removeEventListener("popstate", handlePopState);
328328+ };
329329+}
330330+331331+/**
332332+ * Programmatic navigation helper
333333+ *
334334+ * @param url - URL to navigate to
335335+ * @param options - Navigation options
336336+ *
337337+ * @example
338338+ * ```typescript
339339+ * import { navigate } from 'voltx.js';
340340+ *
341341+ * navigate('/dashboard', { replace: true });
342342+ * ```
343343+ */
344344+export function navigate(url: string, options?: NavigationOpts): Promise<void> {
345345+ return navigateTo(url, options);
346346+}
347347+348348+/**
349349+ * Go back in history
350350+ */
351351+export function goBack(): void {
352352+ globalThis.history.back();
353353+}
354354+355355+/**
356356+ * Go forward in history
357357+ */
358358+export function goForward(): void {
359359+ globalThis.history.forward();
360360+}
361361+362362+/**
363363+ * Redirect to a URL (alias for navigate with replace: true)
364364+ */
365365+export function redirect(url: string): Promise<void> {
366366+ return navigateTo(url, { replace: true });
367367+}
368368+369369+registerDirective("navigate", bindNavigate);
+42-2
lib/src/plugins/scroll.ts
···88/**
99 * Scroll plugin handler to manage various scroll-related behaviors.
1010 *
1111- * Syntax: data-volt-scroll="mode:signalPath"
1111+ * Syntax: data-volt-scroll="mode:signalPath" or data-volt-scroll="mode"
1212 * Modes:
1313 * - restore:signalPath - Save/restore scroll position
1414 * - scrollTo:signalPath - Scroll to element when signal changes
1515 * - spy:signalPath - Update signal when element is visible
1616 * - smooth:signalPath - Enable smooth scrolling behavior
1717+ * - history - Integrate with navigation history (auto save/restore on navigation)
1718 */
1819export function scrollPlugin(ctx: PluginContext, value: string): void {
2020+ if (value === "history") {
2121+ handleScrollHistory(ctx);
2222+ return;
2323+ }
2424+1925 const parts = value.split(":");
2026 if (parts.length !== 2) {
2121- console.error(`Invalid scroll binding: "${value}". Expected format: "mode:signalPath"`);
2727+ console.error(`Invalid scroll binding: "${value}". Expected format: "mode:signalPath" or "history"`);
2228 return;
2329 }
2430···157163 element.style.scrollBehavior = "";
158164 });
159165}
166166+167167+/**
168168+ * Integrate scroll position with browser history
169169+ * Automatically saves and restores scroll position on navigation
170170+ * Works with volt:navigate and volt:popstate events
171171+ */
172172+function handleScrollHistory(ctx: PluginContext): void {
173173+ const element = ctx.element as HTMLElement;
174174+ const scrollPositions = new Map<string, number>();
175175+176176+ const handleNavigate = () => {
177177+ const key = `${globalThis.location.pathname}${globalThis.location.search}`;
178178+ scrollPositions.set(key, element.scrollTop);
179179+ };
180180+181181+ const handlePopstate = () => {
182182+ const key = `${globalThis.location.pathname}${globalThis.location.search}`;
183183+ const savedPosition = scrollPositions.get(key);
184184+185185+ if (savedPosition !== undefined) {
186186+ requestAnimationFrame(() => {
187187+ element.scrollTop = savedPosition;
188188+ });
189189+ }
190190+ };
191191+192192+ globalThis.addEventListener("volt:navigate", handleNavigate);
193193+ globalThis.addEventListener("volt:popstate", handlePopstate);
194194+195195+ ctx.addCleanup(() => {
196196+ globalThis.removeEventListener("volt:navigate", handleNavigate);
197197+ globalThis.removeEventListener("volt:popstate", handlePopstate);
198198+ });
199199+}
+81-5
lib/src/plugins/url.ts
···991010/**
1111 * URL plugin handler.
1212- * Synchronizes signal values with URL parameters and hash.
1212+ * Synchronizes signal values with URL parameters, hash, and full history state.
1313 *
1414- * Syntax: data-volt-url="mode:signalPath"
1414+ * Syntax: data-volt-url="mode:signalPath" or data-volt-url="mode:signalPath:basePath"
1515 * Modes:
1616 * - read:signalPath - Read URL param into signal on mount (one-way)
1717 * - sync:signalPath - Bidirectional sync between signal and URL param
1818 * - hash:signalPath - Sync with hash portion for routing
1919+ * - history:signalPath[:basePath] - Sync with full path + search (History API routing)
1920 */
2021export function urlPlugin(ctx: PluginContext, value: string): void {
2122 const parts = value.split(":");
2222- if (parts.length !== 2) {
2323- console.error(`Invalid url binding: "${value}". Expected format: "mode:signalPath"`);
2323+ if (parts.length < 2) {
2424+ console.error(`Invalid url binding: "${value}". Expected format: "mode:signalPath[:basePath]"`);
2425 return;
2526 }
26272727- const [mode, signalPath] = parts.map((p) => p.trim());
2828+ const [mode, signalPath, basePath] = parts.map((p) => p.trim());
28292930 switch (mode) {
3031 case "read": {
···3738 }
3839 case "hash": {
3940 handleHashRouting(ctx, signalPath);
4141+ break;
4242+ }
4343+ case "history": {
4444+ handleHistoryRouting(ctx, signalPath, basePath);
4045 break;
4146 }
4247 default: {
···215220 return value;
216221 }
217222}
223223+224224+/**
225225+ * Sync signal with full path + search params for History API routing.
226226+ * Bidirectional sync between signal and window.location.pathname + search.
227227+ *
228228+ * @param ctx - Plugin context
229229+ * @param signalPath - Signal path to sync
230230+ * @param basePath - Optional base path to strip from routes (e.g., "/app")
231231+ */
232232+function handleHistoryRouting(ctx: PluginContext, signalPath: string, basePath?: string): void {
233233+ const signal = ctx.findSignal(signalPath);
234234+ if (!signal) {
235235+ console.error(`Signal "${signalPath}" not found for history routing`);
236236+ return;
237237+ }
238238+239239+ const base = basePath || "";
240240+ const getCurrentRoute = (): string => {
241241+ const fullPath = globalThis.location.pathname + globalThis.location.search;
242242+ if (base && fullPath.startsWith(base)) {
243243+ return fullPath.slice(base.length) || "/";
244244+ }
245245+ return fullPath;
246246+ };
247247+248248+ const currentRoute = getCurrentRoute();
249249+ if (currentRoute) {
250250+ (signal as Signal<string>).set(currentRoute);
251251+ }
252252+253253+ let isUpdatingFromHistory = false;
254254+255255+ const updateUrl = (value: unknown) => {
256256+ if (isUpdatingFromHistory) return;
257257+258258+ const route = String(value ?? "/");
259259+ const fullPath = base ? `${base}${route}` : route;
260260+261261+ if (globalThis.location.pathname + globalThis.location.search !== fullPath) {
262262+ globalThis.history.pushState({}, "", fullPath);
263263+ globalThis.dispatchEvent(
264264+ new CustomEvent("volt:navigate", { detail: { url: fullPath, route }, bubbles: true, cancelable: false }),
265265+ );
266266+ }
267267+ };
268268+269269+ const handlePopState = () => {
270270+ isUpdatingFromHistory = true;
271271+ const route = getCurrentRoute();
272272+ (signal as Signal<string>).set(route);
273273+ globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { route }, bubbles: true, cancelable: false }));
274274+ isUpdatingFromHistory = false;
275275+ };
276276+277277+ const handleNavigate = () => {
278278+ isUpdatingFromHistory = true;
279279+ const route = getCurrentRoute();
280280+ (signal as Signal<string>).set(route);
281281+ isUpdatingFromHistory = false;
282282+ };
283283+284284+ const unsubscribe = signal.subscribe(updateUrl);
285285+ globalThis.addEventListener("popstate", handlePopState);
286286+ globalThis.addEventListener("volt:navigate", handleNavigate);
287287+288288+ ctx.addCleanup(() => {
289289+ unsubscribe();
290290+ globalThis.removeEventListener("popstate", handlePopState);
291291+ globalThis.removeEventListener("volt:navigate", handleNavigate);
292292+ });
293293+}