npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

vanjs-reactive-element

v0.5.0

Published

A reactive custom element base class for VanJS with any reactivity system

Downloads

17

Readme

Van Reactive Element

A lightweight web components library that seamlessly integrates VanJS with custom elements. Build reactive web components with minimal boilerplate and maximum flexibility.

Features

  • 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

Installation

npm install vanjs-reactive-element vanjs-core

Quick Links

Usage

Examples

Counter Example - Class Component

A simple counter component demonstrating basic state management:

Try on CodePen

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:

Try on CodePen

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:

Try on CodePen

// Extract VanReactiveElement and css helper from vanRE
const { VanReactiveElement, css, define } = vanRE({ van });

// Define a custom element <todo-list> by extending VanReactiveElement
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 <todo-list>
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']({
  'title-attribute-postfix': 'Attribute Postfix',
  titlePropertyPostfix
});

// 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

Try on CodePen

// 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']({
  'title-attribute-postfix': 'Attribute Postfix',
  titlePropertyPostfix
});

// 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.

API

vanRE(options)

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 components
  • css - Tagged template literal for component styles
  • define - Function for creating functional components

VanReactiveElement Class

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 name
    • converter - Custom converter with fromAttribute and toAttribute methods
    • default - Default property value
    • reflect - Reflect property changes back to attributes
    • type - Property type (String, Number, Boolean, Object, Array) for automatic conversion
  • shadowRootOptions - 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 event
  • hasShadowDOM() - Check if the component uses shadow DOM
  • onCleanup() - Called when the component is disconnected from DOM
  • onMount() - Called after the component connects to DOM
  • query(selector) - Query single element within render root
  • queryAll(selector) - Query all elements within render root
  • registerDisposer(fn) - Register a cleanup function
  • render() - Define the component's content. Returns either:
    • Content directly with state objects for automatic reactivity: div(this.myState)
    • A function returning content for computed values or when using .val: () => 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
    Defines a custom element using the provided name. The name must include a hyphen (per custom elements spec). If no name 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.

define(customElementName, options, setup)

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:
    • Attributes (read-only): element.attributeName is a StateView
      • Reading: element.attributeName.val or direct binding element.attributeName in templates
      • Setting: Use element.setProperty('attributeName', 'new value') and element.setProperties(properties)
    • Properties (read-write): element.propertyName is a State
      • Reading: element.propertyName.val or direct binding element.propertyName in templates
      • Setting: element.propertyName.val = { new: 'value' } to update the State value or element.setProperty('attributeName', 'new value') and element.setProperties(properties) to update the State reference or value
    • Universal setters: element.setProperty('name', value) and element.setProperties(properties) work for both attributes and properties
  • context - Component context object:
    • noShadowDOM() - Disable shadow DOM
    • onCleanup(fn) - Set cleanup callback
    • onMount(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.

Important Notes

  1. Reactivity: Properties are not reactive by default. Use van.state() or van.derive() for reactive properties.
  2. Element Naming: You must provide the full custom element name (with hyphen) when calling define.
  3. Property Binding: Properties with attribute binding use van.state internally for reactivity.
  4. Element-based API in Functional Components:
    • Attributes are read-only (StateView) - use element.setProperty('attrName', value) and element.setProperties(properties) to update
    • Internal properties are read-write (State) - can be updated directly with element.propName.val = value

Example: Element API Usage

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));
  }
);

Reactivity Patterns

VanJS Reactive Element supports two patterns for reactive rendering:

  1. Direct State Binding (Recommended for simple cases):

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

    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

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);
  }
}

Adopted Stylesheets

VanJS Reactive Element supports modern CSS features like adopted stylesheets for better performance:

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.

Attributes and Properties

VanJS Reactive Element provides automatic attribute to property syncing with type conversion:

Property Configuration

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

// HTML
<my-element name="John Doe" count="42" active data='{"role": "admin"}'></my-element>;

// 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

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
      );
    };
  }
);

Slots and Content Distribution

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

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
<card-component>
  <h2 slot="header">Card Title</h2>
  <p>This is the card content</p>
  <p>Multiple elements can go in the default slot</p>
  <button slot="footer">Action</button>
</card-component>;

Working with Slots

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:

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

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;
    }
  }
}

License

MIT License