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:
- Parse the attribute value
- Set up bindings and subscriptions
- 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:
- On mount: Load persisted value into signal (if exists)
- On signal change: Persist new value to storage
- 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
popstatefor browser back/forward - Debounces URL updates to avoid excessive history entries
- Automatically serializes/deserializes values (strings, numbers, booleans)
- Accepts
data-volt-url="mode:signal"ordata-volt-url:signal="mode"forms - Supports
query,hash, andhistorymode 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