A reactive custom element base class for VanJS with any reactivity system
npm install vanjs-reactive-elementA lightweight web components library that seamlessly integrates VanJS with custom elements. Build reactive web components with minimal boilerplate and maximum flexibility.
- Seamless VanJS Integration - Use VanJS state and reactivity within web components
- Two Paradigms - Choose between class or functional component styles inspired by Lit and Solid Element
- Built-in Styling - Scoped CSS support with shadow DOM encapsulation and adopted stylesheets
- Reactive by Design - Automatic UI updates when state changes
- Attribute Syncing - Automatic attribute to property conversion with type coercion
- Property Reflection - Optionally reflect property changes back to attributes
- Developer Friendly - TypeScript support, comprehensive lifecycle hooks
``bash`
npm install vanjs-reactive-element vanjs-core
- Examples - Todo list from VanJS implemented in both class or functional component styles
- API Reference - Complete API documentation
- Reactivity Patterns - Learn about rendering patterns
- Attributes & Properties - Property configuration and type conversion
- Slots & Content - Content distribution with slots
#### Counter Example - Class Component
A simple counter component demonstrating basic state management:
`javascript
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van });
class CounterElement extends VanReactiveElement {
static properties = {
count: { attribute: false, default: 0 }
};
static get styles() {
return css
button {
margin: 0 5px;
}
;
}
render() {
const { button, p } = van.tags;
return [
p('Count: ', this.count),
button({ onclick: () => this.count.val-- }, 'Decrement'),
button({ onclick: () => this.count.val++ }, 'Increment')
];
}
}
// Register the custom element
CounterElement.define();
// Use it
van.add(document.body, van.tags['counter-element']());
`
#### Counter Example - Functional Component
The same counter using the functional approach:
`javascript
const CounterElement = define(
'counter-element',
{
properties: {
count: 0
},
styles: css
button {
margin: 0 5px;
}
},
(element, { noShadowDOM, onCleanup, onMount }) => {
return () => {
const { button, p } = van.tags;
return [
p('Count: ', element.count),
button({ onclick: () => element.count.val-- }, 'Decrement'),
button({ onclick: () => element.count.val++ }, 'Increment')
];
};
}
);
// Use it
van.add(document.body, van.tags['counter-element']());
`
#### Todo List Example - Class Component
A more complex example implementing todo functionality from VanJS:
`javascript
// Extract VanReactiveElement and css helper from vanRE
const { VanReactiveElement, css, define } = vanRE({ van });
// Define a custom element
class TodoList extends VanReactiveElement {
// Props with default values and types
static properties = {
title: { default: 'Todo List', type: String },
titleAttributePostfix: 'Will be replaced',
titlePropertyPostfix: { attribute: false, default: 'Will also be replaced' }
};
// Define component styles
static get styles() {
return css
a {
cursor: pointer;
}
;
}
// Factory method to create a reactive todo item
createTodoItem(text) {
const done = van.state(false); // Track if the todo is done
const deleted = van.state(false); // Track if the todo is deleted
const { div, input, span, del, a } = van.tags;
// Return a function that reactively renders the todo item
return () =>
deleted.val
? null // If deleted, render nothing
: div(
{ class: 'todo-item' },
input({
type: 'checkbox',
checked: done,
onclick: (e) => (done.val = e.target.checked) // Toggle done state
}),
() => (done.val ? del : span)(text), // Strike-through if done
a({ onclick: () => (deleted.val = true) }, '❌') // Delete button
);
}
// Render method for the main todo list UI
render() {
const { div, h2, input, button } = van.tags;
const inputDom = input({ type: 'text' }); // Input for new todo text
const count = van.state(0); // Track number of add clicks
const derived = van.derive(() => count.val * 2); // Derived state
// Main DOM structure
const dom = div(
h2(this.title, ' - Attribute: ', this.titleAttributePostfix, ' - Property: ', this.titlePropertyPostfix),
div('Add Click Count: ', count, ' * 2 = ', derived),
inputDom,
button(
{
onclick: () => (
count.val++, // Increment count
van.add(dom, this.createTodoItem(inputDom.value)) // Add new todo item
)
},
'Add'
)
);
return dom;
}
}
// Register the custom element as
TodoList.define();
// State for the property postfix, updates every second
const titlePropertyPostfix = van.state(new Date().toLocaleTimeString());
// Create the todo-list element and set its attribute postfix
const todoList = van.tags'todo-list';
// Update postfixes every second
const intervalId = setInterval(() => {
const timeString = new Date().toLocaleTimeString();
titlePropertyPostfix.val = timeString; // Update property postfix
todoList.setAttribute('title-attribute-postfix', timeString); // Update attribute postfix
}, 1000);
// Mount the todo list to the document body
van.add(document.body, todoList);
`
> Tip: You can always use the native customElements.define method to register your component classes if you prefer. The provided define method is a convenience, but not required.
#### Todo List Example - Functional Component
`javascript
// Define a custom element "todo-list"
const TodoList = define(
'todo-list',
{
// Attributes (reactive, read-only, settable via setProperty/setProperties)
attributes: {
title: { type: String, default: 'Todo List' },
titleAttributePostfix: { type: String, default: 'Will be replaced' }
},
// Properties (reactive, read-write directly, settable via setProperty/setProperties)
properties: {
titlePropertyPostfix: 'Will also be replaced'
},
// Component styles
styles: css
a {
cursor: pointer;
}
},
(element, { noShadowDOM, onCleanup, onMount }) => {
// Factory for creating a todo item component
const createTodoItem = (text) => {
const done = van.state(false); // Track if todo is done
const deleted = van.state(false); // Track if todo is deleted
const { div, input, span, del, a } = van.tags;
// Return a function that renders the todo item reactively
return () =>
deleted.val
? null // If deleted, render nothing
: div(
{ class: 'todo-item' },
input({
type: 'checkbox',
checked: done,
onclick: (e) => (done.val = e.target.checked)
}),
() => (done.val ? del : span)(text), // Strike-through if done
a({ onclick: () => (deleted.val = true) }, '❌') // Delete button
);
};
// Return the render function for the todo list
return () => {
const { div, h2, input, button } = van.tags;
const inputDom = input({ type: 'text' }); // Input for new todos
const count = van.state(0); // Count of add clicks
const derived = van.derive(() => count.val * 2); // Derived state
// Main DOM structure
const dom = div(
h2(element.title, ' - Attribute: ', element.titleAttributePostfix, ' - Property: ', element.titlePropertyPostfix),
div('Add Click Count: ', count, ' * 2 = ', derived),
inputDom,
button(
{
onclick: () => (
count.val++, // Increment count
van.add(dom, createTodoItem(inputDom.value)) // Add new todo item
)
},
'Add'
)
);
return dom;
};
}
);
// Note: The define function returns a custom element class, which can be subclassed or registered manually if needed.
// State for the property postfix, updates every second
const titlePropertyPostfix = van.state(new Date().toLocaleTimeString());
// Create the todo-list element, set attribute postfix
const todoList = van.tags'todo-list';
// Update postfixes every second
const intervalId = setInterval(() => {
const timeString = new Date().toLocaleTimeString();
titlePropertyPostfix.val = timeString; // Update property postfix
todoList.setAttribute('title-attribute-postfix', timeString); // Update attribute postfix
}, 1000);
// Mount the todo list to the document body
van.add(document.body, todoList);
`
Note: When using the define function, you must provide the full tag name (including a hyphen) as required by the web components spec.
Initialize the VanJS Reactive Element library.
Parameters:
- options.rxScope - Optional, reactive scope function for managing component lifecycle (typically effectScope from your reactivity library)options.van
- - Required, VanJS instance with required add and state methods
Returns:
- VanReactiveElement - Base class for creating class componentscss
- - Tagged template literal for component stylesdefine
- - Function for creating functional components
Base class for creating web components with VanJS integration.
#### Static Properties
- properties - Component property definitions with configuration:attribute
- - Enable attribute binding (true by default) or specify custom attribute nameconverter
- - Custom converter with fromAttribute and toAttribute methodsdefault
- - Default property valuereflect
- - Reflect property changes back to attributestype
- - Property type (String, Number, Boolean, Object, Array) for automatic conversionshadowRootOptions
- - Shadow root creation options (default: { mode: 'open' })styles
- - Component CSS styles (scoped to shadow DOM). Use css tagged template literal or CSSStyleSheet for adopted stylesheets
#### Instance Methods
- createRenderRoot() - Create the render root (override to return this for light DOM)dispatchCustomEvent(name, options)
- - Dispatch a custom eventhasShadowDOM()
- - Check if the component uses shadow DOMonCleanup()
- - Called when the component is disconnected from DOMonMount()
- - Called after the component connects to DOMquery(selector)
- - Query single element within render rootqueryAll(selector)
- - Query all elements within render rootregisterDisposer(fn)
- - Register a cleanup functionrender()
- - Define the component's content. Returns either:div(this.myState)
- Content directly with state objects for automatic reactivity: .val
- A function returning content for computed values or when using : () => display.val ? div(this.myState) : ''setProperty(name, value)
- - Set a single property value (works for both attributes and properties)setProperties(properties)
- - Set multiple properties at once using an object (works for both attributes and properties)
#### Static Methods
- define(name?) - Register the component as a custom element name
Defines a custom element using the provided name. The name must include a hyphen (per custom elements spec).
If no is provided, the class name of the component will be used to generate the custom element name by converting from PascalCase/camelCase to kebab-case.
Create a functional component.
Parameters:
- customElementName - Custom element name (must include a hyphen)options
- - Component configuration object:attributes
- - Attribute property definitions (become StateView - read-only)properties
- - Internal property definitions (become State - read-write)styles
- - Component styles (use css tagged template literal or CSSStyleSheet)shadowRootOptions
- - Shadow root configuration (default: { mode: 'open' })setup
- - Setup function called once per instance that returns the render function
Returns:
A custom element class, which can be subclassed or registered manually if needed.
Setup Function Parameters:
- element - The custom element instance with typed reactive properties:element.attributeName
- Attributes (read-only): is a StateViewelement.attributeName.val
- Reading: or direct binding element.attributeName in templateselement.setProperty('attributeName', 'new value')
- Setting: Use and element.setProperties(properties)element.propertyName
- Properties (read-write): is a Stateelement.propertyName.val
- Reading: or direct binding element.propertyName in templateselement.propertyName.val = { new: 'value' }
- Setting: to update the State value or element.setProperty('attributeName', 'new value') and element.setProperties(properties) to update the State reference or valueelement.setProperty('name', value)
- Universal setters: and element.setProperties(properties) work for both attributes and propertiescontext
- - Component context object:noShadowDOM()
- - Disable shadow DOMonCleanup(fn)
- - Set cleanup callbackonMount(fn)
- - Set mount callback
Setup Function Returns:
The setup function must return a render function that defines the component's content, or nothing (void) if no rendering is needed.
1. Reactivity: Properties are not reactive by default. Use van.state() or van.derive() for reactive properties.define
2. Element Naming: You must provide the full custom element name (with hyphen) when calling .van.state
3. Property Binding: Properties with attribute binding use internally for reactivity.StateView
4. Element-based API in Functional Components:
- Attributes are read-only () - use element.setProperty('attrName', value) and element.setProperties(properties) to updateState
- Internal properties are read-write () - can be updated directly with element.propName.val = value
#### Example: Element API Usage
`javascript
define(
'my-component',
{
attributes: {
// These become StateView (read-only)
name: { type: String, default: 'World' },
count: { type: Number, default: 0 }
},
properties: {
// These become State (read-write)
data: { foo: 'bar' },
items: []
}
},
(element) => {
// Reading attributes (StateView)
console.log(element.name.val); // "World"
// Setting attributes via setProperties method
element.setProperties({ count: 42, name: 'Hello' });
// Reading/writing properties
element.data.val = { foo: 'updated' }; // ✅ Updates value properly
element.items.val.push('new item'); // ✅ Modifying array contents
// Using setProperty method (works for both attributes and properties)
element.setProperty('data', { foo: 'via setter' }); // ✅ Type-safe value update
// Return render function
return () => div('Name: ', element.name, ' Count: ', element.count, ' Data: ', () => JSON.stringify(element.data.val));
}
);
`
VanJS Reactive Element supports two patterns for reactive rendering:
1. Direct State Binding (Recommended for simple cases):
`javascript`
render() {
const {div} = van.tags;
// Pass derive/state objects directly - VanJS handles reactivity
return div('Count: ', this.count);
}
2. Function-based Rendering (Required for computed values or .val access):`
javascript`
render() {
const {div} = van.tags;
// Return a function when using computed values or .val
return () => div(
div('Count: ', this.count),
div('Doubled: ', () => this.count.val * 2)
);
}
Use direct derive/state binding when possible for cleaner code. Use function-based rendering when you need to access .val use computed properties that aren't van.derive states.
#### Example: When to Use Each Pattern
`javascript
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class MyComponent extends VanReactiveElement {
// State
count = van.state(0);
multiplier = van.state(2);
// Computed getter (not a van.derive state)
get doubled() {
return this.count.val * 2;
}
render() {
const { div } = van.tags;
// ❌ Won't be reactive - getter is called once
// return div('Doubled: ', this.doubled);
// ✅ Use van.derive state or function wrapper for computed values
return div('Doubled: ', () => this.doubled);
}
}
`
VanJS Reactive Element supports modern CSS features like adopted stylesheets for better performance:
`javascript
class MyComponent extends VanReactiveElement {
// Create a reusable stylesheet
static stylesheet = new CSSStyleSheet();
static {
// Populate the stylesheet
this.stylesheet.replaceSync(
:host {
display: block;
padding: 20px;
}
.title {
color: #333;
font-size: 24px;
}
);
}
// Return the stylesheet instead of a string
static get styles() {
return this.stylesheet;
}
render() {
const { h1 } = van.tags;
return h1({ class: 'title' }, 'Hello World');
}
}
`
Adopted stylesheets are more efficient for components that are used multiple times, as the browser can share the same stylesheet across all instances.
VanJS Reactive Element provides automatic attribute to property syncing with type conversion:
#### Property Configuration
`javascript
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class MyElement extends VanReactiveElement {
static properties = {
// Simple property with default value
name: { default: 'Anonymous' },
// Property with type converter
count: {
type: Number,
default: 0
},
// Property with custom attribute name
isActive: {
type: Boolean,
default: false,
attribute: 'active' // Maps to 'active' attribute instead of 'is-active'
},
// Property that reflects changes back to attribute
status: {
type: String,
default: 'pending',
reflect: true
},
// Property with custom converter
data: {
type: Object,
converter: {
fromAttribute: (value) => (value ? JSON.parse(value) : null),
toAttribute: (value) => (value ? JSON.stringify(value) : null)
}
},
// Property without attribute binding
internal: {
default: null,
attribute: false
}
};
}
`
#### Built-in Type Converters
- String: Pass-through (default)
- Number: Converts to/from numeric values
- Boolean: Presence = true, absence = false
- Object/Array: JSON serialization
#### Usage Example
`javascript
// HTML
// JavaScript
const element = document.querySelector('my-element');
// Properties are automatically synced from attributes
console.log(element.name); // "John Doe"
console.log(element.count); // 42 (number)
console.log(element.isActive); // true (boolean)
console.log(element.data); // { role: "admin" } (object)
// Update properties
element.count = 100;
element.status = 'completed';
// Reflected properties update attributes
console.log(element.getAttribute('status')); // "completed"
`
#### Functional Component Attributes
`javascript
const ToggleButton = define(
'toggle-button',
{
attributes: {
pressed: {
type: Boolean,
default: false,
reflect: true,
attribute: 'aria-pressed' // Property with custom ARIA attribute name
},
label: {
type: String,
default: 'Toggle'
}
}
},
(element, {}) => {
return () => {
const { button } = van.tags;
return button(
{
'aria-pressed': element.pressed,
onclick: () => element.setProperty('pressed', !element.pressed.val)
},
element.label
);
};
}
);
`
VanJS Reactive Element fully supports Web Components slots for content distribution.
Note: Use native slot APIs such as slot.assignedNodes() and slot.assignedElements() for slot management:
#### Shadow DOM Slots
`javascript
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class CardComponent extends VanReactiveElement {
static get styles() {
return css
:host {
display: block;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.header {
background: #f5f5f5;
padding: 16px;
font-weight: bold;
}
.body {
padding: 16px;
}
.footer {
padding: 16px;
border-top: 1px solid #eee;
}
;
}
render() {
const { div, slot } = van.tags;
return div(
{ class: 'card' },
div({ class: 'header' }, slot({ name: 'header' }, 'Default Header')),
div({ class: 'body' }, slot('Default Content')),
div({ class: 'footer' }, slot({ name: 'footer' }))
);
}
}
// Usage This is the card content Multiple elements can go in the default slot
Card Title
`
#### Working with Slots
`javascript
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class SlotAwareComponent extends VanReactiveElement {
onMount() {
// Access slots directly using standard Web Component APIs
const slots = this.renderRoot.querySelectorAll('slot');
slots.forEach((slot) => {
// Get assigned nodes for each slot
const assignedNodes = slot.assignedNodes();
const assignedElements = slot.assignedElements();
// Listen for slot changes
slot.addEventListener('slotchange', (e) => {
const newNodes = e.target.assignedNodes();
console.log(Slot "${slot.name || 'default'}" changed:, newNodes);
});
});
// Check specific slot content
const footerSlot = this.renderRoot.querySelector('slot[name="footer"]');
if (footerSlot && footerSlot.assignedNodes().length === 0) {
console.log('No footer content provided');
}
}
}
`
#### Light DOM Content Organization
For components using light DOM (no shadow DOM), override createRenderRoot() to return this:
`javascript
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class LightDomLayout extends VanReactiveElement {
createRenderRoot() {
return this; // Use light DOM
}
render() {
const { div, header, main, footer } = van.tags;
return div({ class: 'layout' }, header({ class: 'header-area' }), main({ class: 'content-area' }), footer({ class: 'footer-area' }));
}
onMount() {
// Organize children by slot attribute
Array.from(this.children).forEach((child) => {
const slot = child.getAttribute('slot');
if (slot === 'header') {
this.querySelector('.header-area')?.appendChild(child);
} else if (slot === 'footer') {
this.querySelector('.footer-area')?.appendChild(child);
} else {
this.querySelector('.content-area')?.appendChild(child);
}
});
}
}
`
#### Conditional Slot Rendering
`javascript
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class ConditionalSlots extends VanReactiveElement {
hasIcon = van.state(false);
render() {
const { div, span, slot } = van.tags;
return () =>
div(
{ class: 'button-wrapper' },
this.hasIcon.val && span({ class: 'icon' }, slot({ name: 'icon' })),
span({ class: 'label' }, slot('Button'))
);
}
onMount() {
// Check if icon slot has content
const iconSlot = this.renderRoot.querySelector('slot[name="icon"]');
if (iconSlot) {
this.hasIcon.val = iconSlot.assignedNodes().length > 0;
}
}
}
``
MIT License