a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1# Routing
2
3Client-side routing lets VoltX applications feel like multi-page sites without full page reloads.
4The `url` plugin keeps a signal in sync with the browser URL so your application can react declaratively to route changes.
5This 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.
6
7## Why?
8
9- **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.
10- **Shareable URLs:** Users can refresh or share a link such as `/#/pricing` and land directly on the same view.
11- **Declarative rendering:** Routing is just another signal; templates choose what to display with conditional bindings like `data-volt-if` or `data-volt-show`.
12- **Simple integration:** No extra router dependency is required. Register the plugin once and opt-in per signal.
13
14> The plugin also supports synchronising signals with query parameters (`read:` and `sync:` modes).
15> For multi-page navigation the `hash:` mode is the simplest option because it avoids server configuration and works on static hosting.
16
17## Getting Started
18
191. Install Volt normally (see [Installation](../installation)).
202. Register the plugin before calling `charge()` or `mount()`.
21 Choose the import style that matches your setup:
22
23 ```html
24 <!-- CDN / script-tag usage -->
25 <script type="module">
26 import { charge, registerPlugin, urlPlugin } from "https://unpkg.com/voltx.js@latest/dist/volt.js";
27
28 registerPlugin("url", urlPlugin);
29 charge();
30 </script>
31 ```
32
33 ```ts
34 // src/main.ts -> entry point for a bundled project
35 import { charge, initNavigationListener, registerPlugin, urlPlugin } from "voltx.js";
36
37 registerPlugin("url", urlPlugin);
38 initNavigationListener(); // restores scroll/focus when using history routing
39
40 charge();
41 ```
42
433. In your markup, opt a signal into URL synchronisation, for example `data-volt-url="hash:route"` or `data-volt-url="history:path"`.
44
45## URL modes at a glance
46
47| Mode | Binding example | Sync direction | Use Case |
48| -------- | -------------------------------------- | --------------------------- | ------------------------------------------------------------------------------ |
49| `read` | `data-volt-url="read:filter"` | URL ➝ signal on first mount | Hydrate initial state from a query param without mutating the URL afterwards. |
50| `sync` | `data-volt-url="sync:sort"` | Bidirectional | Mirror a filter, tab, or feature flag in the query string. |
51| `hash` | `data-volt-url="hash:route"` | Bidirectional | Build hash-based navigation that works on static hosts. |
52| `history`| `data-volt-url="history:path:/app"` | Bidirectional | Reflect clean History API routes; strip a base path such as `/app` when needed.|
53
54> Mix and match bindings inside the same scope.
55> It's common to pair `history:path` for the main route with `sync:` bindings for search filters or sort order.
56
57## Building a multi-page shell
58
59The example below delivers a three-page marketing site entirely on the client.
60Each "page" is a section that only renders when the current route matches its slug.
61
62```html
63<main
64 data-volt
65 data-volt-state='{"route": "home"}'
66 data-volt-url="hash:route">
67 <nav>
68 <button data-volt-class:active="route === 'home'" data-volt-on-click="route.set('home')">
69 Home
70 </button>
71 <button data-volt-class:active="route === 'pricing'" data-volt-on-click="route.set('pricing')">
72 Pricing
73 </button>
74 <button data-volt-class:active="route === 'about'" data-volt-on-click="route.set('about')">
75 About
76 </button>
77 </nav>
78
79 <section data-volt-if="route === 'home'">
80 <h1>Volt</h1>
81 <p>A lightning-fast reactive runtime for the DOM.</p>
82 </section>
83
84 <section data-volt-if="route === 'pricing'">
85 <h1>Pricing</h1>
86 <ul>
87 <li>Starter - $0</li>
88 <li>Team - $29</li>
89 <li>Enterprise - Contact us</li>
90 </ul>
91 </section>
92
93 <section data-volt-if="route === 'about'">
94 <h1>About</h1>
95 <p>Learn more about the Volt runtime and ecosystem.</p>
96 </section>
97
98 <section data-volt-if="route !== 'home' && route !== 'pricing' && route !== 'about'">
99 <h1>Not found</h1>
100 <p data-volt-text="'No page named \"' + route + '\"'"></p>
101 <button data-volt-on-click="route.set('home')">Return home</button>
102 </section>
103</main>
104```
105
106### How it works
107
108- On first mount, the plugin reads `window.location.hash` and updates the `route` signal (defaulting to `"home"` if empty).
109- Clicking navigation buttons calls `route.set(...)`, which updates the signal and immediately pushes the new hash to history.
110 The hash-change event also keeps the signal in sync when the user clicks the browser back button.
111- Each section uses `data-volt-if` to opt-in to rendering when the `route` value matches.
112 Volt removes sections that no longer match, so each "page" has a distinct DOM subtree.
113
114You can style the `"active"` class however you like; it toggles purely through declarative class bindings.
115
116## Linking with anchors
117
118Prefer plain `<a>` elements when appropriate so the browser shows the target hash in tooltips and lets users open the route in new tabs:
119
120```html
121<a href="#pricing" data-volt-on-click="route.set('pricing')">Pricing</a>
122```
123
124Setting `href="#pricing"` ensures non-JavaScript fallbacks still land on the right section, while the click handler keeps
125the signal aligned with the hash plugin.
126
127## Nested & Computed Routes
128
129Because the route is just a string signal, you can derive extra information using computed signals or watchers:
130
131```html
132<div
133 data-volt
134 data-volt-state='{"route": "home"}'
135 data-volt-url="hash:route"
136 data-volt-computed:segments="route.split('/')">
137 <p data-volt-text="'Section: ' + segments[0]"></p>
138 <p data-volt-if="segments.length > 1" data-volt-text="'Item: ' + segments[1]"></p>
139</div>
140```
141
142Use this pattern to build nested routes like `#/blog/introducing-volt`. Parse the segments in a computed signal and update child components accordingly.
143
144For richer logic (e.g., mapping slugs to component functions), register a handler in `data-volt-methods` or mount with the programmatic API.
145This would allow something like a switch statement or usage of a look up of route definitions in a collection.
146
147## Preserving State
148
149Client-side routing works best when page-level state lives alongside the route signal.
150Volt keeps signals alive as long as their elements remain mounted, so consider nesting pages inside `data-volt-if` blocks that wrap the entire section.
151When you need to reset state upon navigation, call `.set()` explicitly inside your route change handlers or watch the `route` signal and perform cleanup in `ctx.addCleanup`.
152
153## Query Params
154
155Hash routing is ideal for static sites, but you can combine it with query parameter syncing.
156
157For example:
158
159```html
160<div
161 data-volt
162 data-volt-state='{"route": "home", "preview": false}'
163 data-volt-url="hash:route">
164 <span hidden data-volt-url="sync:preview"></span>
165 <!-- ... -->
166</div>
167```
168
169Now `#/pricing?preview=true` keeps both the route and a feature flag in sync with the URL.
170Add the extra `data-volt-url="sync:preview"` binding on a child element when you need more than one signal to participate in URL synchronisation.
171Use `read:` instead of `sync:` when you only need to hydrate the initial value from the URL without mutating it.
172
173## History API Routing
174
175VoltX supports true History API routing via the `history:` mode on the url plugin and the `navigate` directive for SPA-style navigation with pushState/replaceState.
176
177### Using history mode
178
179The `history:` mode syncs a signal with the browser pathname and search params, updating the URL via `history.pushState()` without page reloads:
180
181```html
182<div
183 data-volt
184 data-volt-state='{"currentPath": "/"}'
185 data-volt-url="history:currentPath">
186 <nav>
187 <a href="/about" data-volt-navigate>About</a>
188 <a href="/pricing" data-volt-navigate>Pricing</a>
189 </nav>
190</div>
191```
192
193Make sure `initNavigationListener()` runs once during boot (see the bundler example above). It restores scroll positions and focus when users navigate with the browser controls.
194
195Links 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.
196
197### Base paths and nested apps
198
199When your app is served from a subdirectory, provide the base path as the third argument:
200
201```html
202<div
203 data-volt
204 data-volt-state='{"currentPath": "/"}'
205 data-volt-url="history:currentPath:/docs">
206 <!-- routes now read "/pricing" instead of "/docs/pricing" -->
207</div>
208```
209
210Volt automatically strips `/docs` from the signal value while keeping the full URL intact.
211
212### Link Interception
213
214The navigate directive ships with VoltX so there is no extra plugin registration required. It handles:
215
216- Click interception on anchor tags (respects Ctrl/Cmd+click for new tabs)
217- Form GET submission as navigation
218- Back/forward button support via popstate events
219- Automatic scroll position restoration
220- Optional View Transition API integration
221
222Use modifiers for control:
223
224- `data-volt-navigate.replace` - Use replaceState instead of pushState
225- `data-volt-navigate.prefetch` - Prefetch on hover or focus
226- `data-volt-navigate.prefetch.viewport` - Prefetch when entering viewport
227- `data-volt-navigate.notransition` - Skip View Transitions
228
229### Programmatic navigation
230
231Import `navigate()`, `redirect()`, `goBack()`, or `goForward()` for JavaScript-driven routing:
232
233```typescript
234import { navigate, redirect } from "voltx.js";
235
236await navigate("/dashboard"); // Pushes state
237await redirect("/login"); // Replaces state
238```
239
240Both functions return Promises that resolve after navigation completes, supporting View Transitions when available.
241
242### Navigation events
243
244History navigations emit custom events you can react to without polling:
245
246```ts
247globalThis.addEventListener("volt:navigate", (event) => {
248 const { url, route } = event.detail; // route is present when dispatched by the url plugin
249 console.debug("navigated to", url, route ?? "");
250});
251
252globalThis.addEventListener("volt:popstate", (event) => {
253 refreshDataFor(event.detail.route);
254});
255```
256
257Use these hooks to trigger data fetching, analytics, or other side effects whenever the active route changes.
258
259## Hash vs History Routing
260
261| Feature | Hash Mode | History Mode |
262| ------------------ | ------------------------ | ------------------------------ |
263| URL format | `/#/page` | `/page` |
264| Server config | None required | Requires catch-all route |
265| Browser history | Yes | Yes |
266| SEO friendly | Limited | Full |
267| Deep linking | Yes | Yes |
268| Static hosting | Perfect | Needs fallback to index.html |
269| Back/forward | Automatic via hashchange | Automatic via popstate |
270| Scroll restoration | Manual | Automatic with navigate plugin |
271
272**Choose hash mode** when deploying to static hosting (GitHub Pages, Netlify without redirects) or when server configuration is unavailable.
273
274**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.
275
276## Route Parameters
277
278VoltX provides pattern matching utilities for extracting dynamic segments from URLs.
279
280### Pattern syntax
281
282Route patterns support:
283
284- Named parameters: `/blog/:slug`
285- Optional parameters: `/blog/:category/:slug?`
286- Wildcard parameters: `/files/*path`
287- Multiple parameters: `/users/:userId/posts/:postId`
288
289### Using route utilities
290
291Import `matchRoute()`, `extractParams()`, or `buildPath()` from voltx.js to work with route patterns:
292
293```typescript
294import { matchRoute, extractParams, buildPath } from "voltx.js";
295
296const match = matchRoute("/blog/:slug", "/blog/hello-world");
297// { path: '/blog/hello-world', params: { slug: 'hello-world' }, pattern: '/blog/:slug' }
298
299const params = extractParams("/users/:id", "/users/42"); // { id: '42' }
300const url = buildPath("/blog/:slug", { slug: "new-post" }); // '/blog/new-post'
301```
302
303Combine 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.
304
305### Declarative parameter extraction
306
307Rather than calling route utilities in methods, create computed signals that derive route data:
308
309```html
310<div
311 data-volt
312 data-volt-state='{"path": "/"}'
313 data-volt-url="history:path"
314 data-volt-computed:blogSlug="path.startsWith('/blog/') ? path.split('/')[2] : null">
315 <article data-volt-if="blogSlug">
316 <h1 data-volt-text="'Post: ' + blogSlug"></h1>
317 </article>
318</div>
319```
320
321For more complex routing needs, register a custom method or use the programmatic API with the router utilities.
322
323## Data fetching on navigation
324
325Combine routing signals with `asyncEffect` to load data whenever the active path changes.
326Abort signals prevent stale responses from updating the UI if the user navigates away mid-request.
327
328```ts
329import { asyncEffect, matchRoute, registerPlugin, signal, urlPlugin } from "voltx.js";
330
331const path = signal("/");
332const blogPost = signal(null);
333const loading = signal(false);
334
335registerPlugin("url", urlPlugin);
336
337asyncEffect(
338 async (abortSignal) => {
339 const match = matchRoute("/blog/:slug", path.get());
340 if (!match) {
341 blogPost.set(null);
342 return;
343 }
344
345 loading.set(true);
346 try {
347 const response = await fetch(`/api/posts/${match.params.slug}`, { signal: abortSignal });
348 if (!response.ok) throw new Error("Failed to load post");
349 blogPost.set(await response.json());
350 } finally {
351 loading.set(false);
352 }
353 },
354 [path],
355 { abortable: true },
356);
357```
358
359Bind `blogPost` and `loading` into your template (`data-volt-if="blogPost"` etc.) to show the fetched content once it arrives.
360
361## View Transitions
362
363The navigate directive automatically integrates with the View Transitions API when available, providing smooth cross-fade animations between page navigations.
364
365### Automatic transitions
366
367By 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.
368
369### Customizing transitions
370
371Control transition behavior with CSS using view-transition pseudo-elements:
372
373```css
374::view-transition-old(root),
375::view-transition-new(root) {
376 animation-duration: 0.3s;
377}
378
379::view-transition-old(root) {
380 animation-name: fade-out;
381}
382
383::view-transition-new(root) {
384 animation-name: fade-in;
385}
386```
387
388Disable transitions per-navigation using the `.notransition` modifier or pass `transition: false` to programmatic navigation functions.
389
390## Focus Management & Accessibility
391
392The navigate plugin includes automatic focus management for keyboard navigation and screen reader users.
393
394On 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.
395
396This ensures users navigating via keyboard don't lose their position in the document after navigation.
397
398View Transitions are automatically skipped in browsers without support or when `prefers-reduced-motion` is enabled. Navigation continues to work normally without visual transitions.
399
400## Scroll Restoration
401
402Scroll positions are automatically saved before navigation and restored when using the browser back/forward buttons.
403The navigate plugin maintains a map of scroll positions keyed by pathname.
404
405For custom scroll containers, use the scroll plugin's history mode:
406
407```html
408<div data-volt-scroll="history" style="overflow-y: auto;">
409 <!-- scrollable content -->
410</div>
411```
412
413This automatically saves and restores the scroll position of the container across navigations.
414
415## Progressive Enhancement
416
417- Always provide semantic HTML in each section so the site remains usable without JavaScript or when crawled.
418- Prefetch data when a link becomes visible by combining navigation events with `asyncEffect` or the `data-volt-navigate.prefetch` modifier.
419- Use the scroll plugin's history mode for tall pages (`data-volt-scroll="history"`).