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
- 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/positionsstate - Pass
functionto other custom hyper-elements via there tag attribute
# npm
npm install hyper-element
# yarn
yarn add hyper-element
# pnpm
pnpm add hyper-element
# bun
bun add hyper-elementimport hyperElement from 'hyper-element';const hyperElement = require('hyper-element');hyperElement('my-elem', (Html) => Html`Hello You!`);For browser environments without a bundler:
<script src="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>| 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 |
hyper-element requires native ES6 class support and the Custom Elements v1 API:
For older browsers, a Custom Elements polyfill may be required.
- Browser Support
- Define a Custom Element
- Functional API
- Lifecycle
- Interface
- Advanced Attributes
- Templates
- Fragments
- Styling
- Connecting to a Data Store
- Signals
- Server-Side Rendering (SSR)
- Hyper Layout (Dashboard Editing)
- Best Practices
- Development
<!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>
hyperElement('my-elem', (Html, { attrs }) => Html`hello ${attrs.who}!`);
</script>
</body>
</html>Output
<my-elem who="world"> hello world </my-elem>Live Example of helloworld
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.
// 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);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 |
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>
`,
});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`,
});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);When a hyper-element is connected to the DOM, it goes through the following initialization sequence:
- Element connected to DOM
- Unique identifier created
- MutationObserver attached (watches for attribute/content changes)
- Fragment methods defined (methods starting with capital letters)
- Attributes and dataset attached to
this setup()called (if defined)- Initial
render()called
After initialization, the element will automatically re-render when:
- Attributes change
- Content mutations occur (innerHTML/textContent changes)
- Store updates trigger
onStoreChange()
There are 2 functions. render is required and setup is optional
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()).
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
Create reusable sub-elements with object/id binding for efficient rendering.
The wire takes two arguments Html.wire(obj, id):
- A reference object to match with the created node, allowing reuse of the existing node
- A string to identify the markup used, allowing the template to be generated only once
Html`
<ul>
${users.map((user) => Html.wire(user, ':user_list_item')`<li>${user.name}</li>`)}
</ul>
`;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
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 |
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}
`;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}`;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}`;Component attributes can directly control what gets rendered:
// Notification banner with attribute-driven rendering
hyperElement('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!" />Create once-off sub-elements for integrating external libraries.
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.
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><strong>Bold</strong> and <em>italic</em></div>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
You can trigger re-renders without any external data:
setup(attachStore) {
setInterval(attachStore(), 1000); // re-render every second
}Pass static data to every render:
setup(attachStore) {
attachStore({ max_levels: 3 }); // passed to every render
}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);
}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 ...")
}Available properties and methods on this (class-based) or ctx (functional API):
| 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 |
Attributes are automatically type-coerced:
| Input | Output | Type |
|---|---|---|
"42" |
42 |
Number |
"3.14" |
3.14 |
Number |
"hello" |
"hello" |
String |
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 attributeBeing 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',
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
Build reusable form components with validation logic and custom event dispatching. Use ctx.element.dispatchEvent() to communicate validation state to parent components.
hyperElement('validated-input', {
setup: (ctx, onNext) => {
const state = { value: '', error: null, touched: false };
const render = onNext(() => state);
ctx.validate = (value) => {
const rules = ctx.attrs.rules || {};
if (rules.required && !value) return 'Required';
if (rules.minLength && value.length < rules.minLength)
return `Min ${rules.minLength} characters`;
if (rules.pattern && !new RegExp(rules.pattern).test(value))
return 'Invalid format';
return null;
};
ctx.handleInput = (e) => {
state.value = e.target.value;
state.touched = true;
state.error = ctx.validate(state.value);
render();
// Dispatch custom event to parent
ctx.element.dispatchEvent(
new CustomEvent('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>
`,
});hyperElement('signup-form', {
setup: (ctx, onNext) => {
const state = { email: { valid: false }, password: { valid: false } };
const render = 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>
`,
});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:
- Add a
templateattribute to your custom element - Define the template markup within your element
- 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
| 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) |
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
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!
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>{@}- 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 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
For components that need to fetch data on mount, use setup() with async operations:
hyperElement('user-profile', {
setup: (ctx, onNext) => {
const state = { user: null, loading: true, error: null };
const render = onNext(() => state);
// Fetch data when component mounts
fetch(`/api/users/${ctx.attrs.userId}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.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: ${new Date(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) => {
const state = { user: null, loading: true, error: null };
const render = onNext(() => state);
// Fetch function that can be called multiple times
ctx.fetchUser = async (userId) => {
state.loading = true;
state.error = null;
render();
try {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
state.user = await res.json();
} catch (err) {
state.error = err.message;
}
state.loading = false;
render();
};
// Initial fetch
ctx.fetchUser(ctx.attrs.userId);
},
// This render is called whenever attrs change
render: (Html, ctx, state) => {
// Check if userId changed and refetch
if (ctx.attrs.userId && state.user?.id !== ctx.attrs.userId) {
ctx.fetchUser(ctx.attrs.userId);
}
return Html`
{+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}
`;
},
});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
+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.
import hyperElement, { defineStyled } from 'hyper-element';
const cardStyles = 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.
Combine list rendering with dynamic inline styles driven by component state:
hyperElement('task-list', {
setup: (ctx, onNext) => {
const state = {
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 },
],
};
const render = onNext(() => state);
ctx.setFilter = (filter) => {
state.filter = filter;
render();
};
ctx.toggleTask = (id) => {
const task = state.tasks.find((t) => t.id === id);
if (task) task.done = !task.done;
render();
};
},
render: (Html, ctx, { filter, tasks }) => {
const filtered = tasks.filter((t) =>
filter === 'all' ? true : filter === 'done' ? t.done : !t.done
);
return Html`
<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>
`;
},
});hyper-element integrates with any state management library via setup(). The pattern is:
- Call
attachStore()with a function that returns your state - Subscribe to your store and call the returned function when state changes
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}`;
}
}
);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}`;
}
}
);customElements.define(
'my-profile',
class extends hyperElement {
setup(attachStore) {
store.subscribe(attachStore(store.getState));
}
render(Html, { user }) {
Html`Profile: ${user.name}`;
}
}
);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 store
const appStore = {
users: {},
listeners: new Set(),
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 updates
hyperElement('user-status', {
setup: (ctx, onNext) => {
const render = onNext(() => appStore.users);
// Subscribe to store changes
appStore.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')Write component actions back to external stores (Redux pattern):
// Redux-style store
const todoStore = {
state: { todos: [] },
listeners: new Set(),
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':
const todo = 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() {
return this.state;
},
};
// Component that reads FROM and writes TO the store
hyperElement('todo-item', {
setup: (ctx, onNext) => {
const render = 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 }) => {
const todo = todos.find((t) => t.id === ctx.attrs.todoId);
if (!todo) return Html``;
return Html`
<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>
`;
},
});Multiple components can share the same store, each rendering their own view of the data:
// Shared store - single source of truth
const cartStore = {
state: { items: [], user: null },
listeners: new Set(),
subscribe(fn) {
this.listeners.add(fn);
},
unsubscribe(fn) {
this.listeners.delete(fn);
},
notify() {
this.listeners.forEach((fn) => fn());
},
// Actions
addItem(item) {
this.state.items.push(item);
this.notify();
},
removeItem(id) {
this.state.items = this.state.items.filter((i) => i.id !== id);
this.notify();
},
getState() {
return this.state;
},
};
// Component 1: Cart badge in header (shows count)
hyperElement('cart-badge', {
setup: (ctx, onNext) => {
const render = 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) => {
const render = 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>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';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());
});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(); // 10Creates 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();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)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-runSignals 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>
`,
});hyper-element supports server-side rendering for faster initial page loads and SEO. The SSR system has two parts:
- Server-side API - Render components to HTML strings in Node.js/Deno/Bun
- Client-side hydration - Capture user interactions during page load and replay them after components register
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';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 |
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' });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 stringsTagged 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>`;Utility functions for HTML escaping:
// Escape user input
const safe = escapeHtml('<script>alert("xss")</script>');
// Result: <script>alert("xss")</script>
// Mark trusted HTML as safe (bypasses escaping)
const trusted = safeHtml('<strong>Bold</strong>');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>
`,
});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.
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
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)
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
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>`;
}
}
);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
}The hydration system automatically preserves:
- Form values - Input, textarea, select values via
inputevents - Checkbox/radio state - Checked state captured and restored
- Scroll position - Scroll positions within components
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,
// Optional CSP nonce for browser-created +styled style hosts
styleNonce: 'request-nonce',
});Server rendering can also put the same nonce on selector-capable +styled
style hosts for strict CSP deployments:
const html = await renderElement('secure-card', {
styleNonce: 'request-nonce',
styled: [{ article: { color: 'black', ':hover': { color: 'blue' } } }],
render: (Html) => Html`<article+styled>Secure</article>`,
});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>
`,
});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.
<link rel="stylesheet" href="hyper-element/src/json-render/json-render.css" />
<script type="module">
// Auto-registers <json-render> and exposes the API
import '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.
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" } }
}
}
}| Type | Props | Events |
|---|---|---|
| Card | title, description | — |
| Row | gap | — |
| Column | gap | — |
| Button | label, variant, disabled, loading | on.press → jr-action |
| Text | content, variant | — |
| Alert | variant, message | — |
| Progress | label, value (0-100) | — |
| Divider | — | — |
| CodeBlock | language, code | — |
| Image | src, alt, width, height | — |
| Checklist | label, items | checkbox → jr-action |
| TextField | label, placeholder, maxLength | on.submit → jr-action |
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) => {
const spec = JSON.parse(ctx.wrappedContent);
return renderSpec(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);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:
// Imperative
document.querySelector('json-render').onaction = (e) => {
console.log(e.detail.action, e.detail.params);
};
// Declarative inside a hyper-element template
Html`<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.
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';
const catalog = 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.
const prompt = 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.
const tool = 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.
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 || {};
return Html`
<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-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-layout
edit=${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.
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>`;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: '' };| Source | Supported Types |
|---|---|
this.attrs |
Number |
this.dataset |
Object, Array, Number, Boolean |
Always return a cleanup function when using resources that need disposal:
setup(attachStore) {
const interval = setInterval(attachStore(), 1000);
return () => clearInterval(interval); // Cleanup on removal
}- Node.js 20 or higher
- npm (comes with Node.js)
-
Clone the repository:
git clone https://github.com/codemeasandwich/hyper-element.git cd hyper-element -
Install dependencies:
npm install
This also installs the pre-commit hooks automatically via the
preparescript.
| 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) |
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
The build process uses esbuild for fast, minimal output:
npm run buildThis produces:
build/hyperElement.min.js- Minified bundle (~6.2 KB)build/hyperElement.min.js.map- Source map for debugging
The project uses a modular pre-commit hook system located in .hooks/. When you commit, the following checks run automatically:
- ESLint - Code quality checks
- Prettier - Code formatting
- Build - Ensures the build succeeds
- Coverage - Enforces 100% test coverage
- JSDoc - Documentation validation
- Docs - Documentation completeness
If any check fails, the commit is blocked until the issue is fixed.
If hooks weren't installed automatically:
npm run hooks:install- 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:fixhyper-element uses a two-phase test workflow to ensure both source quality and build integrity:
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
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)
npm testRuns both phases sequentially: source coverage first, then bundle verification.
After running tests, open the HTML coverage report:
open coverage/index.htmlThis shows:
- File-by-file coverage breakdown
- Line-by-line highlighting of covered/uncovered code
- Statement, branch, and function metrics
Tests are located in examples/kitchensink/ and run via Playwright. See examples/kitchensink/kitchensink.spec.js for the test suite.
See CONTRIBUTING.md for contribution guidelines.