hyper-element
v1.1.0
Published
Fast, lightweight web components
Maintainers
Readme
hyper-element
A lightweight Custom Elements library with a fast, built-in render core. Your custom-element will react to tag attribute and store changes with efficient DOM updates.
If you like it, please ★ it on github
Installation
npm
npm install hyper-elementES6 Modules
import hyperElement from 'hyper-element';
customElements.define(
'my-elem',
class extends hyperElement {
render(Html) {
Html`Hello ${this.attrs.who}!`;
}
}
);CommonJS
const hyperElement = require('hyper-element');
customElements.define(
'my-elem',
class extends hyperElement {
render(Html) {
Html`Hello ${this.attrs.who}!`;
}
}
);CDN (Browser)
For browser environments without a bundler:
<script src="https://cdn.jsdelivr.net/npm/hyper-element@latest/build/hyperElement.min.js"></script>The hyperElement class will be available globally on window.hyperElement.
Browser Support
hyper-element requires native ES6 class support and the Custom Elements v1 API:
| Browser | Version | | ------- | ------- | | Chrome | 67+ | | Firefox | 63+ | | Safari | 10.1+ | | Edge | 79+ |
For older browsers, a Custom Elements polyfill may be required.
Why hyper-element
- hyper-element is fast & small
- Zero runtime dependencies - everything is built-in
- With a completely stateless approach, setting and reseting the view is trivial
- Simple yet powerful Interface
- Built in template system to customise the rendered output
- Inline style objects supported (similar to React)
- First class support for data stores
- Pass
functionto other custom hyper-elements via there tag attribute
Live Demo
Live Examples
| Example | Description | Link | | -------------------- | ----------------------------------- | ---------------------------------------------------------- | | Hello World | Basic element creation | CodePen | | Attach a Store | Store integration with setup() | CodePen | | Templates | Using the template system | CodePen | | Child Element Events | Passing functions to child elements | CodePen | | Async Fragments | Loading content asynchronously | CodePen | | Styling | React-style inline styles | CodePen | | Full Demo | Complete feature demonstration | JSFiddle |
- Browser Support
- Define a Custom Element
- Functional API
- Lifecycle
- Interface
- Advanced Attributes
- Templates
- Fragments
- Styling
- Connecting to a Data Store
- Best Practices
- Development
Define a custom-element
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/hyper-element@latest/build/hyperElement.min.js"></script>
</head>
<body>
<my-elem who="world"></my-elem>
<script>
customElements.define(
'my-elem',
class extends hyperElement {
render(Html) {
Html`hello ${this.attrs.who}`;
}
}
);
</script>
</body>
</html>Output
<my-elem who="world"> hello world </my-elem>Live Example of helloworld
Functional API
In addition to class-based components, hyper-element supports a functional API that hides the class internals. This is useful for simpler components or if you prefer a more functional programming style.
Signatures
// 1. Full definition with tag (auto-registers)
hyperElement('my-counter', {
observedAttributes: ['count'],
setup: (ctx, onNext) => {
/* ... */
},
render: (Html, ctx, store) => Html`Count: ${ctx.attrs.count}`,
});
// 2. Shorthand with tag (auto-registers)
hyperElement('hello-world', (Html, ctx) => Html`Hello, ${ctx.attrs.name}!`);
// 3. Definition without tag (returns class for manual registration)
const MyElement = hyperElement({
render: (Html, ctx) => Html`...`,
});
customElements.define('my-element', MyElement);
// 4. Shorthand without tag (returns class for manual registration)
const Simple = hyperElement((Html, ctx) => Html`Simple!`);
customElements.define('simple-elem', Simple);Context Object
In the functional API, instead of using this, a context object (ctx) is passed explicitly to all functions:
| Property | Description |
| -------------------- | ---------------------------------------------- |
| ctx.element | The DOM element |
| ctx.attrs | Parsed attributes with automatic type coercion |
| ctx.dataset | Dataset proxy with automatic type coercion |
| ctx.store | Store value from setup |
| ctx.wrappedContent | Text content between the tags |
Example: Counter with Setup
hyperElement('my-counter', {
setup: (ctx, onNext) => {
const store = { count: 0 };
const render = onNext(() => store);
ctx.increment = () => {
store.count++;
render();
};
},
handleClick: (ctx, event) => ctx.increment(),
render: (Html, ctx, store) => Html`
<button onclick=${ctx.handleClick}>
Count: ${store?.count || 0}
</button>
`,
});Example: Timer with Teardown
hyperElement('my-timer', {
setup: (ctx, onNext) => {
let seconds = 0;
const render = onNext(() => ({ seconds }));
const interval = setInterval(() => {
seconds++;
render();
}, 1000);
// Return cleanup function
return () => clearInterval(interval);
},
render: (Html, ctx, store) => Html`Elapsed: ${store?.seconds || 0}s`,
});Backward Compatibility
The functional API is fully backward compatible. Class-based components still work:
class MyElement extends hyperElement {
render(Html) {
Html`Hello ${this.attrs.name}!`;
}
}
customElements.define('my-element', MyElement);Lifecycle
When a hyper-element is connected to the DOM, it goes through the following initialization sequence:
- 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()
Interface
Define your element
There are 2 functions. render is required and setup is optional
render
This is what will be displayed within your element. Use the Html to define your content.
render(Html, store) {
Html`
<h1>
Last updated at ${new Date().toLocaleTimeString()}
</h1>
`;
}The second argument store contains the value returned from your store function (if using setup()).
Html
The primary operation is to describe the complete inner content of the element.
render(Html, store) {
Html`
<h1>
Last updated at ${new Date().toLocaleTimeString()}
</h1>
`;
}The Html has a primary operation and two utilities: .wire & .lite
Html.wire
Create reusable sub-elements with object/id binding for efficient rendering.
The wire takes two arguments Html.wire(obj, id):
- 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
Example: Rendering a List
Html`
<ul>
${users.map((user) => Html.wire(user, ':user_list_item')`<li>${user.name}</li>`)}
</ul>
`;Anti-pattern: Inlining Markup as Strings
BAD example: ✗
Html`
<ul>
${users.map((user) => `<li>${user.name}</li>`)}
</ul>
`;This creates a new node for every element on every render, causing:
- Negative impact on performance
- Output will not be sanitized - potential XSS vulnerability
Block Syntax
The Html function supports block syntax for iteration and conditionals directly in tagged template literals:
| Syntax | Description |
| ------------------------------------- | --------------------- |
| {+each ${array}}...{-each} | Iterate over arrays |
| {+if ${condition}}...{-if} | Conditional rendering |
| {+if ${condition}}...{else}...{-if} | Conditional with else |
| {+unless ${condition}}...{-unless} | Negated conditional |
{+each} - Iteration
For cleaner list rendering, use the {+each}...{-each} syntax:
Html`<ul>{+each ${users}}<li>{name}</li>{-each}</ul>`;This is equivalent to:
Html`<ul>${users.map((user) => Html.wire(user, ':id')`<li>${user.name}</li>`)}</ul>`;The {+each} syntax automatically calls Html.wire() for each item, ensuring efficient DOM reuse.
Available variables inside {+each}:
| Syntax | Description |
| -------------------- | ----------------------------------------- |
| {name} | Access item property |
| {address.city} | Nested property access |
| {...} or { ... } | Current item value (see formatting below) |
| {@} | Current array index (0-based) |
Formatting rules for {...} output:
| Type | Output |
| ----------------------------------- | ----------------------------------------------------- |
| Primitive (string, number, boolean) | toString() and HTML escaped |
| Array | .join(",") |
| Object | JSON.stringify() |
| Function | Called with no args, return value follows these rules |
Examples:
// Multiple properties
Html`<ul>{+each ${users}}<li>{name} ({age})</li>{-each}</ul>`;
// Using index
Html`<ol>{+each ${items}}<li>{@}: {title}</li>{-each}</ol>`;
// Nested arrays with {+each {property}}
const categories = [
{ name: 'Fruits', items: [{ title: 'Apple' }, { title: 'Banana' }] },
{ name: 'Veggies', items: [{ title: 'Carrot' }] },
];
Html`
{+each ${categories}}
<section>
<h3>{name}</h3>
<ul>{+each {items}}<li>{title}</li>{-each}</ul>
</section>
{-each}
`;{+if} - Conditionals
Render content based on a condition:
Html`{+if ${isLoggedIn}}<p>Welcome back!</p>{-if}`;
// With else
Html`{+if ${isLoggedIn}}<p>Welcome back!</p>{else}<p>Please log in</p>{-if}`;{+unless} - Negated Conditionals
Render content when condition is falsy (opposite of {+if}):
Html`{+unless ${hasErrors}}<p>Form is valid</p>{-unless}`;
// With else
Html`{+unless ${isValid}}Invalid input!{else}Looking good!{-unless}`;Html.lite
Create once-off sub-elements for integrating external libraries.
Example: Wrapping jQuery DatePicker
customElements.define(
'date-picker',
class extends hyperElement {
onSelect(dateText, inst) {
console.log('selected time ' + dateText);
}
Date(lite) {
const inputElem = lite`<input type="text"/>`;
$(inputElem).datepicker({ onSelect: this.onSelect });
return {
any: inputElem,
once: true,
};
}
render(Html) {
Html`Pick a date ${{ Date: Html.lite }}`;
}
}
);The once: true option ensures the fragment is only generated once, preventing the datepicker from being reinitialized on every render.
setup
The setup function wires up an external data-source. This is done with the attachStore argument that binds a data source to your renderer.
setup(attachStore) {
// the getMouseValues function will be called before each render and passed to render
const onStoreChange = attachStore(getMouseValues);
// call onStoreChange on every mouse event
onMouseMove(onStoreChange);
// cleanup logic
return () => console.warn('On remove, do component cleanup here');
}Live Example of attach a store
Re-rendering Without a Data Source
You can trigger re-renders without any external data:
setup(attachStore) {
setInterval(attachStore(), 1000); // re-render every second
}Set Initial Values
Pass static data to every render:
setup(attachStore) {
attachStore({ max_levels: 3 }); // passed to every render
}Cleanup on Removal
Return a function from setup to run cleanup when the element is removed from the DOM:
setup(attachStore) {
let newSocketValue;
const onStoreChange = attachStore(() => newSocketValue);
const ws = new WebSocket('ws://127.0.0.1/data');
ws.onmessage = ({ data }) => {
newSocketValue = JSON.parse(data);
onStoreChange();
};
// Return cleanup function
return ws.close.bind(ws);
}Multiple Subscriptions
You can trigger re-renders from multiple sources:
setup(attachStore) {
const onStoreChange = attachStore(user);
mobx.autorun(onStoreChange); // update when changed (real-time feedback)
setInterval(onStoreChange, 1000); // update every second (update "the time is now ...")
}this
Available properties and methods on this:
| Property | Description |
| --------------------- | --------------------------------------------------------------------------- |
| this.attrs | Attributes on the tag. <my-elem min="0" max="10" /> = { min:0, max:10 } |
| this.store | Value returned from the store function. Only updated before each render |
| this.wrappedContent | Text content between your tags. <my-elem>Hi!</my-elem> = "Hi!" |
| this.element | Reference to your created DOM element |
| this.dataset | Read/write access to all data-* attributes |
this.attrs
Attributes are automatically type-coerced:
| Input | Output | Type |
| --------- | --------- | ------ |
| "42" | 42 | Number |
| "3.14" | 3.14 | Number |
| "hello" | "hello" | String |
this.dataset
The dataset provides proxied access to data-* attributes with automatic JSON parsing:
| Attribute Value | this.dataset Value | Type |
| ------------------------ | -------------------- | ------- |
| data-count="42" | 42 | Number |
| data-active="true" | true | Boolean |
| data-active="false" | false | Boolean |
| data-users='["a","b"]' | ["a", "b"] | Array |
| data-config='{"x":1}' | { x: 1 } | Object |
Example:
<my-elem data-users='["ann","bob"]'></my-elem>this.dataset.users; // ["ann", "bob"]The dataset is a live reflection. Changes update the matching data attribute on the element:
this.dataset.user = { name: 'Alice' }; // Updates data-user attributeAdvanced Attributes
Dynamic Attributes with Custom-element Children
Being able to set attributes at run-time should be the same for dealing with a native element and ones defined by hyper-element.
⚠ To support dynamic attributes on custom elements YOU MUST USE customElements.define which requires native ES6 support! Use /build/hyperElement.min.js.
This is what allows for the passing any dynamic attributes from parent to child custom element! You can also pass a function, boolean, number, or object to a child element (that extends hyperElement).
Example:
window.customElements.define(
'a-user',
class extends hyperElement {
render(Html) {
const onClick = () => this.attrs.hi('Hello from ' + this.attrs.name);
Html`${this.attrs.name} <button onclick=${onClick}>Say hi!</button>`;
}
}
);
window.customElements.define(
'users-elem',
class extends hyperElement {
onHi(val) {
console.log('hi was clicked', val);
}
render(Html) {
Html`<a-user hi=${this.onHi} name="Beckett" />`;
}
}
);Live Example of passing an onclick to a child element
Templates
Unlike standard Custom Elements which typically discard or replace their innerHTML, hyper-element's template system preserves the markup inside your element and uses it as a reusable template. This means your custom element primarily holds logic, while the template markup between the tags defines how data should be rendered.
To enable templates:
- 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
Basic Template Syntax
| Syntax | Description |
| ---------------------------------- | ------------------------------------- |
| {variable} | Simple interpolation |
| {+if condition}...{-if} | Conditional rendering |
| {+if condition}...{else}...{-if} | Conditional with else |
| {+unless condition}...{-unless} | Negative conditional (opposite of if) |
| {+each items}...{-each} | Iteration over arrays |
| {@} | Current index in each loop (0-based) |
Conditionals: {+if}
Show content based on a condition:
<status-elem template>{+if active}Online{else}Offline{-if}</status-elem>customElements.define(
'status-elem',
class extends hyperElement {
render(Html) {
Html`${Html.template({ active: true })}`;
}
}
);Output: Online
Negation: {+unless}
Show content when condition is falsy (opposite of +if):
<warning-elem template>{+unless valid}Invalid input!{-unless}</warning-elem>customElements.define(
'warning-elem',
class extends hyperElement {
render(Html) {
Html`${Html.template({ valid: false })}`;
}
}
);Output: Invalid input!
Iteration: {+each}
Loop over arrays:
<list-elem template>
<ul>
{+each items}
<li>{name}</li>
{-each}
</ul>
</list-elem>customElements.define(
'list-elem',
class extends hyperElement {
render(Html) {
Html`${Html.template({ items: [{ name: 'Ann' }, { name: 'Bob' }] })}`;
}
}
);Output:
<ul>
<li>Ann</li>
<li>Bob</li>
</ul>Special Variables in {+each}
{@}- The current index (0-based)
<nums-elem template>{+each numbers}{@}: {number}, {-each}</nums-elem>customElements.define(
'nums-elem',
class extends hyperElement {
render(Html) {
Html`${Html.template({ numbers: ['a', 'b', 'c'] })}`;
}
}
);Output: 0: a, 1: b, 2: c,
Fragments
Fragments are pieces of content that can be loaded asynchronously.
You define one with a class property starting with a capital letter.
The fragment function should return an object with:
- placeholder: the placeholder to show while resolving
- once: Only generate the fragment once (default:
false)
And one of the following as the result:
- text: An escaped string to output
- any: Any type of content
- html: A html string to output (not sanitised)
- template: A template string to use (is sanitised)
Example:
customElements.define(
'my-friends',
class extends hyperElement {
FriendCount(user) {
return {
once: true,
placeholder: 'loading your number of friends',
text: fetch('/user/' + user.userId + '/friends')
.then((b) => b.json())
.then((friends) => `you have ${friends.count} friends`)
.catch((err) => 'problem loading friends'),
};
}
render(Html) {
const userId = this.attrs.myId;
Html`<h2> ${{ FriendCount: userId }} </h2>`;
}
}
);Live Example of using an asynchronous fragment
Styling
Supports an object as the style attribute. Compatible with React's implementation.
Example: of centering an element
render(Html) {
const style = {
position: 'absolute',
top: '50%',
left: '50%',
marginRight: '-50%',
transform: 'translate(-50%, -50%)',
};
Html`<div style=${style}> center </div>`;
}Live Example of styling
Connecting to a Data Store
hyper-element integrates with any state management library via setup(). The pattern is:
- Call
attachStore()with a function that returns your state - Subscribe to your store and call the returned function when state changes
Backbone
var user = new (Backbone.Model.extend({
defaults: {
name: 'Guest User',
},
}))();
customElements.define(
'my-profile',
class extends hyperElement {
setup(attachStore) {
user.on('change', attachStore(user.toJSON.bind(user)));
// OR user.on("change", attachStore(() => user.toJSON()));
}
render(Html, { name }) {
Html`Profile: ${name}`;
}
}
);MobX
const user = observable({
name: 'Guest User',
});
customElements.define(
'my-profile',
class extends hyperElement {
setup(attachStore) {
mobx.autorun(attachStore(user));
}
render(Html, { name }) {
Html`Profile: ${name}`;
}
}
);Redux
customElements.define(
'my-profile',
class extends hyperElement {
setup(attachStore) {
store.subscribe(attachStore(store.getState));
}
render(Html, { user }) {
Html`Profile: ${user.name}`;
}
}
);Best Practices
Always Use Html.wire for Lists
When rendering lists, always use Html.wire() to ensure proper DOM reuse and prevent XSS vulnerabilities:
// GOOD - Safe and efficient
Html`<ul>${users.map((u) => Html.wire(u, ':item')`<li>${u.name}</li>`)}</ul>`;
// BAD - XSS vulnerability and poor performance
Html`<ul>${users.map((u) => `<li>${u.name}</li>`)}</ul>`;Dataset Updates Require Assignment
The dataset works by reference. To update an attribute you must use assignment:
// BAD - mutation doesn't trigger attribute update
this.dataset.user.name = '';
// GOOD - assignment triggers attribute update
this.dataset.user = { name: '' };Type Coercion Reference
| Source | Supported Types |
| -------------- | ------------------------------ |
| this.attrs | Number |
| this.dataset | Object, Array, Number, Boolean |
Cleanup Resources in setup()
Always return a cleanup function when using resources that need disposal:
setup(attachStore) {
const interval = setInterval(attachStore(), 1000);
return () => clearInterval(interval); // Cleanup on removal
}Development
Prerequisites
- Node.js 20 or higher
- npm (comes with Node.js)
Setup
Clone the repository:
git clone https://github.com/codemeasandwich/hyper-element.git cd hyper-elementInstall dependencies:
npm installThis also installs the pre-commit hooks automatically via the
preparescript.
Available Scripts
| Command | Description |
| --------------------- | ------------------------------------------------- |
| npm run build | Build minified production bundle with source maps |
| npm test | Run Playwright tests with coverage |
| npm run test:ui | Run tests with Playwright UI for debugging |
| npm run test:headed | Run tests in headed browser mode |
| npm run kitchensink | Start local dev server for examples |
| npm run lint | Run ESLint to check for code issues |
| npm run format | Check Prettier formatting |
| npm run format:fix | Auto-fix Prettier formatting issues |
| npm run release | Run the release script (maintainers only) |
Project Structure
hyper-element/
├── src/ # Source files (ES modules)
│ ├── attributes/ # Attribute handling
│ ├── core/ # Core utilities
│ ├── html/ # HTML tag functions
│ ├── lifecycle/ # Lifecycle hooks
│ ├── render/ # Custom render core (uhtml-inspired)
│ ├── signals/ # Reactive primitives (signal, computed, effect)
│ ├── template/ # Template processing
│ ├── utils/ # Shared utilities
│ └── index.js # Main export
├── build/
│ ├── hyperElement.min.js # Minified production build
│ └── hyperElement.min.js.map
├── kitchensink/ # Test suite
│ ├── kitchensink.spec.js # Playwright test runner
│ └── *.html # Test case files
├── example/ # Example project
├── docs/ # Documentation
├── .hooks/ # Git hooks
│ ├── pre-commit # Main hook orchestrator
│ ├── commit-msg # Commit message validator
│ └── pre-commit.d/ # Modular validation scripts
└── scripts/
└── publish.sh # Release scriptBuilding
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
Pre-commit Hooks
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.
Installing Hooks Manually
If hooks weren't installed automatically:
npm run hooks:installCode 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:fixTesting
Tests are located in kitchensink/ and run via Playwright. See kitchensink/kitchensink.spec.js for the test suite.
Contributing
See CONTRIBUTING.md for contribution guidelines.
