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"`).