Skip to content

Repository files navigation

hyper-element

npm versionnpm package sizeCILicense: MITCoverageXSS ProtectedES6+

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

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
  • Built-in json-render module: render LLM tool-call output as live UI with 12 built-in components and an auto-generated catalog for LLM schema/prompt generation
  • Optional Hyper Layout module: GridStack-style direct-child dashboard placement with controlled items/positions state
  • Pass function to other custom hyper-elements via there tag attribute

Setup

Install

# npm
npm install hyper-element
# yarn
yarn add hyper-element
# pnpm
pnpm add hyper-element
# bun
bun add hyper-element

ES6 Modules

importhyperElementfrom'hyper-element';

CommonJS

consthyperElement=require('hyper-element');

use

hyperElement('my-elem',(Html)=>Html`Hello You!`);

CDN (Browser)

For browser environments without a bundler:

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

The hyperElement will be available globally on window.hyperElement.

<script>
window.hyperElement('my-elem', Html =>Html`Hello You!`);</script>

Live Examples

ExampleDescriptionLink
Hello WorldBasic element creationCodePen
Attach a StoreStore integration with setup()CodePen
TemplatesUsing the template systemCodePen
Child Element EventsPassing functions to child elementsCodePen
Async FragmentsLoading content asynchronouslyCodePen
StylingReact-style inline stylesCodePen
Full DemoComplete feature demonstrationJSFiddle

Browser Support

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

Chrome 86+Firefox 78+Safari 14.1+Edge 86+

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



Define a custom-element

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

Output

<my-elemwho="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)constMyElement=hyperElement({render: (Html,ctx)=>Html`...`,});customElements.define('my-element',MyElement);// 4. Shorthand without tag (returns class for manual registration)constSimple=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:

PropertyDescription
ctx.elementThe DOM element
ctx.attrsParsed attributes with automatic type coercion
ctx.datasetDataset proxy with automatic type coercion
ctx.storeStore value from setup
ctx.wrappedContentText content between the tags

Example: Counter with Setup

hyperElement('my-counter',{setup: (ctx,onNext)=>{conststore={count: 0};constrender=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)=>{letseconds=0;constrender=onNext(()=>({ seconds }));constinterval=setInterval(()=>{seconds++;render();},1000);// Return cleanup functionreturn()=>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:

classMyElementextendshyperElement{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 ${newDate().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 ${newDate().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:

SyntaxDescription
{+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}:

SyntaxDescription
{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:

TypeOutput
Primitive (string, number, boolean)toString() and HTML escaped
Array.join(",")
ObjectJSON.stringify()
FunctionCalled with no args, return value follows these rules

Examples:

// Multiple propertiesHtml`<ul>{+each ${users}}<li>{name} ({age})</li>{-each}</ul>`;// Using indexHtml`<ol>{+each ${items}}<li>{@}: {title}</li>{-each}</ol>`;// Nested arrays with {+each {property}}constcategories=[{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 elseHtml`{+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 elseHtml`{+unless ${isValid}}Invalid input!{else}Looking good!{-unless}`;

Using Attributes to Drive Conditionals

Component attributes can directly control what gets rendered:

// Notification banner with attribute-driven renderinghyperElement('notification-banner',{setup: (ctx)=>{ctx.dismiss=()=>ctx.element.remove();},render: (Html,ctx)=>Html` <div class="banner ${ctx.attrs.type}"> {+if ${ctx.attrs.type==='error'}} <span class="icon">!</span> {-if} {+if ${ctx.attrs.type==='success'}} <span class="icon">OK</span> {-if} {+if ${ctx.attrs.type==='warning'}} <span class="icon">?</span> {-if} <p>${ctx.attrs.message}</p> {+if ${ctx.attrs.dismissible}} <button onclick=${ctx.dismiss}>Close</button> {-if} </div> `,});// Usage:// <notification-banner type="error" message="Something went wrong!" dismissible />// <notification-banner type="success" message="Saved successfully!" />

Html.lite

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

Example: Wrapping jQuery DatePicker

customElements.define('date-picker',classextendshyperElement{onSelect(dateText,inst){console.log('selected time '+dateText);}Date(lite){constinputElem=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){consttrustedHtml='<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 renderconstonStoreChange=attachStore(getMouseValues);// call onStoreChange on every mouse eventonMouseMove(onStoreChange);// cleanup logicreturn()=>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){letnewSocketValue;constonStoreChange=attachStore(()=>newSocketValue);constws=newWebSocket('ws://127.0.0.1/data');ws.onmessage=({ data })=>{newSocketValue=JSON.parse(data);onStoreChange();};// Return cleanup functionreturnws.close.bind(ws);}

Multiple Subscriptions

You can trigger re-renders from multiple sources:

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

this / ctx

Available properties and methods on this (class-based) or ctx (functional API):

PropertyDescription
this.attrsAttributes on the tag. <my-elem min="0" max="10" /> = { min:0, max:10 }
this.storeValue returned from the store function. Only updated before each render
this.wrappedContentText content between your tags. <my-elem>Hi!</my-elem> = "Hi!"
this.elementReference to your created DOM element
this.datasetRead/write access to all data-* attributes
this.innerShadowGet the innerHTML of the element's rendered content

this.attrs

Attributes are automatically type-coerced:

InputOutputType
"42"42Number
"3.14"3.14Number
"hello""hello"String

this.dataset

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

Attribute Valuethis.dataset ValueType
data-count="42"42Number
data-active="true"trueBoolean
data-active="false"falseBoolean
data-users='["a","b"]'["a", "b"]Array
data-config='{"x":1}'{ x: 1 }Object

Example:

<my-elemdata-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.

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',classextendshyperElement{render(Html){constonClick=()=>this.attrs.hi('Hello from '+this.attrs.name);Html`${this.attrs.name} <button onclick=${onClick}>Say hi!</button>`;}});window.customElements.define('users-elem',classextendshyperElement{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


Form Validation & Custom Events

Build reusable form components with validation logic and custom event dispatching. Use ctx.element.dispatchEvent() to communicate validation state to parent components.

Validated Input Component

hyperElement('validated-input',{setup: (ctx,onNext)=>{conststate={value: '',error: null,touched: false};constrender=onNext(()=>state);ctx.validate=(value)=>{construles=ctx.attrs.rules||{};if(rules.required&&!value)return'Required';if(rules.minLength&&value.length<rules.minLength)return`Min ${rules.minLength} characters`;if(rules.pattern&&!newRegExp(rules.pattern).test(value))return'Invalid format';returnnull;};ctx.handleInput=(e)=>{state.value=e.target.value;state.touched=true;state.error=ctx.validate(state.value);render();// Dispatch custom event to parentctx.element.dispatchEvent(newCustomEvent('validated',{bubbles: true,detail: {value: state.value,error: state.error,valid: !state.error,},}));};},render: (Html,ctx,state)=>Html` <div class="form-field"> <input type=${ctx.attrs.type||'text'} value=${state.value} oninput=${ctx.handleInput} placeholder=${ctx.attrs.placeholder||''} /> {+if ${state.error&&state.touched}} <span class="error">${state.error}</span> {-if} </div> `,});

Parent Listening for Custom Events

hyperElement('signup-form',{setup: (ctx,onNext)=>{conststate={email: {valid: false},password: {valid: false}};constrender=onNext(()=>state);ctx.handleValidated=(e)=>{const{ name }=e.target.closest('validated-input').attributes;state[name.value]=e.detail;render();};ctx.handleSubmit=(e)=>{e.preventDefault();if(state.email.valid&&state.password.valid){console.log('Form submitted!',state);}};},render: (Html,ctx,state)=>Html` <form onsubmit=${ctx.handleSubmit}> <validated-input name="email" type="email" placeholder="Email" rules=${{required: true,pattern: '^[^@]+@[^@]+$'}} onvalidated=${ctx.handleValidated} /> <validated-input name="password" type="password" placeholder="Password" rules=${{required: true,minLength: 8}} onvalidated=${ctx.handleValidated} /> <button type="submit" disabled=${!state.email.valid||!state.password.valid}> Sign Up </button> </form> `,});

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-listtemplatedata-json='[{"name":"ann","url":""},{"name":"bob","url":""}]'><div><ahref="{url}">{name}</a></div></my-list>
customElements.define('my-list',classextendshyperElement{render(Html){Html`${this.dataset.json.map((user)=>Html.template(user))}`;}});

Output:

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

Live Example of using templates


Basic Template Syntax

SyntaxDescription
{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-elemtemplate>{+if active}Online{else}Offline{-if}</status-elem>
customElements.define('status-elem',classextendshyperElement{render(Html){Html`${Html.template({active: true})}`;}});

Output: Online


Negation: {+unless}

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

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

Output: Invalid input!


Iteration: {+each}

Loop over arrays:

<list-elemtemplate><ul>
{+each items}
<li>{name}</li>
{-each}
</ul></list-elem>
customElements.define('list-elem',classextendshyperElement{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-elemtemplate>{+each numbers}{@}: {number}, {-each}</nums-elem>
customElements.define('nums-elem',classextendshyperElement{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',classextendshyperElement{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){constuserId=this.attrs.myId;Html`<h2> ${{FriendCount: userId}} </h2>`;}});

Live Example of using an asynchronous fragment

Data Fetching Pattern

For components that need to fetch data on mount, use setup() with async operations:

hyperElement('user-profile',{setup: (ctx,onNext)=>{conststate={user: null,loading: true,error: null};constrender=onNext(()=>state);// Fetch data when component mountsfetch(`/api/users/${ctx.attrs.userId}`).then((res)=>{if(!res.ok)thrownewError(`HTTP ${res.status}`);returnres.json();}).then((user)=>{state.user=user;state.loading=false;render();}).catch((err)=>{state.error=err.message;state.loading=false;render();});},render: (Html,ctx,state)=>Html` {+if ${state.loading}} <div class="spinner">Loading...</div> {-if} {+if ${state.error}} <div class="error">Error: ${state.error}</div> {-if} {+if ${state.user}} <div class="profile"> <img src=${state.user.avatar} alt=${state.user.name} /> <h2>${state.user.name}</h2> <p>${state.user.email}</p> <p>Member since: ${newDate(state.user.createdAt).toLocaleDateString()}</p> </div> {-if} `,});// Usage: <user-profile user-id="123"></user-profile>

For components that need to refetch when attributes change, trigger the fetch in both setup() and watch for attribute updates:

hyperElement('user-profile-reactive',{setup: (ctx,onNext)=>{conststate={user: null,loading: true,error: null};constrender=onNext(()=>state);// Fetch function that can be called multiple timesctx.fetchUser=async(userId)=>{state.loading=true;state.error=null;render();try{constres=awaitfetch(`/api/users/${userId}`);if(!res.ok)thrownewError(`HTTP ${res.status}`);state.user=awaitres.json();}catch(err){state.error=err.message;}state.loading=false;render();};// Initial fetchctx.fetchUser(ctx.attrs.userId);},// This render is called whenever attrs changerender: (Html,ctx,state)=>{// Check if userId changed and refetchif(ctx.attrs.userId&&state.user?.id!==ctx.attrs.userId){ctx.fetchUser(ctx.attrs.userId);}returnHtml` {+if ${state.loading}}<div>Loading...</div>{-if} {+if ${state.error}}<div>Error: ${state.error}</div>{-if} {+if ${state.user&&!state.loading}} <div class="profile"> <h2>${state.user.name}</h2> </div> {-if} `;},});

Styling

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

Example: of centering an element

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

Live Example of styling

Selector Styles with +styled

+styled can consume style objects with selector keys. Inline-only objects stay inline; selector-capable objects receive generated scoped classes and one renderer-owned style host at the component root so selectors such as :hover, :focus, @media, and nested child selectors can participate in normal CSS cascade.

importhyperElement,{defineStyled}from'hyper-element';constcardStyles=defineStyled({article: {base: {padding: '12px',color: 'black'},error: {color: 'red'},':hover': {color: 'blue'},' .title': {fontWeight: 'bold'},},});hyperElement('demo-card',{styled: cardStyles,render: (Html,ctx)=>Html` <article+styled error=${ctx.attrs.error} css=${{':hover': {color: ctx.attrs.hoverColor||'green'}}} > <h2 class="title">Title</h2> </article> `,});

Use cardStyles.article({ error: true }) for full selector-capable data on +styled elements, and cardStyles.article.inline({ error: true }) when passing styles to a normal native style=${...} attribute.

List with State-Based Styling

Combine list rendering with dynamic inline styles driven by component state:

hyperElement('task-list',{setup: (ctx,onNext)=>{conststate={filter: 'all',tasks: ctx.dataset.tasks||[{id: 1,text: 'Learn hyper-element',done: true},{id: 2,text: 'Build an app',done: false},{id: 3,text: 'Deploy to production',done: false},],};constrender=onNext(()=>state);ctx.setFilter=(filter)=>{state.filter=filter;render();};ctx.toggleTask=(id)=>{consttask=state.tasks.find((t)=>t.id===id);if(task)task.done=!task.done;render();};},render: (Html,ctx,{ filter, tasks })=>{constfiltered=tasks.filter((t)=>filter==='all' ? true : filter==='done' ? t.done : !t.done);returnHtml` <div> <div class="filters">${['all','active','done'].map((f)=>Html.wire({ f },':filter')` <button onclick=${()=>ctx.setFilter(f)} style=${{backgroundColor: filter===f ? '#007bff' : '#f8f9fa',color: filter===f ? '#fff' : '#333',fontWeight: filter===f ? 'bold' : 'normal',border: '1px solid #ddd',padding: '8px 16px',cursor: 'pointer',}} >${f}</button> `)} </div> <ul style=${{listStyle: 'none',padding: 0}}>${filtered.map((task)=>Html.wire(task,':task')` <li onclick=${()=>ctx.toggleTask(task.id)} style=${{textDecoration: task.done ? 'line-through' : 'none',opacity: task.done ? 0.6 : 1,padding: '12px',borderBottom: '1px solid #eee',cursor: 'pointer',}} >${task.text}</li> `)} </ul> </div> `;},});

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

varuser=new(Backbone.Model.extend({defaults: {name: 'Guest User',},}))();customElements.define('my-profile',classextendshyperElement{setup(attachStore){user.on('change',attachStore(user.toJSON.bind(user)));// OR user.on("change", attachStore(() => user.toJSON()));}render(Html,{ name }){Html`Profile: ${name}`;}});

MobX

constuser=observable({name: 'Guest User',});customElements.define('my-profile',classextendshyperElement{setup(attachStore){mobx.autorun(attachStore(user));}render(Html,{ name }){Html`Profile: ${name}`;}});

Redux

customElements.define('my-profile',classextendshyperElement{setup(attachStore){store.subscribe(attachStore(store.getState));}render(Html,{ user }){Html`Profile: ${user.name}`;}});

Dual Reactivity: Attributes + Store

Components automatically re-render when either attributes change or the store updates. This lets you build components that respond to both parent-driven props and global state:

// Global storeconstappStore={users: {},listeners: newSet(),subscribe(fn){this.listeners.add(fn);},unsubscribe(fn){this.listeners.delete(fn);},notify(){this.listeners.forEach((fn)=>fn());},setUserStatus(userId,status){this.users[userId]={ ...this.users[userId], status };this.notify();},};// Component reacts to BOTH attribute changes AND store updateshyperElement('user-status',{setup: (ctx,onNext)=>{constrender=onNext(()=>appStore.users);// Subscribe to store changesappStore.subscribe(render);// Component ALSO re-renders automatically when attrs change// e.g., <user-status user-id="123" /> → <user-status user-id="456" />return()=>appStore.unsubscribe(render);},render: (Html,ctx,users)=>Html` <div class="user-status"> <!-- Reacts to attribute changes --> <h3>User: ${ctx.attrs.userId}</h3> <!-- Reacts to store updates --> <span class="status ${users[ctx.attrs.userId]?.status||'offline'}">${users[ctx.attrs.userId]?.status||'offline'} </span> </div> `,});// Usage: Component updates when either happens:// 1. Attribute change: element.setAttribute('user-id', '456')// 2. Store update: appStore.setUserStatus('123', 'online')

Persisting State to Stores

Write component actions back to external stores (Redux pattern):

// Redux-style storeconsttodoStore={state: {todos: []},listeners: newSet(),subscribe(fn){this.listeners.add(fn);},dispatch(action){switch(action.type){case'ADD_TODO':
this.state.todos.push({id: Date.now(),text: action.text,done: false,});break;case'TOGGLE_TODO':
consttodo=this.state.todos.find((t)=>t.id===action.id);if(todo)todo.done=!todo.done;break;case'REMOVE_TODO':
this.state.todos=this.state.todos.filter((t)=>t.id!==action.id);break;}this.listeners.forEach((fn)=>fn());},getState(){returnthis.state;},};// Component that reads FROM and writes TO the storehyperElement('todo-item',{setup: (ctx,onNext)=>{constrender=onNext(()=>todoStore.getState());todoStore.subscribe(render);// Actions dispatch to store (persist state changes)ctx.toggle=()=>{todoStore.dispatch({type: 'TOGGLE_TODO',id: ctx.attrs.todoId});};ctx.remove=()=>{todoStore.dispatch({type: 'REMOVE_TODO',id: ctx.attrs.todoId});};},render: (Html,ctx,{ todos })=>{consttodo=todos.find((t)=>t.id===ctx.attrs.todoId);if(!todo)returnHtml``;returnHtml` <li style=${{textDecoration: todo.done ? 'line-through' : 'none'}}> <input type="checkbox" checked=${todo.done} onclick=${ctx.toggle} /> <span>${todo.text}</span> <button onclick=${ctx.remove}>Delete</button> </li> `;},});

Multi-Component State Sharing

Multiple components can share the same store, each rendering their own view of the data:

// Shared store - single source of truthconstcartStore={state: {items: [],user: null},listeners: newSet(),subscribe(fn){this.listeners.add(fn);},unsubscribe(fn){this.listeners.delete(fn);},notify(){this.listeners.forEach((fn)=>fn());},// ActionsaddItem(item){this.state.items.push(item);this.notify();},removeItem(id){this.state.items=this.state.items.filter((i)=>i.id!==id);this.notify();},getState(){returnthis.state;},};// Component 1: Cart badge in header (shows count)hyperElement('cart-badge',{setup: (ctx,onNext)=>{constrender=onNext(()=>cartStore.getState());cartStore.subscribe(render);return()=>cartStore.unsubscribe(render);},render: (Html,ctx,{ items })=>Html` <span class="badge">${items.length}</span> `,});// Component 2: Cart contents (shows full list)hyperElement('cart-list',{setup: (ctx,onNext)=>{constrender=onNext(()=>cartStore.getState());cartStore.subscribe(render);ctx.remove=(id)=>cartStore.removeItem(id);return()=>cartStore.unsubscribe(render);},render: (Html,ctx,{ items })=>Html` <ul>${items.map((item)=>Html.wire(item,':item')` <li>${item.name} - $${item.price} <button onclick=${()=>ctx.remove(item.id)}>Remove</button> </li> `)} </ul> <p>Total: $${items.reduce((sum,i)=>sum+i.price,0)}</p> `,});// Component 3: Product card (adds to shared cart)hyperElement('product-card',{setup: (ctx)=>{ctx.addToCart=()=>cartStore.addItem({id: Date.now(),name: ctx.attrs.name,price: Number(ctx.attrs.price),});},render: (Html,ctx)=>Html` <div class="product"> <h3>${ctx.attrs.name}</h3> <p>$${ctx.attrs.price}</p> <button onclick=${ctx.addToCart}>Add to Cart</button> </div> `,});// All three components stay in sync:// <cart-badge></cart-badge> <!-- Shows "2" -->// <cart-list></cart-list> <!-- Shows items -->// <product-card name="Widget" price="9.99"></product-card>

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.

constcount=signal(0);// Read value (tracks dependencies in effects/computed)console.log(count.value);// 0// Write value (notifies subscribers)count.value=1;// Read without trackingcount.peek();// 1// Subscribe to changesconstunsubscribe=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.

constcount=signal(0);constdoubled=computed(()=>count.value*2);console.log(doubled.value);// 0count.value=5;console.log(doubled.value);// 10// Read without trackingdoubled.peek();// 10

effect

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

constcount=signal(0);// Effect runs immediately, then on every changeconstcleanup=effect(()=>{console.log('Count is:',count.value);// Optional cleanup functionreturn()=>{console.log('Cleaning up previous effect');};});count.value=1;// Logs: "Cleaning up previous effect"// Logs: "Count is: 1"// Stop the effectcleanup();

batch

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

constfirstName=signal('John');constlastName=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 updatesbatch(()=>{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.

constcount=signal(0);constother=signal('hello');effect(()=>{// This dependency IS trackedconsole.log('Count:',count.value);// This read is NOT tracked - effect won't re-run when 'other' changesconstotherValue=untracked(()=>other.value);console.log('Other:',otherValue);});count.value=1;// Effect re-runsother.value='world';// Effect does NOT re-run

Using Signals with hyper-element

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

importhyperElement,{signal,effect}from'hyper-element';hyperElement('counter-app',{setup: (ctx,onNext)=>{constcount=signal(0);// Trigger re-render when count changesconststopEffect=effect(()=>{onNext(()=>({count: count.value}))();});// Expose increment methodctx.increment=()=>count.value++;// Cleanup effect on disconnectreturnstopEffect;},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 / Denoimport{renderElement,renderElements,createRenderer,ssrHtml,escapeHtml,safeHtml,}from'hyper-element/ssr/server';

renderElement

Render a single component to an HTML string:

consthtml=awaitrenderElement('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:

OptionTypeDescription
attrsobjectAttributes to pass to the component
storeobjectStore data available in render
renderfunctionRequired render function (Html, ctx) => Html\...``
shadowDOMbooleanWrap output in Declarative Shadow DOM template
fragmentsobjectFragment functions for async content

createRenderer

Create a reusable renderer for a component:

constrenderUserCard=createRenderer('user-card',(Html,ctx)=>Html` <div class="card"> <h2>${ctx.attrs.name}</h2> </div> `,{shadowDOM: false}// default options);// Use it multiple timesconsthtml1=awaitrenderUserCard({name: 'Alice'});consthtml2=awaitrenderUserCard({name: 'Bob'});

renderElements

Render multiple components in parallel:

constresults=awaitrenderElements([{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:

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

escapeHtml / safeHtml

Utility functions for HTML escaping:

// Escape user inputconstsafe=escapeHtml('<script>alert("xss")</script>');// Result: &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;// Mark trusted HTML as safe (bypasses escaping)consttrusted=safeHtml('<strong>Bold</strong>');

Fragments in SSR

Fragments work on the server too for async content:

consthtml=awaitrenderElement('user-profile',{attrs: {userId: '123'},fragments: {FriendCount: async(userId)=>{constcount=awaitfetchFriendCount(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 capturedevMode: true,// Show visual indicator during capture (dev only)styleNonce: 'request-nonce',// Optional CSP nonce for generated +styled style hosts});

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',classextendshyperElement{// Called before events are replayed// Return filtered/modified events arrayonBeforeHydrate(bufferedEvents){console.log('Events captured:',bufferedEvents.length);// Filter out old eventsreturnbufferedEvents.filter((e)=>Date.now()-e.timestamp<5000);}// Called after all events have been replayedonAfterHydrate(){console.log('Hydration complete!');}render(Html){Html`<button>Click me</button>`;}});

BufferedEvent Structure

Each captured event contains:

interfaceBufferedEvent{type: string;// 'click', 'input', etc.timestamp: number;// When event occurredtargetPath: 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 hydrationevents: ['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,// Optional CSP nonce for browser-created +styled style hostsstyleNonce: 'request-nonce',});

Server rendering can also put the same nonce on selector-capable +styled style hosts for strict CSP deployments:

consthtml=awaitrenderElement('secure-card',{styleNonce: 'request-nonce',styled: [{article: {color: 'black',':hover': {color: 'blue'}}}],render: (Html)=>Html`<article+styled>Secure</article>`,});

Complete SSR Example

Server (Node.js):

import{renderElement}from'hyper-element/ssr/server';consthtml=awaitrenderElement('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 pageres.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):

importhyperElement,{configureSSR}from'hyper-element';// Optional: configure before components registerconfigureSSR({devMode: true});// Register the component - hydration happens automaticallyhyperElement('todo-list',{onBeforeHydrate(events){console.log('Replaying',events.length,'events');returnevents;},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> `,});

json-render (Spec-Driven UI)

hyper-element ships with a json-render module that turns flat JSON specs — the kind an LLM produces from a json_render tool call — into live DOM trees. It is designed for agent-driven UIs: the model emits a structured spec, hyper-element renders it, interactive components bubble jr-action CustomEvents back up, and the agent reacts.

The <json-render> element wraps a JSON spec the same way <script> wraps JavaScript — the tag is the mount point, and the JSON inside is the payload. A plain ```json code fence never mounts UI; it is documentation only.

Import the module once and a <json-render> custom element becomes available in HTML. The JSON spec goes between the tags as body text — no attribute, no quote escaping.

<linkrel="stylesheet" href="hyper-element/src/json-render/json-render.css" /><scripttype="module">// Auto-registers <json-render> and exposes the APIimport'hyper-element/json-render';</script><json-render>
{"root":"msg","elements":{"msg":{"type":"Text","props":{"content":"Hello from
json-render!"}}}}
</json-render>

JSON structural characters ({}[]:,") are all HTML-safe, so the spec survives HTML parsing untouched. Only literal < / > inside string values need JSON unicode escapes (\u003c / \u003e). To update a spec at runtime, assign to textContent:

document.querySelector('json-render').textContent=JSON.stringify(nextSpec);

hyper-element's MutationObserver sees the childList change and re-renders automatically — no observedAttributes, no manual invalidation.

Spec format

A spec is a flat { root, elements } map, not a tree. Children are key references, so partial streaming specs (where a child key exists but its definition has not arrived yet) show a [loading...] placeholder instead of crashing.

{
"root": "card_0",
"elements": {
"card_0": {
"type": "Card",
"props": { "title": "Approval Required" },
"children": ["row_0"]
},
"row_0": {
"type": "Row",
"children": ["btn_ok", "btn_cancel"]
},
"btn_ok": {
"type": "Button",
"props": { "label": "Approve", "variant": "primary" },
"on": { "press": { "action": "approve", "params": { "id": "123" } } }
},
"btn_cancel": {
"type": "Button",
"props": { "label": "Cancel", "variant": "destructive" },
"on": { "press": { "action": "reject" } }
}
}
}

Built-in components

TypePropsEvents
Cardtitle, description
Rowgap
Columngap
Buttonlabel, variant, disabled, loadingon.pressjr-action
Textcontent, variant
Alertvariant, message
Progresslabel, value (0-100)
Divider
CodeBlocklanguage, code
Imagesrc, alt, width, height
Checklistlabel, itemscheckbox → jr-action
TextFieldlabel, placeholder, maxLengthon.submitjr-action

Programmatic API

import{renderSpec,registerComponent,validateSpec,getCatalog,listComponentTypes,}from'hyper-element';// Render a spec inside any hyper-element component. Pull the spec// from wherever is natural — a store, a tool-call response, etc.hyperElement('chat-message',(Html,ctx)=>{constspec=JSON.parse(ctx.wrappedContent);returnrenderSpec(Html,spec,ctx.element);});// Validate a spec before rendering. Returns every violation, not// just the first, so the LLM (or the developer) sees the full// diagnostic picture.const{ valid, errors }=validateSpec(spec);

Interactive events

Every interactive component dispatches a jr-action CustomEvent from the <json-render> host. The event bubbles and is composed so a single listener on the host (or any ancestor) sees all user actions:

document.querySelector('json-render').addEventListener('jr-action',(e)=>{console.log(e.detail.action);// "approve"console.log(e.detail.params);// { id: "123" }});

For a React-style spelling, every <json-render> also exposes an onaction IDL property. Assigning a function registers a single bubble-phase listener; reassigning atomically replaces it (never stacks); null removes it; any other value throws TypeError so typos surface loudly. The same setter backs the declarative attribute form inside hyper-element templates:

// Imperativedocument.querySelector('json-render').onaction=(e)=>{console.log(e.detail.action,e.detail.params);};// Declarative inside a hyper-element templateHtml`<json-render onaction=${(e)=>handle(e.detail)}>${JSON.stringify(spec)}</json-render>`;

The auto-busy data-jr-busy lock is independent from onaction: a capture-phase listener on the host stamps the busy attributes before any bubble-phase onaction handler runs, so the visual lock is in place by the time consumer code sees the event.

LLM schema generation (getCatalog)

Every built-in component is shipped with structured catalog metadata (description, typed props, slots, actions). getCatalog() walks the live registry and returns a frozen snapshot that exposes two LLM-facing formatters:

import{getCatalog}from'hyper-element';constcatalog=getCatalog();// Natural-language system prompt listing every cataloged component// with its props (type/required/enum/nullable), children capability,// and actions, followed by the `{ root, elements }` output-format block.constprompt=catalog.prompt({customRules: ['Use Card as the root element for any layout'],});// Claude/OpenAI-compatible JSON Schema tool definition. The `type.enum`// lists every cataloged component name, so the model can only emit// types the renderer knows how to handle.consttool=catalog.toolDefinition({name: 'json_render',description: 'Render interactive UI components',});

The snapshot is deep-cloned and recursively frozen, so the raw catalog data in the live registry can never be mutated through the API. A new snapshot is built on every call, so any components registered since the last call automatically appear.

Custom components via jrType

Tag any hyperElement(...) definition with jrType to auto-register the resulting custom element into json-render's registry. Whenever a spec references that type, json-render instantiates your custom element instead of a built-in (or instead of an [unknown: ...] placeholder for entirely new types). Adding jrCatalog alongside makes the component visible to getCatalog() for LLM prompt / tool generation.

hyperElement('product-card',{jrType: 'ProductCard',jrCatalog: {description: 'Product display with price and buy action',props: {name: {type: 'string',required: true},price: {type: 'number',required: true},image: {type: 'string'},},slots: [],actions: {press: {description: 'User taps buy',params: {productId: {type: 'string'}},},},},render: (Html,ctx)=>{// The bridge serialises def.props as JSON onto data-jr-props.// The dataset proxy auto-parses it back to a real object on read.const{ name, price, image }=ctx.dataset.jrProps||{};returnHtml` <article class="product-card"> <img src="${image}" alt="${name}" /> <h3>${name}</h3> <span>$${price}</span> </article>`;},});

Two overlap rules apply: registering a jrType that already matches a built-in (e.g. jrType: 'Card') replaces the built-in globally — every subsequent Card in any spec renders the bridged element, with a console.warn on registration. Registering the same custom jrType twice is last-write-wins, also with a console.warn.

Full documentation: src/json-render/README.md.


Hyper Layout (Dashboard Editing)

hyper-element/layout registers <hyper-layout>, an optional dashboard layout editor for direct child custom elements. It follows a parent-owned identity model: the parent renders children in order and passes an ordered items manifest whose opaque IDs map one-to-one to those direct children. Hyper Layout owns geometry only.

import'hyper-element/layout';

Start with a static layout when you only need Hyper Layout to own grid placement:

<hyper-layout><sales-card></sales-card><risk-chart></risk-chart></hyper-layout>

For persisted dashboards, pass items and positions from the parent. The ID is normally a database ID, UUID, or hash from application data; it does not need to resemble the rendered element tag.

<hyper-layoutedit=${isEditing}items=${[{id: 'database-id-1', tag: 'sales-card', can: ['drag'] },
{ id: 'database-id-2', tag: 'risk-chart', can: ['drag', 'resize'] },
]}
positions=${positions}onchange=${(event,nextPositions) => persist(nextPositions)}
onremoved=${(event, id, nextPositions) => {
removeWidgetRecord(id);
persist(nextPositions);
}}
removable="trash"
trash="#dashboard-trash"
><sales-card></sales-card><risk-chart></risk-chart></hyper-layout>

items[index] maps to directChildren[index]. If items is present, its length must match the direct child count. New IDs are auto-placed, removed IDs are dropped from live positions, and a valid reconciliation emits change so controlled parents can persist the converged layout state.

Removal follows the same parent-owned identity rule. When removable and trash or outside removal are configured, Hyper Layout removes the engine node and calls onremoved(event, id, positions, ids). The parent removes the matching record from its own data, renders one fewer child, and passes the next items and positions state back into the layout.

While a drag is active, trash removal is previewed before drop. If the cursor enters the configured trash target or the dragged item overlaps it, the default overlay turns red, swaps the move icon for a trash icon, reduces the item to 50% opacity, and scales it to 80%. Custom overlays can react to the same state with [data-hl-removing="true"]. The overlap test accounts for that 20% shrink so the preview does not flicker at the trash edge, and releasing while the trash preview is active removes the item.

edit defaults to false. In edit mode, Hyper Layout wraps each direct child in an editor shell with drag/stretch controls and a muted overlay. Descendants inside the child remain owned by that child. Nested editable areas require nested <hyper-layout> elements.

Use items[].can to allow drag, resize, both, or neither per item. Use overlay="custom-overlay-tag" to render a custom hover overlay; Hyper Layout passes capability attributes plus ctx.attrs.can, ctx.attrs.item, ctx.attrs.node, ctx.attrs.drag(event), and ctx.attrs.resize(event) to that overlay.

hyperElement('dashboard-overlay',{render: (Html,ctx)=>Html` <div onpointerdown=${ctx.attrs.drag}>${ctx.attrs.can.includes('drag') ? 'Move' : ''}${ctx.attrs.can.includes('resize') ? Html` <button type="button" onpointerdown=${ctx.attrs.resize}>Resize</button> ` : ''} </div> `,});

Full documentation: src/layout/README.md.


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 efficientHtml`<ul>${users.map((u)=>Html.wire(u,':item')`<li>${u.name}</li>`)}</ul>`;// BAD - XSS vulnerability and poor performanceHtml`<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 updatethis.dataset.user.name='';// GOOD - assignment triggers attribute updatethis.dataset.user={name: ''};

Type Coercion Reference

SourceSupported Types
this.attrsNumber
this.datasetObject, Array, Number, Boolean

Cleanup Resources in setup()

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

setup(attachStore){constinterval=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

CommandDescription
npm run buildBuild minified production bundle with source maps
npm testRun Playwright tests with coverage
npm run test:uiRun tests with Playwright UI for debugging
npm run test:headedRun tests in headed browser mode
npm run kitchensinkStart local dev server for examples
npm run lintRun ESLint to check for code issues
npm run formatCheck Prettier formatting
npm run format:fixAuto-fix Prettier formatting issues
npm run releaseRun 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
├── examples/ # Examples and test suite
│ ├── kitchensink/ # Interactive demos + E2E tests
│ │ ├── kitchensink.spec.js # Playwright test runner
│ │ └── *.html # Test case files
│ └── boilerplate/ # Starter template
├── 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
  • Opens ordinary kitchen sink examples through direct file:// URLs so local browser review uses the same bundle path developers open manually
  • 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 examples/kitchensink/ and run via Playwright. See examples/kitchensink/kitchensink.spec.js for the test suite.

Contributing

See CONTRIBUTING.md for contribution guidelines.


About

hyper-element is the fast, small, and feature-rich Custom Element framework

Topics

Resources

Contributing

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages