Solarite is a small (9KB min+gzip), fast, compilation-free JavaScript library for enhancing web components with minimal DOM updates on re-render.
ximport h, {Solarite} from './dist/Solarite.min.js';
class ShoppingList extends Solarite { // Solarite extends HTMLElement constructor(items=[]) { super(); this.items = items; }
addItem() { this.items.push({name: '', qty: 0}); this.render(); }
removeItem(item) { this.items.splice(this.items.indexOf(item), 1); this.render(); }
render() { // Think of h(this) as like: // this.outerHTML = `<shopping-list>...` // but rendering only minimal DOM updates when the html changes. h(this)` <shopping-list> <style> /* scoped styles */ :host input { width: 80px } </style>
<button onclick=${this.addItem}>Add Item</button>
${this.items.map(item => h` <div> <!-- 2-way binding --> <input placeholder="Item"value=${[item, 'name']} oninput=${this.render}> <input type="number" value=${[item, 'qty']} oninput=${this.render}> <button onclick=${()=>this.removeItem(item)}>x</button> </div> `)}
<pre>items = ${JSON.stringify(this.items, null, 4)}</pre> </shopping-list>` }}
document.body.append(new ShoppingList()); // add <shopping-list> and call render()Compilation-Free: No build step required. Standard ES6 modules work directly in the browser.
Explicit Rendering: You control exactly when updates happen by calling render(). No unexpected side effects.
Minimal Rendering: Only changed DOM elements are updated.
Simple State Management: No signals and no special state setup required. Use regular JavaScript variables and data structures of arbitrary depth.
Two-Way Binding: Built-in shorthand for connecting data to form elements.
Scoped CSS: Native styles that apply only to your component while still inheriting parent styles--without Shadow DOM.
Automatic Element References: Elements with id or data-id automatically become class properties.
Component Composition: Attributes are passed as constructor arguments to nested components for easy data flow.
TypeScript Support: Includes a comprehensive .d.ts file for excellent IDE support and type safety in TypeScript projects.
MIT License: Free for commercial use with no attribution required.
Import the module directly from a CDN:
Solarite.min.js (9KB minified+gzipped)
Or install via NPM:
xxxxxxxxxxnpm install solariteFor the best development experience, use an IDE like WebStorm or VS Code with a Lit-html extension for syntax highlighting of HTML template strings. Solarite's included Solarite.d.ts provides auto-completion and type checking for all core APIs.
Solarite provides near-native performance by performing targeted DOM updates. Benchmarks were run on a Ryzen 7 3700X on Windows 10.

Note that the JS Framework Benchmark separates keyed and non-keyed frameworks. Solarite is non-keyed according to the criteria of this benchmark but in this chart it's placed next to keyed frameworks since otherwise we can't compare it with the most popular frameworks.
Solarite enhances web components with efficient and minimal re-rendering of elements when your data changes. This approach minimizes DOM operations and improves performance.
In this minimal example, we create a class called MyComponent which extends from HTMLElement (the standard way to create web components). We add a render() method to define its HTML content, and call it from the constructor when a new instance is created.
Important: All browsers require web component tag names to contain at least one dash (e.g., my-component, not mycomponent). This is a standard requirement for custom elements.
xxxxxxxxxximport h, {Solarite} from './dist/Solarite.min.js';
class MyComponent extends Solarite { name = 'Fred';
render() { // This is how we'd create a web component using vanilla JavaScript // without Solarite. But this recreates all children on every render! //this.innerHTML = `Hello <b>${this.name}!<b>`;
// Using Solarite's h() function performs minimal updates on render. h(this)`<my-component>Hello <b>${this.name}!</b></my-component>` }}
// Register the <my-component> tag name with the browser.MyComponent.define('my-component'); // Optional.
let mc = new MyComponent();document.body.append(mc);
mc.name = 'Solarite';mc.render();
We can alternatively instantiate the element directly from html:
xxxxxxxxxx<my-component></my-component>Note that we call .define() to register the <my-component> tag name with the browser. Internally, this calls the Broser's customElements.define() function. Browsers can only use web components that have been defined.
However, if you don't call .define() and create an instance of your element via new it will be defined automatically using the ClassName converted to kebob-case as the tag name. However this will NOT work if the first encounter with the element is when it's instantiated from html via its tag name.
Since these are just regular web components, they can define the connectedCallback() and disconnectedCallback() methods that will be called when they're added and removed from the DOM, respectively.
Use the h function as a tagged template literal to convert HTML strings and embedded expressions into a Solarite Template. This data structure efficiently stores processed HTML and expressions for optimal rendering.
When you call h(this) followed by a template string, it renders that Template as the element's attributes and children. This is similar to assigning to the browser's built-in this.outerHTML property, but with a crucial difference: Solarite's updates are much faster because only the changed elements are replaced, not all nodes.
When an element is first added to the DOM, the render() function is called automatically. But only if it hasn't already been previously called manually.
Unlike many frameworks, Solarite does not automatically re-render when data changes. Instead, you must call the render() function manually when you want to update the DOM. This is a deliberate design choice that:
Gives you complete control over when rendering occurs. You can update data without triggering a render.
Reduces unexpected side effects, making behavior more predictable.
This approach is particularly useful in performance-critical applications where you need precise control over when DOM updates occur.
Wrapping the web component's html in its tag name is optional. But without it you then must set any attributes on your web component manually:
xxxxxxxxxximport h, {Solarite} from './dist/Solarite.min.js';
class MyComponent extends Solarite { name = 'Solarite'; render() { // With optional element tags: // h(this)`<my-component class="big">Hello <b>${this.name}!<b></my-component>`
// Without optional element tags: h(this)`Hello <b>${this.name}!<b>`; this.setAttribute('class', 'big'); }}MyComponent.define('my-component');let myComponent = new MyComponent();document.body.append(myComponent);If you do wrap the web component's html in its tag, that tag name must exactly match the tag name passed to customElements.define().
By default, h() renders expressions as text. To render HTML, wrap the string in h() as well.
xxxxxxxxxximport h, {toEl} from './dist/Solarite.min.js';
let folderIcon = // https://icon-sets.iconify.design/material-symbols/folder-outline/`<svg width="10em" height="10em" viewBox="0 0 24 24"> <path fill="currentColor" d="M2 4h8l2 2h10v14H2V4Zm2 2v12h16V8h-8.825l-2-2H4Zm0 12V6v12Z"/></svg>`;
console.log(h(folderIcon));
let icon1 = toEl({ render() { // Bad: Renders svg as html entities. h(this)`<div>${folderIcon}</div>` }});document.body.append(icon1);
let icon2 = toEl({ render() { // Good: folderIcon html string wrapped in h() h(this)`<div>${h(folderIcon)}</div>` }});document.body.append(icon2);
These types of objects can be returned by in expressions with h tagged template literals:
strings and numbers.
boolean true, which will be rendered as 'true'
false, null, and undefined, which will be rendered as empty string.
Solarite Templates, which can be created by h-tagged template literals.
DOM Nodes, including other web components.
Arrays of any of the above.
Functions that return any of the above.
Dynamic attributes can be specified by inserting expressions inside a tag. An expression can be part or all of an attribute value, or a string specifying multiple whole attributes. For example:
xxxxxxxxxximport h, {toEl} from './dist/Solarite.min.js';
let style = 'width: 100px; height: 40px; background: orange';let isEditable = true;let height = 40;
let attributeDemo = toEl({ render() { h(this)` <div class="big"> <div style=${style}>Look at me</div> <div style="${'width: 100px'}; height: ${height}px; background: gray">Look at me</div> <div style="width: 100px; height: 40px; background: brown" ${'title="I have a title"'}>Hover me</div> <div style="width: 100px; height: 40px; background: red" contenteditable=${isEditable} >Edit me</div> </div>` }});
document.body.append(attributeDemo);
style = 'width: 100px; height: 40px; background: green';setTimeout(attributeDemo.render, 2000);Expressions can also toggle the presence of an attribute. In the last div above, if isEditable is false, null, or undefined, the contenteditable attribute will be removed.
You can also specify multiple attributes at once using an object, where the keys are attribute names and the values are attribute values:
xxxxxxxxxximport h, {Solarite} from './dist/Solarite.min.js';
class ObjectAttributeDemo extends Solarite { constructor() { super(); this.attrs = { class: 'important', style: 'color: blue', 'data-test': 'example', disabled: false }; }
setDisabled() { this.attrs.disabled = true; this.render(); }
render() { h(this)` <object-attribute-demo> <button ${this.attrs} onclick=${this.setDisabled}> Click to disable </button> </object-attribute-demo>` }}ObjectAttributeDemo.define('object-attribute-demo');document.body.append(new ObjectAttributeDemo());In the example above, all attributes from the this.attrs object are applied to the button element. If a value is undefined, false, or null, the attribute will be skipped or removed if it was previously set.
Note that attributes can also be assigned to the root element, such as class="big" on the <object-attribute-demo> tag above.
Any element in the html with an id or data-id attribute is automatically bound to a property with the same name on the class instance. But this only happens after render() is first called:
xxxxxxxxxximport h, {Solarite} from './dist/Solarite.min.js';
class RaceTeam extends Solarite { render() { h(this)` <race-team> <input data-id="driver" value="Mario"> <div data-id="car">Cutlas Supreme</div> <div data-id="instructor.name">Lightning McQueen</div> </race-team>` }}let raceTeam = new RaceTeam();document.body.append(raceTeam); // calls render();
raceTeam.driver.value = 'Luigi'; raceTeam.car.style.border = '1px solid green';// We don't need to call render() because we're editing the DOM Directly.Id's that have values matching built-in HTMLElement attribute names such as title or disabled are not allowed.
To capture events, set an event attribute like onclick to a function. Alternatively, use an array where the first item is the function and subsequent items are its arguments.
xxxxxxxxxximport h, {Solarite} from './dist/Solarite.min.js';
class EventDemo extends Solarite { showMessage(message) { alert(message); }
render() { h(this)` <event-demo> <button onclick=${(ev, el)=>alert('Element ' + el.tagName + ' clicked!')}> Click me</button> <button onclick=${[this.showMessage, 'I too was clicked!']}> Click me too!</button> </event-demo>` }} document.body.append(new EventDemo());Event binding with an array containing a function and its arguments is slightly faster, since the function isn't recreated when render() is called, and it doesn't need to be unbound and rebound. But the performance difference is usually negligible.
Make sure to put your events inside ${...} expressions, because classic events can't reference variables in the current scope.
Two-way binding connects your component's data to form elements, keeping them in sync automatically.
Form elements update properties when an event like oninput is assigned a function to handle the change:
xxxxxxxxxximport h, {Solarite} from './dist/Solarite.min.js';
class BindingDemo extends Solarite {
constructor() { super(); this.count = 0; this.lines = []; }
render() { h(this)` <binding-demo> <input type="number" value=${this.count} oninput=${ev => { this.count = ev.target.value; this.render(); }}> <pre>count is ${this.count}</pre> <textarea rows="6" value=${this.lines.join('\n')} oninput=${ev => { this.lines = ev.target.value.split('\n') this.render(); }} ></textarea> <pre>line count is ${this.lines.length}</pre> <button onclick=${()=> { this.count = 0; this.lines = []; this.render(); }}>Reset</button> </binding-demo>` }}document.body.append(new BindingDemo());<input>, <select>, <textarea>, and elements with the contenteditable attribute can all use the value attribute to set their value on render. Likewise so can any custom web component that defines a value property.
Solarite also provides a shortcut for two-way binding using array syntax: value=${[this, 'count']}:
When render() is called, the input's value is set to this.count
When a user types in the input, an input event listener updates this.count with the new value.
Optionally add an oninput=${this.render} attribute to trigger re-rendering when the value changes.
xxxxxxxxxximport h, {Solarite} from './dist/Solarite.min.js';
class BindingDemo extends Solarite { constructor() { super(); this.reset(); }
reset() { this.count = 0; this.isBig = false; this.render(); }
render() { h(this)` <binding-demo> <style> :host { font-size: ${this.isBig ? 20 : 12}px }</style> <input type="number" value=${[this, 'count']} oninput=${this.render}><br> <label> <input type="checkbox" checked=${[this, 'isBig']} oninput=${this.render}> Big Text </label> <pre>count is ${this.count}</pre> <button onclick=${this.reset}>Reset</button> </binding-demo>` }}
document.body.append(new BindingDemo());The most common way to render lists is with JavaScript's Array.map() function:
xxxxxxxxxximport h, {Solarite} from './dist/Solarite.min.js';
class TodoList extends Solarite { items = [0, 1, 2, 3]; addItem() { this.items.push(this.items.length); this.render(); } render() { h(this)` <todo-list> ${this.items.map(item => h`${item}<br>` )} <button onclick=${this.addItem}> Add Item </button> </todo-list>` }}
document.body.append(new TodoList());When you update the items list and call render(), Solarite only redraws the changed elements.
Important: Nested template literals must also have the h prefix, or they'll be rendered as escaped text. Try removing the h from line 15 to see what happens.
Solarite provides a powerful scoped styling system that allows components to define styles that apply only to themselves and their children. Unlike Shadow DOM, this allows styles to be inherited from the rest of the document.
When you include a <style> element in your component template, Solarite automatically scopes those styles to your component instance. This prevents style leakage and conflicts with other elements.
Internally, scoped styles become:
A unique data-style attribute to the root element with an incrementing data-style attribute for each component instance
:host selectors are replaced with the web component tag name and unique identifier: fancy-text[data-style="1"])
xxxxxxxxxximport h from './dist/Solarite.min.js';
class FancyText extends HTMLElement { constructor() { super(); this.render(); } render() { h(this)` <fancy-text> <style> :host { display: block; border: 10px dashed red } :host p { text-shadow: 0 0 3px #f40 } </style> <p>I have a red border and shadow!</p> </fancy-text>`
/* The code above is rewritten as: <fancy-text data-style="1"> <style> fancy-text[data-style="1"] { display: block; border: 10px dashed red } fancy-text[data-style="1"] p { text-shadow: 0 0 3px #f40 } </style> <p>I have a red border and shadow!</p> </fancy-text>` */ }}customElements.define('fancy-text', FancyText);document.body.append(new FancyText());A style tag with the global attribute defines the style only once in the document head, instead of for every instance of a component. This improves rendering performance with many instances. Unlike regular styles, global styles cannot have expressions within them.
xxxxxxxxxximport h from './dist/Solarite.min.js';
class FancyText extends HTMLElement { constructor() { super(); this.render(); } render() { h(this)` <fancy-text> <style global> :host { display: block; color: blue; margin: 10ps; background: #345 } </style> <p>We all share the same style tag in the document <head>.</p> </fancy-text>` }}
customElements.define('fancy-text', FancyText);
document.body.append(new FancyText());document.body.append(new FancyText());document.body.append(new FancyText());document.body.append(new FancyText());Slots let you pass HTML content from a parent into specific locations within a child component. This is useful for reusable layouts like cards, modals, or tabs.
Use the <slot> element to define where children should be rendered:
xxxxxxxxxximport h, {Solarite, toEl} from './dist/Solarite.min.js';
class MyFrame extends Solarite { render() { h(this)` <my-frame> <div style="border: 1px solid gray; padding: 10px"> <slot></slot> </div> </my-frame>` }}MyFrame.define();
// Usage:document.body.append(toEl('<my-frame><span>Inside the frame</span></my-frame>'));To use multiple slots, give them a name attribute. Assign children to these slots using the slot attribute:
xxxxxxxxxximport h, {Solarite, toEl} from './dist/Solarite.min.js';
class MyLayout extends Solarite { render() { h(this)`<my-layout> <header><slot name="header"></slot></header> <main><slot></slot></main> <footer><slot name="footer"></slot></footer> </my-layout>` }}MyLayout.define();
// Usage:document.body.append(toEl(` <my-layout> <div slot="header">Page Title</div> <p>Main content goes here.</p> <div slot="footer">Copyright 2024</div> </my-layout>`));Elements without a slot attribute go into the unnamed (default) slot. Multiple elements can be assigned to the same slot; they appear in the order they are provided.
If a component has no <slot> elements, any provided children are appended to the end of the component by default.
Solarite makes it easy to compose complex UIs by combining smaller, reusable components.
When one web component is embedded within the html of another, its attributes are automatically passed as arguments to the constructor:
xxxxxxxxxximport h, {Solarite} from './dist/Solarite.min.js';
// A single rowclass NotesItem extends Solarite { // Constructor receives item object from attributes. constructor(fields={}) { super(); this.item = fields.item; this.fontSize = fields.fontSize; this.render(); }
render(fields=null, changed=true) { // Same arguments as constructor if (!changed) return; // fields haven't changed since previous render() call. if (fields) { this.item = fields.item; this.fontSize = fields.fontSize; } h(this)` <notes-item> <style> :host { font-size: ${this.fontSize}px; display: block; background: #ccf; padding: 4px; input { width: 90px } } </style> <div oninput=${() => this.parentNode.render()}> <input value=${[this.item, 'name']}> <input value=${[this.item, 'description']}> </div> </notes-item>` }}// Defining is required because we instantiate it from <notes-item> NotesItem.define('notes-item'); // rather than from new NotesItem()
// Contains all NotesItemsclass NotesList extends Solarite { constructor(items=[]) { super(); this.items = items; } add() { this.items.push({name: '', description: ''}); // This calls render(item, changed=false) on the first // two NotesItems, and changed=true on the third one. // We always call render() even when nothing has changed // so that the component can decide for itself what to do. this.render(); } render() { h(this)` <notes-list> ${this.items.map((item, i) => // Pass item object to NotesItem constructor: h`<notes-item item=${item} font-size=${15+i}</notes-item>` )} <button onclick=${this.add}>Add Item</button> <pre>items = ${JSON.stringify(this.items, null, 4)}</pre> </notes-list>` }}
let list = new NotesList([