Solarite

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 (12.4KB with Brotli) and runs straight in the browser as a standard ES module.

Key Features

Installation

Quick Start

Import the module directly from a CDN:

Or install via NPM:

Development Tips

For 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.

Performance

Solarite is faster than almost every well-known framework, according to the js-framework-benchmark. Its score of 1.10 means it's about 10% slower than hand-written vanilla JavaScript. Benchmarks were run on a Ryzen 7 3700X with 16GB RAM on Kubuntu 26.04.

js-framework-benchmark

Core Concepts

Web Components

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.

We can alternatively instantiate the element directly from html:

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.

Rendering

How Rendering Works

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.

Manual Rendering

Unlike many frameworks, Solarite does not automatically re-render when data changes. You call render() when you want the DOM updated. That means you can change as much data as you like without triggering a render, and nothing redraws at a moment you didn't choose.

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:

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().

SVG

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.

By default, expressions render as text, so raw SVG markup in a string expression is escaped and shown as text. Put it in an svg tagged template instead. That also makes a reusable icon: assign the whole template to a constant once and embed it wherever you need it.

These types of values can be used in expressions within h tagged template literals:

  1. strings and numbers.

  2. boolean true, which will be rendered as 'true'

  3. false, null, and undefined, which will be rendered as empty string.

  4. Solarite Templates, which can be created by h-tagged template literals.

  5. DOM Nodes, including other web components.

  6. Arrays of any of the above.

  7. Functions that return any of the above.

Attributes

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:

Expressions can also toggle the presence of an attribute. In the last div above, if isEditable is false, null, undefined, or an empty string, the contenteditable attribute is removed rather than left behind as contenteditable="". Zero and the string "0" are ordinary values and are written normally.

Form elements are the exception, since there an empty string is a real value. value=${''} on an <input> clears the field rather than removing anything, and the same goes for any attribute the element exposes as a property, such as checked.

You can also specify multiple attributes at once using an object, where the keys are attribute names and the values are attribute values:

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.

Id's

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:

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, but only when you run from the source files or from Solarite-debug.js; the check is a development aid and is compiled out of Solarite.js and Solarite.min.js, so render every template at least once during development to be sure you have seen it.

Events

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.

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.

Event Delegation

Solarite delegates bubbling events by default. Instead of calling addEventListener on every element, it listens once per event type at the document level and finds handlers by walking up from the event target. So a data grid with buttons on every row costs zero listener registrations, which makes large lists noticeably faster to create and clear, especially on phones. Your templates don't change:

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 eventDelegation: ['click', 'input'] as a render option to delegate only specific events, or eventDelegation: false to bind every event directly with addEventListener. Pass eventDelegation: 'document' to also register the dispatcher on the document, so handlers keep firing on nodes that another component re-parents outside your component - for example a toolbar that a dock panel moves into its own tab bar. The best design is still to render such content into an element that moves with it, since then no option is needed; 'document' is the escape hatch for content that must be rendered in place and moved by someone else.

A few caveats, all rare in practice: delegated handlers run when the event bubbles up to the component's root element (or the document, with 'document'), so a manually added addEventListener on an element in between fires before them rather than after, and stopPropagation() called from such a manual listener prevents delegated handlers from running. A non-bubbling event dispatched programmatically (dispatchEvent without bubbles: true) won't reach delegated handlers either. Handlers see the correct event.currentTarget in every case. Use eventDelegation: false if any of these matter.

Two-Way Binding

Two-way binding connects your component's data to form elements, keeping them in sync automatically.

Basic Two-Way Binding

Form elements update properties when an event like oninput is assigned a function to handle the change:

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

Shorthand Two-Way Binding

Solarite also provides a shortcut for two-way binding using array syntax: value=${[this, 'count']}:

  1. When render() is called, the input's value is set to this.count

  2. 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.

Form Element Types

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.

ElementBind toProperty type read back
<input> (text, password, email, etc.)valueString
<input type="checkbox">checkedBoolean
<input type="radio">checkedString (the selected radio's value)
<input type="number">, type="range"valueNumber (NaN when empty)
<input type="date">, time, datetime-localvalueDate object (null when empty)
<input type="file">valueArray of File objects
<select>valueString
<select multiple>valueArray of Strings
<textarea>valueString
contenteditable elementvalueString (the element's innerHTML)
Custom component with a value propertyvalueWhatever 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:

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:

Loops

The most common way to render lists is with JavaScript's Array.map() function:

Efficient List Updates

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.

Efficient List Items

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. h.map() skips both steps for rows that haven't changed: each row remembers the item it was built from, and a row still holding the same object is recognized by one identity check — no template built, nothing compared, and its DOM left alone. Re-render a thousand rows because two of them changed, and only those two are looked at.

The comparison is deliberately shallow. Solarite checks the item reference and never looks inside it, which is what makes the check cheap enough to run per row — so to change a row you replace it with a new object rather than editing the one that's there. That's the same contract Solid's <For> and React's keyed lists use, and it keeps the call site a plain list with no caching code:

Rules and costs:

Plain .map() remains the right choice when you mutate rows in place, or when the list is short enough that none of this matters.

Keyed Lists

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:

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:

h.map() and keys compose: h.map() skips rebuilding unchanged rows' templates, while keys control node identity and movement.

Selection

Highlighting the selected row of a table is a special case worth its own tool. Storing the selected id as an ordinary field works, but it means every change of selection calls render(), and the reconciler then has to walk the list to discover that exactly two rows differ. h.selector() skips that: each row's binding remembers the element it was written to, so changing the selection writes those two attributes and nothing else.

when(key, on, off) binds an attribute to whether that row's key is the selected one. It gives the attribute the on value when it is and the off value when it isn't; off defaults to '', which leaves the element with no such attribute rather than an empty one.

Rules for selectors:

A selector is worth reaching for when a change of selection would otherwise re-render a long list. For a short list, or for state that several parts of the template derive from, an ordinary field and a render() call are simpler and fast enough.

Scoped Styles

A <style> element in a component's template is automatically scoped to that component instance, so its rules can't leak out or collide with the rest of the page. Unlike Shadow DOM, styles from the document still reach in.

Internally, scoped styles become:

  1. A data-style attribute on the root element, with a number that increments for each instance of the component.

  2. :host selectors rewritten to the tag name plus that identifier: fancy-text[data-style="1"]. The functional form works too: :host(:focus-within) becomes fancy-text[data-style="1"]:focus-within.

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.

Slots

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.

Basic Slots

Use the <slot> element to define where children should be rendered:

Named Slots

To use multiple slots, give them a name attribute. Assign children to these slots using the slot attribute:

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.

Slotless Components

If a component has no <slot> elements, any provided children are appended to the end of the component by default.

 

Child Components

Passing Data to Child Components

When one web component is embedded within the html of another, its attributes are automatically passed as arguments to the constructor:

Attribute Name Conversion

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.

assignAttributes()

A component can be created three ways, and its values arrive differently each time.

When you create it with new MyTimer({duration: 7}), or embed it inside a tagged template with bindings like h`<my-timer duration=${7}>`, the values keep their original types and arrive in the constructor's fields argument. Here duration is the number 7, so you assign fields directly. These two ways are really the same: both hand the constructor a typed object.

But when you write plain html like <my-timer duration="7">, there is no fields argument. The values live in the element's html attribute, and html attributes are always strings, so duration is the string "7". assignAttributes() reads those attributes onto your component, casting each string to the type you name. It only writes to fields that already exist on the component.

Now all three produce the same result, a duration of 7 (a number) and an autoStart of true (a boolean):

The two sources never collide. A new call or a tagged template passes its values in fields and sets no attributes when the constructor runs, while plain html sets attributes and passes no fields.

Each types entry maps a field name to a converter. Number, Boolean, String, and Date are built in, or you can pass any function that takes the string and returns a value. A Boolean attribute is true whenever it's present, even when bare like auto-start above, and false only for "false" or "0". An attribute you don't name in types is assigned as its raw string. An attribute written like ${...} is JSON-parsed back to its original type. To skip an attribute, pass its field name in the third argument: assignAttributes(this, types, ['duration']).

Component Rendering Hierarchy

When a parent component renders:

  1. Its render() function executes, typically calling h() to update itself and its children.

  2. For each child web component (whether a Solarite component or otherwise), h() then calls that child's render() method, if it exists.

  3. The child receives its attributes as an object (first argument) and a changed boolean (second argument).

  4. 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:

Functions

h()

The h() function handles template creation, DOM updates, and element instantiation:

toEl()

The toEl() function converts a string or a template created via the h function into a DOM element. It enforces these rules:

getEventBinding()

getEventBinding(node, key) returns the binding Solarite registered on an element, or undefined if there isn't one. The key is the attribute name that created it, without any on prefix: 'value' for a two-way binding written as value=${[obj, 'field']}, 'click' for an onclick=${...} handler.

The returned object has a handleEvent(event) method — the same one the browser calls — so invoking it runs the binding immediately. This exists for one specific problem: a two-way binding writes back to your data when the element fires its event, so if you need that value written before something else happens in the same tick, you have to trigger the binding yourself rather than wait for the event.

Type into the field below and click Save without clicking away first. The input event hasn't fired yet, so this.name is still the old value until the binding is flushed:

Most components never need this. Use it only when the ordering within a single tick actually matters.

Advanced Techniques

Extending Native HTML Elements

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.

Manual DOM Operations

While Solarite handles most updates automatically, you can perform manual DOM operations in these scenarios:

  1. Static Attributes: Modify attributes not created by expressions.

  2. Static Nodes: Add or remove nodes not created by expressions, and not directly adjacent to node-creating expressions.

  3. Temporary Changes: Modify any node if you restore its original state before the next render().

This example demonstrates these rules:

Non-Component Elements

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.

If you want multiple instances of such an element, the code above can be wrapped in a function:

This is an experimental feature and is likely to change in the future.

JSX

Solarite components are normally written with h tagged templates, which need no build step. But Solarite also supports JSX. Your code is identical regardless of what tool compiles the JSX:

A piece of JSX produces a Solarite Template — the same render-ready value an h tagged template returns, which you hand to h(this, ...) to render. So everything else in Solarite — render(), lists, events, two-way binding, child components — works exactly the same way.

A few rules:

Setup

JSX has to be converted to JavaScript by a build tool. Your JSX code is always the same; only the build tool's configuration changes. The choice of tool also affects speed — see Speed below.

Deno — nothing to install, and full runtime speed (Deno precompiles JSX itself). Just add this to deno.json:

Vite, esbuild, or Babel — these tools don't precompile JSX on their own, so add the matching build-time plugin for the same full runtime speed as Deno. Each plugin only runs while your project is being built; it is never sent to the browser, so it adds nothing to your bundle.

Any tool, nothing extra to install — just point the tool's built-in JSX setting at Solarite. This works with TypeScript, esbuild, Vite, and similar tools, but runs a little slower (see below).

Speed

How fast your JSX runs depends on which setup above you picked:

If you can use Deno or the plugin, do. Otherwise the no-setup option is perfectly fine for most apps.

Components

You can use your own components as JSX tags:

How Solarite Works

None of this is needed to use Solarite, but it explains why some patterns are faster than others.

Efficient Rendering Algorithm

Consider this example where we're rendering a list of tasks:

When you call render(), Solarite performs these steps:

  1. 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.

  2. 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.

  3. Diffing: When render() is called, each list item is matched to the item that drew the DOM already sitting in that spot — by position for a plain list, by key=${...} for a keyed one, or by object identity for h.map(). Matching is done with === comparisons, so nothing is hashed and no html is built to compare against. A matched item whose template html is unchanged is rewritten in place, updating only the expressions whose values actually differ, and an item that matched exactly is skipped entirely. See DOM Diffing below for how each strategy handles items that moved, appeared, or vanished.

  4. Minimal DOM Updates:

    1. A lone primitive expression renders as a bare text node and updates via nodeValue, with no wrapper objects.

    2. Attributes are written only when their value changes.

    3. Event handlers register one listener per element; re-renders just swap the function it calls.

    4. Removed list items are pooled and reused by later renders instead of being rebuilt.

DOM Diffing

Solarite picks one of three strategies for a list, based on what the expression holds.

An unkeyed list 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.

A list whose items carry key=${...} is instead matched by key, so a row's DOM follows its data when the list reorders. The prefix and suffix scans work the same way, the remaining window matches through a key map, and rows outside a longest increasing subsequence of their old positions are the only ones moved — which is the fewest node ranges that can produce the new order. A short reorder such as a swap or a dragged row skips the map entirely and cross-matches the handful of affected rows against each other.

A list built with h.map() trades depth of comparison for speed. It checks only whether each position still holds the very same object it was drawn from, using a single === against the item, never looking inside it. A row that passes is left alone without building a template or comparing anything. When only a few positions fail that check, those are patched and the rest of the list is never visited, so the cost tracks what changed rather than how long the list is. The price of the shortcut is that a row mutated in place looks unchanged. To get it to redraw, replace the object instead.

When an expression contains raw DOM nodes, none of those apply, because Solarite tracks its own node groups rather than nodes you hand it. Those fall back to a general pass that removes the nodes no longer present and then walks the new list back to front, inserting only the nodes that aren't already in their target position.

Examples

This is the time example from Lit.js implemented with Solarite:

Possible Upcoming Features

  1. Shadow DOM Support: Optional integration with the browser's native Shadow DOM for true encapsulation of styles and DOM.

  2. Automatic Rendering: An opt-in feature to automatically re-render components when properties change, eliminating the need to manually call render().

Follow the GitHub repository Star.