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 🙏

© 2026 – Pkg Stats / Ryan Hefner

hyper-element

v2.0.0

Published

Fast, lightweight web components

Readme

hyper-element

npm version npm package size CI License: MIT Coverage XSS Protected ES6+

A lightweight Custom Elements library with a fast, built-in render core. Your custom-element will react to tag attribute and store changes with efficient DOM updates.

If you like it, please ★ it on github

Installation

npm

npm install hyper-element

ES6 Modules

import hyperElement from 'hyper-element';

hyperElement('my-elem', (Html, ctx) => Html`Hello ${ctx.attrs.who}!`);

CommonJS

const hyperElement = require('hyper-element');

hyperElement('my-elem', (Html, ctx) => Html`Hello ${ctx.attrs.who}!`);

CDN (Browser)

For browser environments without a bundler:

<script src="https://cdn.jsdelivr.net/npm/hyper-element@latest/build/hyperElement.min.js"></script>

The hyperElement class will be available globally on window.hyperElement.

Browser Support

hyper-element requires native ES6 class support and the Custom Elements v1 API:

| Browser | Version | | ------- | ------- | | Chrome | 86+ | | Firefox | 78+ | | Safari | 14.1+ | | Edge | 86+ |

For older browsers, a Custom Elements polyfill may be required.

Why hyper-element

  • hyper-element is fast & small
    • Zero runtime dependencies - everything is built-in
  • With a completely stateless approach, setting and reseting the view is trivial
  • Simple yet powerful Interface
  • Built in template system to customise the rendered output
  • Inline style objects supported (similar to React)
  • First class support for data stores
  • Server-side rendering with progressive hydration
  • Pass function to other custom hyper-elements via there tag attribute

Live Demo

Live Examples

| Example | Description | Link | | -------------------- | ----------------------------------- | ---------------------------------------------------------- | | Hello World | Basic element creation | CodePen | | Attach a Store | Store integration with setup() | CodePen | | Templates | Using the template system | CodePen | | Child Element Events | Passing functions to child elements | CodePen | | Async Fragments | Loading content asynchronously | CodePen | | Styling | React-style inline styles | CodePen | | Full Demo | Complete feature demonstration | JSFiddle |



Define a custom-element

<!DOCTYPE html>
<html>
  <head>
    <script src="https://cdn.jsdelivr.net/npm/hyper-element@latest/build/hyperElement.min.js"></script>
  </head>
  <body>
    <my-elem who="world"></my-elem>
    <script>
      customElements.define(
        'my-elem',
        class extends hyperElement {
          render(Html) {
            Html`hello ${this.attrs.who}`;
          }
        }
      );
    </script>
  </body>
</html>

Output

<my-elem who="world"> hello world </my-elem>

Live Example of helloworld


Functional API

In addition to class-based components, hyper-element supports a functional API that hides the class internals. This is useful for simpler components or if you prefer a more functional programming style.

Signatures

// 1. Full definition with tag (auto-registers)
hyperElement('my-counter', {
  setup: (ctx, onNext) => {
    /* ... */
  },
  render: (Html, ctx, store) => Html`Count: ${ctx.attrs.count}`,
});

// 2. Shorthand with tag (auto-registers)
hyperElement('hello-world', (Html, ctx) => Html`Hello, ${ctx.attrs.name}!`);

// 3. Definition without tag (returns class for manual registration)
const MyElement = hyperElement({
  render: (Html, ctx) => Html`...`,
});
customElements.define('my-element', MyElement);

// 4. Shorthand without tag (returns class for manual registration)
const Simple = hyperElement((Html, ctx) => Html`Simple!`);
customElements.define('simple-elem', Simple);

Context Object

In the functional API, instead of using this, a context object (ctx) is passed explicitly to all functions:

| Property | Description | | -------------------- | ---------------------------------------------- | | ctx.element | The DOM element | | ctx.attrs | Parsed attributes with automatic type coercion | | ctx.dataset | Dataset proxy with automatic type coercion | | ctx.store | Store value from setup | | ctx.wrappedContent | Text content between the tags |

Example: Counter with Setup

hyperElement('my-counter', {
  setup: (ctx, onNext) => {
    const store = { count: 0 };
    const render = onNext(() => store);

    ctx.increment = () => {
      store.count++;
      render();
    };
  },

  handleClick: (ctx, event) => ctx.increment(),

  render: (Html, ctx, store) => Html`
    <button onclick=${ctx.handleClick}>
      Count: ${store?.count || 0}
    </button>
  `,
});

Example: Timer with Teardown

hyperElement('my-timer', {
  setup: (ctx, onNext) => {
    let seconds = 0;
    const render = onNext(() => ({ seconds }));

    const interval = setInterval(() => {
      seconds++;
      render();
    }, 1000);

    // Return cleanup function
    return () => clearInterval(interval);
  },

  render: (Html, ctx, store) => Html`Elapsed: ${store?.seconds || 0}s`,
});

Backward Compatibility

The functional API is fully backward compatible. Class-based components still work:

class MyElement extends hyperElement {
  render(Html) {
    Html`Hello ${this.attrs.name}!`;
  }
}
customElements.define('my-element', MyElement);

Lifecycle

When a hyper-element is connected to the DOM, it goes through the following initialization sequence:

  1. Element connected to DOM
  2. Unique identifier created
  3. MutationObserver attached (watches for attribute/content changes)
  4. Fragment methods defined (methods starting with capital letters)
  5. Attributes and dataset attached to this
  6. setup() called (if defined)
  7. Initial render() called

After initialization, the element will automatically re-render when:

  • Attributes change
  • Content mutations occur (innerHTML/textContent changes)
  • Store updates trigger onStoreChange()

Interface

Define your element

There are 2 functions. render is required and setup is optional

render

This is what will be displayed within your element. Use the Html to define your content.

render(Html, store) {
  Html`
    <h1>
      Last updated at ${new Date().toLocaleTimeString()}
    </h1>
  `;
}

The second argument store contains the value returned from your store function (if using setup()).


Html

The primary operation is to describe the complete inner content of the element.

render(Html, store) {
  Html`
    <h1>
      Last updated at ${new Date().toLocaleTimeString()}
    </h1>
  `;
}

The Html has a primary operation and two utilities: .wire & .lite


Html.wire

Create reusable sub-elements with object/id binding for efficient rendering.

The wire takes two arguments Html.wire(obj, id):

  1. A reference object to match with the created node, allowing reuse of the existing node
  2. A string to identify the markup used, allowing the template to be generated only once

Example: Rendering a List

Html`
  <ul>
    ${users.map((user) => Html.wire(user, ':user_list_item')`<li>${user.name}</li>`)}
  </ul>
`;

Anti-pattern: Inlining Markup as Strings

BAD example:

Html`
  <ul>
    ${users.map((user) => `<li>${user.name}</li>`)}
  </ul>
`;

This creates a new node for every element on every render, causing:

  • Negative impact on performance
  • Output will not be sanitized - potential XSS vulnerability

Block Syntax

The Html function supports block syntax for iteration and conditionals directly in tagged template literals:

| Syntax | Description | | ------------------------------------- | --------------------- | | {+each ${array}}...{-each} | Iterate over arrays | | {+if ${condition}}...{-if} | Conditional rendering | | {+if ${condition}}...{else}...{-if} | Conditional with else | | {+unless ${condition}}...{-unless} | Negated conditional |

{+each} - Iteration

For cleaner list rendering, use the {+each}...{-each} syntax:

Html`<ul>{+each ${users}}<li>{name}</li>{-each}</ul>`;

This is equivalent to:

Html`<ul>${users.map((user) => Html.wire(user, ':id')`<li>${user.name}</li>`)}</ul>`;

The {+each} syntax automatically calls Html.wire() for each item, ensuring efficient DOM reuse.

Available variables inside {+each}:

| Syntax | Description | | -------------------- | ----------------------------------------- | | {name} | Access item property | | {address.city} | Nested property access | | {...} or { ... } | Current item value (see formatting below) | | {@} | Current array index (0-based) |

Formatting rules for {...} output:

| Type | Output | | ----------------------------------- | ----------------------------------------------------- | | Primitive (string, number, boolean) | toString() and HTML escaped | | Array | .join(",") | | Object | JSON.stringify() | | Function | Called with no args, return value follows these rules |

Examples:

// Multiple properties
Html`<ul>{+each ${users}}<li>{name} ({age})</li>{-each}</ul>`;

// Using index
Html`<ol>{+each ${items}}<li>{@}: {title}</li>{-each}</ol>`;

// Nested arrays with {+each {property}}
const categories = [
  { name: 'Fruits', items: [{ title: 'Apple' }, { title: 'Banana' }] },
  { name: 'Veggies', items: [{ title: 'Carrot' }] },
];
Html`
  {+each ${categories}}
    <section>
      <h3>{name}</h3>
      <ul>{+each {items}}<li>{title}</li>{-each}</ul>
    </section>
  {-each}
`;

{+if} - Conditionals

Render content based on a condition:

Html`{+if ${isLoggedIn}}<p>Welcome back!</p>{-if}`;

// With else
Html`{+if ${isLoggedIn}}<p>Welcome back!</p>{else}<p>Please log in</p>{-if}`;

{+unless} - Negated Conditionals

Render content when condition is falsy (opposite of {+if}):

Html`{+unless ${hasErrors}}<p>Form is valid</p>{-unless}`;

// With else
Html`{+unless ${isValid}}Invalid input!{else}Looking good!{-unless}`;

Html.lite

Create once-off sub-elements for integrating external libraries.

Example: Wrapping jQuery DatePicker

customElements.define(
  'date-picker',
  class extends hyperElement {
    onSelect(dateText, inst) {
      console.log('selected time ' + dateText);
    }

    Date(lite) {
      const inputElem = lite`<input type="text"/>`;
      $(inputElem).datepicker({ onSelect: this.onSelect });
      return {
        any: inputElem,
        once: true,
      };
    }

    render(Html) {
      Html`Pick a date ${{ Date: Html.lite }}`;
    }
  }
);

The once: true option ensures the fragment is only generated once, preventing the datepicker from being reinitialized on every render.


Html.raw

Mark a string as trusted HTML that should not be escaped. Use this when you have HTML from a trusted source that you need to render directly.

Warning: Only use with trusted content. Never use with user-provided input as it bypasses XSS protection.

render(Html) {
  const trustedHtml = '<strong>Bold</strong> and <em>italic</em>';
  Html`<div>${Html.raw(trustedHtml)}</div>`;
}

Output:

<div><strong>Bold</strong> and <em>italic</em></div>

Without Html.raw(), the HTML would be escaped:

<div>&lt;strong&gt;Bold&lt;/strong&gt; and &lt;em&gt;italic&lt;/em&gt;</div>

setup

The setup function wires up an external data-source. This is done with the attachStore argument that binds a data source to your renderer.

setup(attachStore) {
  // the getMouseValues function will be called before each render and passed to render
  const onStoreChange = attachStore(getMouseValues);

  // call onStoreChange on every mouse event
  onMouseMove(onStoreChange);

  // cleanup logic
  return () => console.warn('On remove, do component cleanup here');
}

Live Example of attach a store

Re-rendering Without a Data Source

You can trigger re-renders without any external data:

setup(attachStore) {
  setInterval(attachStore(), 1000); // re-render every second
}

Set Initial Values

Pass static data to every render:

setup(attachStore) {
  attachStore({ max_levels: 3 }); // passed to every render
}

Cleanup on Removal

Return a function from setup to run cleanup when the element is removed from the DOM:

setup(attachStore) {
  let newSocketValue;
  const onStoreChange = attachStore(() => newSocketValue);
  const ws = new WebSocket('ws://127.0.0.1/data');

  ws.onmessage = ({ data }) => {
    newSocketValue = JSON.parse(data);
    onStoreChange();
  };

  // Return cleanup function
  return ws.close.bind(ws);
}

Multiple Subscriptions

You can trigger re-renders from multiple sources:

setup(attachStore) {
  const onStoreChange = attachStore(user);

  mobx.autorun(onStoreChange); // update when changed (real-time feedback)
  setInterval(onStoreChange, 1000); // update every second (update "the time is now ...")
}

this

Available properties and methods on this:

| Property | Description | | --------------------- | --------------------------------------------------------------------------- | | this.attrs | Attributes on the tag. <my-elem min="0" max="10" /> = { min:0, max:10 } | | this.store | Value returned from the store function. Only updated before each render | | this.wrappedContent | Text content between your tags. <my-elem>Hi!</my-elem> = "Hi!" | | this.element | Reference to your created DOM element | | this.dataset | Read/write access to all data-* attributes | | this.innerShadow | Get the innerHTML of the element's rendered content |

this.attrs

Attributes are automatically type-coerced:

| Input | Output | Type | | --------- | --------- | ------ | | "42" | 42 | Number | | "3.14" | 3.14 | Number | | "hello" | "hello" | String |

this.dataset

The dataset provides proxied access to data-* attributes with automatic JSON parsing:

| Attribute Value | this.dataset Value | Type | | ------------------------ | -------------------- | ------- | | data-count="42" | 42 | Number | | data-active="true" | true | Boolean | | data-active="false" | false | Boolean | | data-users='["a","b"]' | ["a", "b"] | Array | | data-config='{"x":1}' | { x: 1 } | Object |

Example:

<my-elem data-users='["ann","bob"]'></my-elem>
this.dataset.users; // ["ann", "bob"]

The dataset is a live reflection. Changes update the matching data attribute on the element:

this.dataset.user = { name: 'Alice' }; // Updates data-user attribute

Advanced Attributes

Dynamic Attributes with Custom-element Children

Being able to set attributes at run-time should be the same for dealing with a native element and ones defined by hyper-element.

⚠ To support dynamic attributes on custom elements YOU MUST USE customElements.define which requires native ES6 support! Use /build/hyperElement.min.js.

This is what allows for the passing any dynamic attributes from parent to child custom element! You can also pass a function, boolean, number, or object to a child element (that extends hyperElement).

Example:

window.customElements.define(
  'a-user',
  class extends hyperElement {
    render(Html) {
      const onClick = () => this.attrs.hi('Hello from ' + this.attrs.name);
      Html`${this.attrs.name} <button onclick=${onClick}>Say hi!</button>`;
    }
  }
);

window.customElements.define(
  'users-elem',
  class extends hyperElement {
    onHi(val) {
      console.log('hi was clicked', val);
    }
    render(Html) {
      Html`<a-user hi=${this.onHi} name="Beckett" />`;
    }
  }
);

Live Example of passing an onclick to a child element


Templates

Unlike standard Custom Elements which typically discard or replace their innerHTML, hyper-element's template system preserves the markup inside your element and uses it as a reusable template. This means your custom element primarily holds logic, while the template markup between the tags defines how data should be rendered.

To enable templates:

  1. Add a template attribute to your custom element
  2. Define the template markup within your element
  3. Call Html.template(data) in your render method to populate the template

Example:

<my-list template data-json='[{"name":"ann","url":""},{"name":"bob","url":""}]'>
  <div>
    <a href="{url}">{name}</a>
  </div>
</my-list>
customElements.define(
  'my-list',
  class extends hyperElement {
    render(Html) {
      Html`${this.dataset.json.map((user) => Html.template(user))}`;
    }
  }
);

Output:

<my-list template data-json='[{"name":"ann","url":""},{"name":"bob","url":""}]'>
  <div>
    <a href="">ann</a>
  </div>
  <div>
    <a href="">bob</a>
  </div>
</my-list>

Live Example of using templates


Basic Template Syntax

| Syntax | Description | | ---------------------------------- | ------------------------------------- | | {variable} | Simple interpolation | | {+if condition}...{-if} | Conditional rendering | | {+if condition}...{else}...{-if} | Conditional with else | | {+unless condition}...{-unless} | Negative conditional (opposite of if) | | {+each items}...{-each} | Iteration over arrays | | {@} | Current index in each loop (0-based) |


Conditionals: {+if}

Show content based on a condition:

<status-elem template>{+if active}Online{else}Offline{-if}</status-elem>
customElements.define(
  'status-elem',
  class extends hyperElement {
    render(Html) {
      Html`${Html.template({ active: true })}`;
    }
  }
);

Output: Online


Negation: {+unless}

Show content when condition is falsy (opposite of +if):

<warning-elem template>{+unless valid}Invalid input!{-unless}</warning-elem>
customElements.define(
  'warning-elem',
  class extends hyperElement {
    render(Html) {
      Html`${Html.template({ valid: false })}`;
    }
  }
);

Output: Invalid input!


Iteration: {+each}

Loop over arrays:

<list-elem template>
  <ul>
    {+each items}
    <li>{name}</li>
    {-each}
  </ul>
</list-elem>
customElements.define(
  'list-elem',
  class extends hyperElement {
    render(Html) {
      Html`${Html.template({ items: [{ name: 'Ann' }, { name: 'Bob' }] })}`;
    }
  }
);

Output:

<ul>
  <li>Ann</li>
  <li>Bob</li>
</ul>

Special Variables in {+each}

  • {@} - The current index (0-based)
<nums-elem template>{+each numbers}{@}: {number}, {-each}</nums-elem>
customElements.define(
  'nums-elem',
  class extends hyperElement {
    render(Html) {
      Html`${Html.template({ numbers: ['a', 'b', 'c'] })}`;
    }
  }
);

Output: 0: a, 1: b, 2: c,


Fragments

Fragments are pieces of content that can be loaded asynchronously.

You define one with a class property starting with a capital letter.

The fragment function should return an object with:

  • placeholder: the placeholder to show while resolving
  • once: Only generate the fragment once (default: false)

And one of the following as the result:

  • text: An escaped string to output
  • any: Any type of content
  • html: A html string to output (not sanitised)
  • template: A template string to use (is sanitised)

Example:

customElements.define(
  'my-friends',
  class extends hyperElement {
    FriendCount(user) {
      return {
        once: true,
        placeholder: 'loading your number of friends',
        text: fetch('/user/' + user.userId + '/friends')
          .then((b) => b.json())
          .then((friends) => `you have ${friends.count} friends`)
          .catch((err) => 'problem loading friends'),
      };
    }

    render(Html) {
      const userId = this.attrs.myId;
      Html`<h2> ${{ FriendCount: userId }} </h2>`;
    }
  }
);

Live Example of using an asynchronous fragment


Styling

Supports an object as the style attribute. Compatible with React's implementation.

Example: of centering an element

render(Html) {
  const style = {
    position: 'absolute',
    top: '50%',
    left: '50%',
    marginRight: '-50%',
    transform: 'translate(-50%, -50%)',
  };
  Html`<div style=${style}> center </div>`;
}

Live Example of styling


Connecting to a Data Store

hyper-element integrates with any state management library via setup(). The pattern is:

  1. Call attachStore() with a function that returns your state
  2. Subscribe to your store and call the returned function when state changes

Backbone

var user = new (Backbone.Model.extend({
  defaults: {
    name: 'Guest User',
  },
}))();

customElements.define(
  'my-profile',
  class extends hyperElement {
    setup(attachStore) {
      user.on('change', attachStore(user.toJSON.bind(user)));
      // OR user.on("change", attachStore(() => user.toJSON()));
    }

    render(Html, { name }) {
      Html`Profile: ${name}`;
    }
  }
);

MobX

const user = observable({
  name: 'Guest User',
});

customElements.define(
  'my-profile',
  class extends hyperElement {
    setup(attachStore) {
      mobx.autorun(attachStore(user));
    }

    render(Html, { name }) {
      Html`Profile: ${name}`;
    }
  }
);

Redux

customElements.define(
  'my-profile',
  class extends hyperElement {
    setup(attachStore) {
      store.subscribe(attachStore(store.getState));
    }

    render(Html, { user }) {
      Html`Profile: ${user.name}`;
    }
  }
);

Signals

hyper-element includes a built-in signals API for fine-grained reactivity, similar to Solid.js or Preact Signals. Signals provide automatic dependency tracking and efficient updates.

import { signal, computed, effect, batch, untracked } from 'hyper-element';

signal

Creates a reactive signal that holds a value and notifies subscribers when it changes.

const count = signal(0);

// Read value (tracks dependencies in effects/computed)
console.log(count.value); // 0

// Write value (notifies subscribers)
count.value = 1;

// Read without tracking
count.peek(); // 1

// Subscribe to changes
const unsubscribe = count.subscribe(() => {
  console.log('Count changed:', count.peek());
});

computed

Creates a derived signal that automatically recomputes when its dependencies change. Computation is lazy and cached.

const count = signal(0);
const doubled = computed(() => count.value * 2);

console.log(doubled.value); // 0

count.value = 5;
console.log(doubled.value); // 10

// Read without tracking
doubled.peek(); // 10

effect

Creates a side effect that runs immediately and re-runs whenever its dependencies change. Can return a cleanup function.

const count = signal(0);

// Effect runs immediately, then on every change
const cleanup = effect(() => {
  console.log('Count is:', count.value);

  // Optional cleanup function
  return () => {
    console.log('Cleaning up previous effect');
  };
});

count.value = 1;
// Logs: "Cleaning up previous effect"
// Logs: "Count is: 1"

// Stop the effect
cleanup();

batch

Batches multiple signal updates so effects only run once after all updates complete.

const firstName = signal('John');
const lastName = signal('Doe');

effect(() => {
  console.log(`${firstName.value} ${lastName.value}`);
});
// Logs: "John Doe"

// Without batch: effect would run twice
// With batch: effect runs once after both updates
batch(() => {
  firstName.value = 'Jane';
  lastName.value = 'Smith';
});
// Logs: "Jane Smith" (only once)

untracked

Reads signals without creating dependencies. Useful for reading values in effects without subscribing to changes.

const count = signal(0);
const other = signal('hello');

effect(() => {
  // This dependency IS tracked
  console.log('Count:', count.value);

  // This read is NOT tracked - effect won't re-run when 'other' changes
  const otherValue = untracked(() => other.value);
  console.log('Other:', otherValue);
});

count.value = 1; // Effect re-runs
other.value = 'world'; // Effect does NOT re-run

Using Signals with hyper-element

Signals integrate naturally with hyper-element's setup/render lifecycle:

import hyperElement, { signal, effect } from 'hyper-element';

hyperElement('counter-app', {
  setup: (ctx, onNext) => {
    const count = signal(0);

    // Trigger re-render when count changes
    const stopEffect = effect(() => {
      onNext(() => ({ count: count.value }))();
    });

    // Expose increment method
    ctx.increment = () => count.value++;

    // Cleanup effect on disconnect
    return stopEffect;
  },

  handleClick: (ctx) => ctx.increment(),

  render: (Html, ctx, store) => Html`
    <button onclick=${ctx.handleClick}>
      Count: ${store?.count ?? 0}
    </button>
  `,
});

Server-Side Rendering (SSR)

hyper-element supports server-side rendering for faster initial page loads and SEO. The SSR system has two parts:

  1. Server-side API - Render components to HTML strings in Node.js/Deno/Bun
  2. Client-side hydration - Capture user interactions during page load and replay them after components register

Server-Side API

Import SSR functions from the dedicated server entry point:

// Node.js / Bun / Deno
import {
  renderElement,
  renderElements,
  createRenderer,
  ssrHtml,
  escapeHtml,
  safeHtml,
} from 'hyper-element/ssr/server';

renderElement

Render a single component to an HTML string:

const html = await renderElement('user-card', {
  attrs: { name: 'Alice', role: 'Admin' },
  store: { lastLogin: '2024-01-15' },
  render: (Html, ctx) => Html`
    <div class="card">
      <h2>${ctx.attrs.name}</h2>
      <span>${ctx.attrs.role}</span>
      <small>Last login: ${ctx.store.lastLogin}</small>
    </div>
  `,
});

// Result: <user-card name="Alice" role="Admin"><div class="card">...</div></user-card>

Options:

| Option | Type | Description | | ----------- | ---------- | ----------------------------------------------------- | | attrs | object | Attributes to pass to the component | | store | object | Store data available in render | | render | function | Required render function (Html, ctx) => Html\...`| |shadowDOM|boolean | Wrap output in Declarative Shadow DOM template | |fragments|object` | Fragment functions for async content |

createRenderer

Create a reusable renderer for a component:

const renderUserCard = createRenderer(
  'user-card',
  (Html, ctx) => Html`
    <div class="card">
      <h2>${ctx.attrs.name}</h2>
    </div>
  `,
  { shadowDOM: false } // default options
);

// Use it multiple times
const html1 = await renderUserCard({ name: 'Alice' });
const html2 = await renderUserCard({ name: 'Bob' });

renderElements

Render multiple components in parallel:

const results = await renderElements([
  { tagName: 'user-card', attrs: { name: 'Alice' }, render: renderFn },
  { tagName: 'user-card', attrs: { name: 'Bob' }, render: renderFn },
]);
// Returns array of HTML strings

ssrHtml

Tagged template literal for rendering HTML strings directly. SVG content is auto-detected when using <svg> tags:

const header = ssrHtml`<header><h1>${title}</h1></header>`;
const icon = ssrHtml`<svg viewBox="0 0 24 24"><path d="${pathData}"/></svg>`;

escapeHtml / safeHtml

Utility functions for HTML escaping:

// Escape user input
const safe = escapeHtml('<script>alert("xss")</script>');
// Result: &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;

// Mark trusted HTML as safe (bypasses escaping)
const trusted = safeHtml('<strong>Bold</strong>');

Fragments in SSR

Fragments work on the server too for async content:

const html = await renderElement('user-profile', {
  attrs: { userId: '123' },
  fragments: {
    FriendCount: async (userId) => {
      const count = await fetchFriendCount(userId);
      return { text: `${count} friends` };
    },
  },
  render: (Html, ctx) => Html`
    <div>
      <h1>Profile</h1>
      <p>${{ FriendCount: ctx.attrs.userId }}</p>
    </div>
  `,
});

Client-Side Hydration

When SSR HTML arrives in the browser, users can interact with elements before JavaScript loads and components register. hyper-element captures these interactions and replays them after hydration.

How It Works

1. CAPTURE - hyper-element loads in <head>, starts listening for events
2. BUFFER  - User interacts with SSR markup, events are stored
3. REPLAY  - After customElements.define() + first render, events replay

configureSSR

Configure which events to capture (call before components register):

import { configureSSR } from 'hyper-element';

configureSSR({
  events: ['click', 'input', 'change', 'submit'], // Events to capture
  devMode: true, // Show visual indicator during capture (dev only)
});

Default captured events: click, dblclick, input, change, submit, keydown, keyup, keypress, focus, blur, focusin, focusout, touchstart, touchend, touchmove, touchcancel

Lifecycle Hooks

Components can hook into the hydration process:

customElements.define(
  'my-component',
  class extends hyperElement {
    // Called before events are replayed
    // Return filtered/modified events array
    onBeforeHydrate(bufferedEvents) {
      console.log('Events captured:', bufferedEvents.length);
      // Filter out old events
      return bufferedEvents.filter((e) => Date.now() - e.timestamp < 5000);
    }

    // Called after all events have been replayed
    onAfterHydrate() {
      console.log('Hydration complete!');
    }

    render(Html) {
      Html`<button>Click me</button>`;
    }
  }
);

BufferedEvent Structure

Each captured event contains:

interface BufferedEvent {
  type: string; // 'click', 'input', etc.
  timestamp: number; // When event occurred
  targetPath: string; // DOM path like 'DIV:0/BUTTON:1'
  detail: object; // Event-specific properties
}

State Preservation

The hydration system automatically preserves:

  • Form values - Input, textarea, select values via input events
  • Checkbox/radio state - Checked state captured and restored
  • Scroll position - Scroll positions within components

SSR Configuration

Full Configuration Reference

import { configureSSR } from 'hyper-element';

configureSSR({
  // Events to capture during SSR hydration
  events: [
    'click',
    'dblclick',
    'input',
    'change',
    'submit',
    'keydown',
    'keyup',
    'keypress',
    'focus',
    'blur',
    'focusin',
    'focusout',
    'touchstart',
    'touchend',
    'touchmove',
    'touchcancel',
  ],

  // Show orange "SSR Capture Active" badge (development only)
  devMode: false,
});

Complete SSR Example

Server (Node.js):

import { renderElement } from 'hyper-element/ssr/server';

const html = await renderElement('todo-list', {
  attrs: { title: 'My Tasks' },
  store: {
    items: [
      { id: 1, text: 'Learn SSR', done: false },
      { id: 2, text: 'Build app', done: false },
    ],
  },
  render: (Html, ctx) => Html`
    <h1>${ctx.attrs.title}</h1>
    <ul>
      {+each ${ctx.store.items}}
        <li data-id="{id}">{text}</li>
      {-each}
    </ul>
  `,
});

// Serve full HTML page
res.send(`
<!DOCTYPE html>
<html>
<head>
  <script src="/hyper-element.min.js"></script>
</head>
<body>
  ${html}
  <script src="/app.js"></script>
</body>
</html>
`);

Client (app.js):

import hyperElement, { configureSSR } from 'hyper-element';

// Optional: configure before components register
configureSSR({ devMode: true });

// Register the component - hydration happens automatically
hyperElement('todo-list', {
  onBeforeHydrate(events) {
    console.log('Replaying', events.length, 'events');
    return events;
  },

  onAfterHydrate() {
    console.log('Todo list hydrated!');
  },

  render: (Html, ctx, store) => Html`
    <h1>${ctx.attrs.title}</h1>
    <ul>
      {+each ${store.items}}
        <li data-id="{id}">{text}</li>
      {-each}
    </ul>
  `,
});

Best Practices

Always Use Html.wire for Lists

When rendering lists, always use Html.wire() to ensure proper DOM reuse and prevent XSS vulnerabilities:

// GOOD - Safe and efficient
Html`<ul>${users.map((u) => Html.wire(u, ':item')`<li>${u.name}</li>`)}</ul>`;

// BAD - XSS vulnerability and poor performance
Html`<ul>${users.map((u) => `<li>${u.name}</li>`)}</ul>`;

Dataset Updates Require Assignment

The dataset works by reference. To update an attribute you must use assignment:

// BAD - mutation doesn't trigger attribute update
this.dataset.user.name = '';

// GOOD - assignment triggers attribute update
this.dataset.user = { name: '' };

Type Coercion Reference

| Source | Supported Types | | -------------- | ------------------------------ | | this.attrs | Number | | this.dataset | Object, Array, Number, Boolean |

Cleanup Resources in setup()

Always return a cleanup function when using resources that need disposal:

setup(attachStore) {
  const interval = setInterval(attachStore(), 1000);
  return () => clearInterval(interval); // Cleanup on removal
}

Development

Prerequisites

  • Node.js 20 or higher
  • npm (comes with Node.js)

Setup

  1. Clone the repository:

    git clone https://github.com/codemeasandwich/hyper-element.git
    cd hyper-element
  2. Install dependencies:

    npm install

    This also installs the pre-commit hooks automatically via the prepare script.

Available Scripts

| Command | Description | | --------------------- | ------------------------------------------------- | | npm run build | Build minified production bundle with source maps | | npm test | Run Playwright tests with coverage | | npm run test:ui | Run tests with Playwright UI for debugging | | npm run test:headed | Run tests in headed browser mode | | npm run kitchensink | Start local dev server for examples | | npm run lint | Run ESLint to check for code issues | | npm run format | Check Prettier formatting | | npm run format:fix | Auto-fix Prettier formatting issues | | npm run release | Run the release script (maintainers only) |

Project Structure

hyper-element/
├── src/                     # Source files (ES modules)
│   ├── attributes/          # Attribute handling
│   ├── core/                # Core utilities
│   ├── html/                # HTML tag functions
│   ├── lifecycle/           # Lifecycle hooks
│   ├── render/              # Custom render core (uhtml-inspired)
│   ├── signals/             # Reactive primitives (signal, computed, effect)
│   ├── template/            # Template processing
│   ├── utils/               # Shared utilities
│   └── index.js             # Main export
├── build/
│   ├── hyperElement.min.js  # Minified production build
│   └── hyperElement.min.js.map
├── kitchensink/             # Test suite
│   ├── kitchensink.spec.js  # Playwright test runner
│   └── *.html               # Test case files
├── example/                 # Example project
├── docs/                    # Documentation
├── .hooks/                  # Git hooks
│   ├── pre-commit           # Main hook orchestrator
│   ├── commit-msg           # Commit message validator
│   └── pre-commit.d/        # Modular validation scripts
└── scripts/
    └── publish.sh           # Release script

Building

The build process uses esbuild for fast, minimal output:

npm run build

This produces:

  • build/hyperElement.min.js - Minified bundle (~6.2 KB)
  • build/hyperElement.min.js.map - Source map for debugging

Pre-commit Hooks

The project uses a modular pre-commit hook system located in .hooks/. When you commit, the following checks run automatically:

  1. ESLint - Code quality checks
  2. Prettier - Code formatting
  3. Build - Ensures the build succeeds
  4. Coverage - Enforces 100% test coverage
  5. JSDoc - Documentation validation
  6. Docs - Documentation completeness

If any check fails, the commit is blocked until the issue is fixed.

Installing Hooks Manually

If hooks weren't installed automatically:

npm run hooks:install

Code Style

  • Prettier for formatting (2-space indent, single quotes, trailing commas)
  • ESLint for code quality
  • All files are automatically checked on commit

Run formatting manually:

npm run format:fix

Testing

hyper-element uses a two-phase test workflow to ensure both source quality and build integrity:

Phase 1: Source Coverage

npm run test:src
  • Loads src/ directly via ES modules + import maps
  • Collects V8 coverage on source files
  • Generates HTML report at coverage/index.html
  • Runs SSR tests with coverage
  • Requires 100% coverage on all metrics

Phase 2: Bundle Verification

npm run test:bundle
  • Loads built build/hyperElement.min.js
  • Verifies nothing broke during bundling
  • No coverage collected (just verification)

Full Test Suite

npm test

Runs both phases sequentially: source coverage first, then bundle verification.

Viewing Coverage Report

After running tests, open the HTML coverage report:

open coverage/index.html

This shows:

  • File-by-file coverage breakdown
  • Line-by-line highlighting of covered/uncovered code
  • Statement, branch, and function metrics

Test Files

Tests are located in kitchensink/ and run via Playwright. See kitchensink/kitchensink.spec.js for the test suite.

Contributing

See CONTRIBUTING.md for contribution guidelines.