a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals

VoltX Plugin System Spec#

Overview#

The plugin system enables extending the framework with custom data-volt-* attribute bindings.

Plugins follow the same binding patterns as core bindings (text, html, class, events) but can implement specialized behaviors like persistence, scrolling, and URL synchronization.

Design Goals#

Extensibility#

Plugins can access the full binding context including the DOM element, reactive scope, signal utilities, and cleanup registration.

Explicit Opt-In#

Built-in plugins require explicit registration to keep the core bundle minimal. Applications only load the functionality they use.

Simplicity#

Plugin API mirrors the internal binding handler signature. Developers who end up familiar with Volt internals can easily create plugins.

Consistency#

Plugins should integrate seamlessly with the mount/unmount lifecycle, cleanup system, and reactive primitives.

Plugin API#

Registration#

Plugins are registered using the registerPlugin() function:

registerPlugin(name: string, handler: PluginHandler): void

The plugin name becomes the data-volt-* attribute suffix. For example, registering a plugin named "tooltip" enables data-volt-tooltip attributes.

Plugin Handler#

Plugin handlers receive a context object and the attribute value:

type PluginHandler = (context: PluginContext, value: string) => void

The handler should:

  1. Parse the attribute value
  2. Set up bindings and subscriptions
  3. Register cleanup functions for unmount

PluginContext#

The context object provides:

interface PluginContext {
  element: Element;                              // The bound DOM element
  scope: Scope;                                  // Reactive scope with signals
  addCleanup(fn: CleanupFunction): void;        // Register cleanup
  findSignal(path: string): Signal | undefined; // Locate signals by path
  evaluate(expression: string): unknown;        // Evaluate expressions
}

Example: Custom Tooltip Plugin#

import { registerPlugin } from 'voltx.js';

registerPlugin('tooltip', (context, value) => {
  const tooltip = document.createElement('div');
  tooltip.className = 'tooltip';
  tooltip.textContent = context.evaluate(value);

  const show = () => document.body.appendChild(tooltip);
  const hide = () => tooltip.remove();

  context.element.addEventListener('mouseenter', show);
  context.element.addEventListener('mouseleave', hide);

  context.addCleanup(() => {
    hide();
    context.element.removeEventListener('mouseenter', show);
    context.element.removeEventListener('mouseleave', hide);
  });

  const signal = context.findSignal(value);
  if (signal) {
    const unsubscribe = signal.subscribe((newValue) => {
      tooltip.textContent = String(newValue);
    });
    context.addCleanup(unsubscribe);
  }
});

Built-in Plugins#

VoltX.js ships with three built-in plugins that must be explicitly registered.

data-volt-persist#

Synchronizes signal values with persistent storage (localStorage, sessionStorage, IndexedDB).

Syntax:

<input data-volt-persist="signalName:storageType" />

Storage Types:

  • local - localStorage (persistent across sessions)
  • session - sessionStorage (cleared on tab close)
  • indexeddb - IndexedDB (large datasets, async)
  • Custom adapters via registerStorageAdapter()

Behavior:

  1. On mount: Load persisted value into signal (if exists)
  2. On signal change: Persist new value to storage
  3. On unmount: Clean up storage listeners

Examples:

<!-- Persist counter to localStorage -->
<div data-volt-text="count" data-volt-persist="count:local"></div>

<!-- Persist form state to sessionStorage -->
<input data-volt-on-input="updateForm" data-volt-persist="formData:session" />

<!-- Persist large dataset to IndexedDB -->
<div data-volt-persist="userData:indexeddb"></div>

Custom Storage Adapters:

interface StorageAdapter {
  get(key: string): Promise<unknown> | unknown;
  set(key: string, value: unknown): Promise<void> | void;
  remove(key: string): Promise<void> | void;
}

registerStorageAdapter('custom', {
  async get(key) { /* ... */ },
  async set(key, value) { /* ... */ },
  async remove(key) { /* ... */ }
});

data-volt-scroll#

Manages scroll behavior including position restoration, programmatic scrolling, scroll spy, and smooth scrolling.

Syntax:

<!-- Scroll position restoration -->
<div data-volt-scroll="restore:position"></div>

<!-- Scroll to element when signal changes -->
<div data-volt-scroll="scrollTo:targetId"></div>

<!-- Scroll spy (updates signal when in viewport) -->
<div data-volt-scroll="spy:isVisible"></div>

<!-- Smooth scroll behavior -->
<div data-volt-scroll="smooth:true"></div>

Behaviors:

Position Restoration:

<div id="content" data-volt-scroll="restore:scrollPos">
  <!-- scroll position saved on scroll, restored on mount -->
</div>

Saves scroll position to the specified signal and restores on mount.

Scroll-To:

<button data-volt-on-click="scrollToSection.set('section2')">Go to Section 2</button>
<div id="section2" data-volt-scroll="scrollTo:scrollToSection"></div>

Scrolls to element when the specified signal changes to match element's ID or selector.

Scroll Spy:

<nav>
  <a data-volt-class="{ active: section1Visible }">Section 1</a>
  <a data-volt-class="{ active: section2Visible }">Section 2</a>
</nav>
<div data-volt-scroll="spy:section1Visible"></div>
<div data-volt-scroll="spy:section2Visible"></div>

Updates signal with boolean visibility state using Intersection Observer.

Smooth Scrolling:

<div data-volt-scroll="smooth:behavior"></div>

Enables smooth scrolling with configurable behavior from signal.

data-volt-url#

Synchronizes signal values with URL parameters and hash-based routing.

Syntax:

<!-- One-way: Read URL param into signal on mount -->
<input data-volt-url="read:searchQuery" />

<!-- Bidirectional: Keep URL and signal in sync -->
<input data-volt-url="sync:filter" />

<!-- Hash-based routing -->
<div data-volt-url="hash:currentRoute"></div>

Behaviors:

Read URL Parameters:

<!-- Initialize signal from ?tab=profile -->
<div data-volt-url="read:tab"></div>

Reads URL parameter on mount and sets signal value. Signal changes do not update URL.

Bidirectional Sync:

<!-- Keep ?search=query in sync with searchQuery signal -->
<input data-volt-on-input="handleSearch" data-volt-url="sync:searchQuery" />

You can also use the shorthand attribute form where the signal name is encoded in the attribute suffix:

<!-- Equivalent to data-volt-url="sync:searchQuery" -->
<input data-volt-url:searchQuery="query" />

Changes to signal update URL parameter, changes to URL update signal. Uses History API for clean URLs.

Hash Routing:

<!-- Sync with #/page/about -->
<div data-volt-url="hash:route"></div>
<div data-volt-text="route === '/page/about' ? 'About Page' : 'Home'"></div>

Keeps hash portion of URL in sync with signal. Useful for client-side routing.

Notes:

  • Uses History API (pushState/replaceState) for param sync
  • Listens to popstate for browser back/forward
  • Debounces URL updates to avoid excessive history entries
  • Automatically serializes/deserializes values (strings, numbers, booleans)
  • Accepts data-volt-url="mode:signal" or data-volt-url:signal="mode" forms
  • Supports query, hash, and history mode aliases in shorthand attributes (e.g., data-volt-url:filter="query")

Implementation#

Integration#

The binder system checks the plugin registry before falling through to unknown attribute warnings

Context#

The binder creates a PluginContext from BindingContext:

function createPluginContext(bindingContext: BindingContext): PluginContext {
  return {
    element: bindingContext.element,
    scope: bindingContext.scope,
    addCleanup: (fn) => bindingContext.cleanups.push(fn),
    findSignal: (path) => findSignalInScope(bindingContext.scope, path),
    evaluate: (expr) => evaluate(expr, bindingContext.scope)
  };
}

Module Structure#

src/
  core/
    plugin.ts       # Plugin registry and API
    binder.ts       # Modified to integrate plugins
  plugins/
    persist.ts      # Persistence plugin
    scroll.ts       # Scroll behavior plugin
    url.ts          # URL synchronization plugin
  index.ts          # Exports registerPlugin and built-in plugins

Bundle Size Considerations#

With explicit registration, applications control their bundle size:

  • Core framework: ~15 KB gzipped (no plugins)
  • Each plugin: ~1-3 KB gzipped
  • Applications import only what they use
  • Tree-shaking eliminates unused plugins

Example bundle breakdown:

volt/core         : 15 KB
volt/plugins/persist  : 2 KB
volt/plugins/scroll   : 2.5 KB
volt/plugins/url      : 1.5 KB
--------------------------------
Total (all plugins)   : 21 KB

Extension Points#

Future plugin capabilities:

  • Lifecycle hooks (beforeMount, afterMount, beforeUnmount)
  • Plugin dependencies and composition
  • Plugin configuration API
  • Async plugin initialization
  • Plugin registry