Solarite makes native web components fast to update, with no build step and no signals. You write plain JavaScript and call render() when your data changes; Solarite then patches only the DOM that actually changed. It's tiny (12KB min+gzip) and runs straight in the browser as a standard ES module.
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.
Keyed Lists: An optional key attribute makes DOM nodes follow their data when lists reorder.
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 (12KB minified+gzipped)
Or install via NPM:
npm 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 is the faster than almost every well known framework, according to the js-framework-benchmark. It's score of 1.10 indicates being 10% slower than vanilla js (hand-coding everything). Benchmarks were run on a Ryzen 7 3700X with 16GB ram on Kubuntu 26.04.

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 browser's customElements.define() function. Browsers can only use web components that have been defined.
If you don't call .define() and instead create an instance via new, the tag is defined automatically using the class name converted to kebab-case. But this auto-define can't happen if the browser first meets the element as a tag name in html, so in that case you must call .define() yourself.
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().
Use the svg tagged-template prefix for SVG markup. The resulting template can be embedded in a normal h template. Use svg for dynamically generated SVG child fragments too, such as shapes created in a loop.
xxxxxxxxxximport h, {Solarite, svg} from './dist/Solarite.min.js';
class BarChart extends Solarite { values = [8, 14, 6, 18, 10];
render() { let max = Math.max(this.values);
h(this)` <bar-chart> ${svg` <svg viewBox="0 0 ${this.values.length * 14} 40" width="12em" height="4em" fill="currentColor"> ${this.values.map((value, i) => svg` <rect x=${i * 14} y=${40 - value / max * 40} width="10" height=${value / max * 40} rx="2"> <title>${value}</title> </rect>` )} </svg>`} </bar-chart>` }}
document.body.append(new BarChart());By default, expressions render as text. So raw SVG markup in a string expression is escaped and displayed as text. Put SVG markup in an svg tagged template instead.
xxxxxxxxxximport h, {toEl, svg} from './dist/Solarite.min.js';
let folderIcon = svg`<svg width="10em" height="10em" viewBox="0 0 24 24"> <path fill="currentColor" d="M2 4h8l2 2h10v14H2V4Zm2 2v12h16V8h-8.825l-2-2H4Zm0 12V6v12Z"/></svg>`;
let icon = toEl({ render() { h(this)`<div>${folderIcon}</div>` }});document.body.append(icon);For reusable SVG icons, store the whole icon as an svg template:
xxxxxxxxxximport h, {toEl, svg} from './dist/Solarite.min.js';
const playIcon = svg`<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" aria-hidden="true"> <path d="M8 5v14l11-7L8 5z"></path></svg>`;
let button = toEl({ render() { h(this)`<button>${playIcon} Play</button>` }});document.body.append(button);These types of values can be used in expressions within 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.Don't use an id that collides with a built-in HTMLElement property (like title or style), a class method, or a field that already holds a non-element value. Solarite throws rather than silently clobbering it.
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.
For components that render many event handlers, like a data grid with buttons on every row, pass eventDelegation: true as a render option:
xxxxxxxxxximport h, {Solarite} from './dist/Solarite.min.js';
class LogViewer extends Solarite { rows = [ {id: 1, text: 'First message'}, {id: 2, text: 'Second message'}, {id: 3, text: 'Third message'}, ];
deleteRow(row) { this.rows = this.rows.filter(r => r !== row); this.render(); }
render() { h(this, {eventDelegation: true})` <log-viewer> ${this.rows.map(row => h` <div key=${row.id}> ${row.text} <button onclick=${[this.deleteRow, row]}>x</button> </div>`)} </log-viewer>` }}
document.body.append(new LogViewer());Your templates don't change at all. Internally, instead of calling addEventListener on every element, Solarite listens once per event type at the document level and finds handlers by walking up from the event target. Creating 10,000 rows with two handlers each then costs zero listener registrations, which makes large lists noticeably faster to create and clear, especially on phones.
Only events that bubble are delegated (click, input, keydown, and the like); focus, blur, scroll and other non-bubbling events automatically keep regular listeners. Pass an array like eventDelegation: ['click', 'input'] to delegate only specific events.
Two caveats, both rare in practice: delegated handlers run when the event reaches the document, so a manually added addEventListener on an ancestor element fires before them rather than after, and stopPropagation() called from such a manual listener prevents delegated handlers from running. Handlers see the correct event.currentTarget either way.
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());When a bound value is read back from an element, Solarite converts it to the most appropriate JavaScript type. Bind to the value attribute for most elements, and to the checked attribute for checkboxes and radio buttons.
| Element | Bind to | Property type read back |
|---|---|---|
<input> (text, password, email, etc.) | value | String |
<input type="checkbox"> | checked | Boolean |
<input type="radio"> | checked | String (the selected radio's value) |
<input type="number">, type="range" | value | Number (NaN when empty) |
<input type="date">, time, datetime-local | value | Date object (null when empty) |
<input type="file"> | value | Array of File objects |
<select> | value | String |
<select multiple> | value | Array of Strings |
<textarea> | value | String |
contenteditable element | value | String (the element's innerHTML) |
Custom component with a value property | value | Whatever type the component's value holds |
For a radio group, put the same checked=${[this, 'prop']} binding on every radio in the group. Each radio is checked when its value matches the bound property. Clicking a radio writes its value back to the property:
xxxxxxxxxximport h, {Solarite} from './dist/Solarite.min.js';
class ColorPicker extends Solarite { color = 'green';
render() { h(this)` <color-picker> <label><input type="radio" name="color" value="red" checked=${[this, 'color']} oninput=${this.render}> Red</label> <label><input type="radio" name="color" value="green" checked=${[this, 'color']} oninput=${this.render}> Green</label> <label><input type="radio" name="color" value="blue" checked=${[this, 'color']} oninput=${this.render}> Blue</label> <pre>color is ${this.color}</pre> </color-picker>` }}document.body.append(new ColorPicker());For a <select multiple>, bind an array. Each option whose value is in the array is selected, and the selected options' values are written back as an array of strings:
xxxxxxxxxximport h, {Solarite} from './dist/Solarite.min.js';
class Toppings extends Solarite { picked = ['cheese'];
render() { h(this)` <toppings-list> <select multiple value=${[this, 'picked']} oninput=${this.render}> <option value="cheese">Cheese</option> <option value="olives">Olives</option> <option value="onions">Onions</option> </select> <pre>picked is ${JSON.stringify(this.picked)}</pre> </toppings-list>` }}document.body.append(new Toppings());The most common way to render lists is with JavaScript's Array.map() function:
xxxxxxxxxximport h, {Solarite} from './dist/Solarite.min.js';
class EmojiGarden extends Solarite { plants = ['🌱'];
grow() { let seeds = ['🌷', '🌻', '🌵', '🍄', '🌿', '🌳']; this.plants.push(seeds[Math.floor(Math.random() * seeds.length)]); this.render(); }
render() { h(this)` <emoji-garden> <div style="font-size: 2rem"> ${this.plants.map(plant => h`<span title="plant">${plant}</span>` )} </div> <button onclick=${this.grow}>Grow 🌧️</button> </emoji-garden>` }}
document.body.append(new EmojiGarden());When you push a new plant and call render(), Solarite appends a single <span> instead of rebuilding the whole row. Only the changed elements are touched.
Important: Nested template literals must also have the h prefix, or they'll be rendered as escaped text. Try removing the h before `<span ...>` to see what happens.
Normally each list item runs its .map() callback to build a template, and then Solarite compares that template against the live DOM to find what changed. For very long lists, h.memo() skips both steps for items that haven't changed, similar to Vue's v-memo or Lit's guard(). Give it the loop item, the values its html depends on, and a function that builds its template:
xxxxxxxxxximport h, {Solarite} from './dist/Solarite.min.js';
class UserTable extends Solarite { rows = [{id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}, {id: 3, name: 'Carol'}]; selectedId = 1;
selectNext() { let ids = this.rows.map(row => row.id); this.selectedId = ids[(ids.indexOf(this.selectedId) + 1) % ids.length]; this.render(); }
render() { h(this)` <user-table> <style>:host .selected { background: #fde68a }</style> ${this.rows.map(row => h.memo(row, [row.name, row.id === this.selectedId], row => h`<div class=${row.id === this.selectedId ? 'selected' : ''}>${row.name}</div>` ))} <button onclick=${this.selectNext}>Select next</button> </user-table>` }}
document.body.append(new UserTable());The second argument is the list of values the item's html depends on. Pass one value, or an array of them. h.memo() compares these values against the previous render with === (and shallowly, if it's an array). As long as they all stay the same, h.memo() returns the item's previous template without calling your build function, and the list diff reuses that item's DOM untouched. As soon as one of those values changes, your build function runs again and the item re-renders normally. The same item object must not appear twice in one list.
By default, Solarite matches list items to existing DOM nodes by position, rewriting each changed row in place. That's the fastest option when rows hold no state of their own. But when rows contain form inputs, focus, animations, or components with internal state, add a key attribute so DOM nodes follow their data instead:
xxxxxxxxxximport h, {Solarite} from './dist/Solarite.min.js';
class UserTable extends Solarite { rows = [{id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}, {id: 3, name: 'Carol'}];
reverse() { this.rows.reverse(); this.render(); }
render() { h(this)` <user-table> ${this.rows.map(row => h`<div key=${row.id}>${row.name} <input placeholder="notes"></div>` )} <button onclick=${this.reverse}>Reverse</button> </user-table>` }}
document.body.append(new UserTable());With keys, reordering the rows array moves the existing DOM nodes (using the fewest possible moves), removing a row removes exactly its node, and rows with new keys always get newly created nodes. Anything the user typed into a row's <input> travels with the row.
Rules for key:
It must be a single whole-value expression: key=${expr}. A static value like key="a" or a mixed value like key="a${x}" throws an error.
It must be on a top-level element of its template, and a template can have only one.
Keys are compared with ===; numbers, strings, and object references all work. Keys must be unique within the list.
The key never appears in the DOM, and components never receive key as a constructor or render argument. Don't name component arguments key.
h.memo() and keys compose: memo skips rebuilding unchanged rows' templates, while keys control node identity and movement.
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 data-style attribute on the root element, with a number that increments for each instance of the component.
:host selectors rewritten to the tag name plus that 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: 10px; 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([ {name: 'English', description: 'See spot run.'}, {name: 'Science', description: 'Space is big.'}]);document.body.append(list);Since HTML attributes are case-insensitive, Solarite automatically converts dash-case (kebab-case) attribute names to camelCase when passing them to component constructors. For example, the font-size attribute becomes the fontSize property of the first argument passed to the constructor and to the render() function.
When a parent component renders:
Its render() function executes, typically calling h() to update itself and its children.
For each child web component (whether a Solarite component or otherwise), h() then calls that child's render() method, if it exists.
The child receives its attributes as an object (first argument) and a changed boolean (second argument).
The child then decides whether to call its own h() function to update.
In the example above, creating <notes-item> via new instead of its tag name is discouraged, as it would cause the component to be recreated on every render:
xxxxxxxxxxclass NotesList extends HTMLElement { render() { h(this)` <notes-list> ${this.items.map(item => // Pass item object to NotesItem constructor: new NotesItem({item: item}) // Causes full redraw every time (!) )} </notes-list>` }}The h() function handles template creation, DOM updates, and element instantiation:
xxxxxxxxxximport h from './dist/Solarite.min.js';
// Convert the html to a Solarite Template that can later be used to create nodes.let template = h`<b>Hello ${"World"}!</b>`;let template = h(`<b>Hello ${"World"}!</b>`);
// Convert a template string to an HTMLElementlet el = h()`<b>Hello ${"World"}!</b>`;
// Convert a template string with multiple-top-level nodes to a DocumentFragmentlet el = h()`Hello <b>${"World"}!</b>`;
// h(HTMLElement)`string`// Create template and render its node(s) as a child of HTMLElement el.h(el)`<b>Hello ${'World'}</b>`;The toEl() function converts a string or a template created via the h function into a DOM element. It enforces these rules:
If the html begins with a start tag and ends with an end tag (minus whitespace before or after it), that whitespace is trimmed.
If the HTML contains more than one Node, all nodes will be created with a DocumentFragment as their parent, which will be returned.
Otherwise a single Node will be returned.
xxxxxxxxxximport h, {toEl} from './dist/Solarite.min.js';
let a = toEl('Hello'); // Create single text node.let b = toEl(' <div>Yo</div> '); // Create single HTMLDivElementlet c = toEl('<b>Hi</b><u>Bye</u>'); // Create document fragment as a parent to the Nodes
let template = h`<div>${'Waz'+'up'}</div>`;let d = toEl(template) // Render Template document.body.append(a, b, c, d);
HTML has strict rules about which elements can be children of certain container elements. For example, a <table> can only have specific children like <tr>, <thead>, etc.
If you want to create a custom component to use in these restricted contexts (like a custom <tr> element), you can extend the appropriate native HTML element instead of the generic HTMLElement.
To do this, pass {extends: 'tr'} as the third argument to customElements.define. This is standard, vanilla JavaScript and is not specific to Solarite.
xxxxxxxxxximport h from './dist/Solarite.min.js';
class LineItem extends HTMLTableRowElement { constructor(user) { super(); this.user = user; this.render(); }
render() { h(this)` <td>${this.user.name}</td> <td>${this.user.email}</td>` }}
customElements.define('line-item', LineItem, {extends: 'tr'});
let table = document.createElement('table')for (let i=0; i<10; i++) { let user = {name: 'User ' + i, email: 'user'+i+'@example.com'}; table.append(new LineItem(user));}document.body.append(table);While Solarite handles most updates automatically, you can perform manual DOM operations in these scenarios:
Static Attributes: Modify attributes not created by expressions.
Static Nodes: Add or remove nodes not created by expressions, and not directly adjacent to node-creating expressions.
Temporary Changes: Modify any node if you restore its original state before the next render().
This example demonstrates these rules:
xxxxxxxxxximport h, {toEl} from './dist/Solarite.min.js';
let list = toEl({ items: [],
add() { this.items.push('Item ' + this.items.length); this.render(); },
render() { h(this)`<div> <button onclick=${this.add}>Add Item</button> <hr> ${this.items.map(item => h` <p>${item}</p> `)} </div>` }});
document.body.append(list);
// Set attributes not created by expressions. This is allowed. list.setAttribute('title', 'DOM manipulation demo');list.querySelector('button').setAttribute('title', 'Click me');
// Remove the <hr> element.// This is fine, because the hr element isn't part of an expression.// And isn't adjacent to an expression, because there's a whitespace// node between the <hr> and the expression.// You could also put a comment node between them.list.querySelector('hr').remove();list.render();
// Remove the first <p> element and add it back again.// This is fine, because we put it back the way it was before render()list.add();let p = list.querySelector('p');list.append(p); // put it back.list.render();
// Remove the first <p> element.// This will cause an error because we're modifying nodes created by an expression.// list.querySelector('p').remove();// list.render();The toEl() function (discussed above) can also be given an object with a render() method to toEl(). Properties and methods of the object become bound to the resulting element.
xxxxxxxxxximport h, {toEl} from './dist/Solarite.min.js';
let button = toEl({ count: 0,
inc() { this.count++; this.render(); },
render() { h(this)`<button onclick=${() => this.inc()}>I've been clicked ${this.count} times.</button>` }});document.body.append(button);If you want multiple instances of such an element, the code above can be wrapped in a function:
xxxxxxxxxximport h, {toEl} from './dist/Solarite.min.js';
function createButton(text) { return toEl({ count: 0,
inc() { this.count++; this.render(); },
render() { h(this)`<button onclick=${this.inc}>${this.count} ${text}</button>` } })}document.body.append(createButton('clicks'));document.body.append(createButton('tickles'));This is an experimental feature and is likely to change in the future.
Understanding how Solarite works internally can help you write more efficient components and debug issues more effectively.
Consider this example where we're rendering a list of tasks:
xxxxxxxxxximport h from './dist/Solarite.min.js';
class MyTasks extends HTMLElement { tasks = [];
deleteTask(index) { this.tasks.splice(index, 1); this.render(); }
render() { h(this)` <div> ${this.tasks.map((task, index) => h` <div> ${task} <button onclick=${() => this.deleteTask(index)}>Delete</button> </div>` )} </div>`; }}customElements.define('my-tasks', MyTasks);
let myTasks = new MyTasks();for (let i=0; i<10; i++) myTasks.tasks.push('Item ' + i);myTasks.render();document.body.append(myTasks);When you call render(), Solarite performs these steps:
Template Parsing: The h() function pairs the template literal's static html with its ${...} expression values in a lightweight Template object. The static html is parsed only once, no matter how many items or renders use it: each unique template gets a cached "Shell" of expression-free DOM nodes, plus precomputed paths to where the expressions belong. Whitespace-only text between table tags is dropped since browsers never render it.
Instantiation: New elements are created by cloning the Shell's nodes, then resolving all expression locations in the clone with a single precomputed resolve program that visits each target node once.
Positional Diffing: On re-render, list items are compared positionally against the previous render's items. Expression values are compared by identity (===), so unchanged items are skipped with no hashing or string building. Items created from the same template html are rewritten in place, updating only the expressions whose values changed.
Minimal DOM Updates: A lone primitive expression renders as a bare text node and updates via nodeValue, with no wrapper objects. Attributes are written only when their value changes. Event handlers register one listener per element; re-renders just swap the function it calls. Removed list items are pooled and reused by later renders instead of being rebuilt.
List reconciliation uses a positional two-pointer diff: matching prefix and suffix items are kept, the aligned middle is rewritten in place, and leftovers are removed or batch-inserted with direct DOM operations. When expressions contain raw DOM nodes, Solarite instead falls back to WebReflection/udomdiff to rearrange them with minimal DOM manipulations.
This is the time example from Lit.js implemented with Solarite:
xxxxxxxxxx<script type="module">import h, {Solarite, svg} from './dist/Solarite.min.js';
const replay = svg`<svg enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="currentColor"><title>Replay</title><g><rect fill="none" height="24" width="24"/><rect fill="none" height="24" width="24"/><rect fill="none" height="24" width="24"/></g><g><g/><path d="M12,5V1L7,6l5,5V7c3.31,0,6,2.69,6,6s-2.69,6-6,6s-6-2.69-6-6H4c0,4.42,3.58,8,8,8s8-3.58,8-8S16.42,5,12,5z"/></g></svg>`;const pause = svg`<svg height="24px" viewBox="0 0 24 24" width="24px" fill="currentColor"><title>Pause</title><path d="M0 0h24v24H0V0z" fill="none"/><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>`;const play = svg`<svg height="24px" viewBox="0 0 24 24" width="24px" fill="currentColor"><title>Play</title><path d="M0 0h24v24H0V0z" fill="none"/><path d="M10 8.64L15.27 12 10 15.36V8.64M8 5v14l11-7L8 5z"/></svg>`;
class MyTimer extends Solarite {
constructor(attribs={}) { // super() fills the empty attribs object from the html attributes // when the element is instantiated from regular html // rather than inside a tagged template. super(attribs); this.duration = parseFloat(attribs.duration) || 60; this.end = null; this.remaining = this.duration * 1000; this.render(); }
render() { const min = Math.floor(this.remaining / 60000); const sec = pad(min, Math.floor((this.remaining / 1000) % 60)); const hun = pad(true, Math.floor((this.remaining % 1000) / 10)); h(this)` <my-timer> ${min ? `${min}:${sec}` : `${sec}.${hun}`} <footer> <style> :host { display: inline-block; min-width: 90px; font-size: 30px; text-align: center; padding: 0.2em; margin: 0.2em 0.1em; footer { user-select: none } } </style> ${ this.remaining === 0 ? '' : this.running ? h`<span onclick=${this.pause}>${pause}</span>` : h`<span onclick=${this.start}>${play}</span>` } <span onclick=${this.reset}>${replay}</span> </footer> </my-timer>`; }
start() { this.end = Date.now() + this.remaining; this.tick(); }
pause() { this.end = null; this.render(); }
reset() { this.remaining = this.duration * 1000; this.end = this.running ? Date.now() + this.remaining : null; this.render(); }
tick() { if (this.running) { this.remaining = Math.max(0, this.end - Date.now()); this.render(); requestAnimationFrame(() => this.tick()); } }
get running() { return this.end && this.remaining; }}customElements.define('my-timer', MyTimer);
function pad(pad, val) { return pad ? String(val).padStart(2, '0') : val;}</script><my-timer duration="7"></my-timer><my-timer duration="60"></my-timer><my-timer duration="300"></my-timer>Solarite is actively being developed with several exciting features planned for future releases:
Shadow DOM Support: Optional integration with the browser's native Shadow DOM for true encapsulation of styles and DOM.
JSX Support: Alternative syntax for those who prefer JSX over template literals.
Automatic Rendering: An opt-in feature to automatically re-render components when watched properties change, eliminating the need to manually call render().
Performance Optimizations: Continued improvements to rendering speed and efficiency.
Stay tuned for updates on these features by following the GitHub repository Star.