Skip to content

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:

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

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

ts
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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

ts
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

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

sh
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