npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@verajs/renderer

v0.2.2

Published

Reef JS renderer repurposed as lightweight renderer option for use with VeraJS

Downloads

916

Readme

@verajs/renderer

The DOM renderer for VeraJS — 4.58 KB gzipped, no dependencies, no build step required.

Tagged templates parse once and clone; every render after the first walks only the value slots, so updates touch the DOM and nothing else. Lists are keyed by value, not by directive. Server-rendered pages hydrate through a separate entry that non-SSR apps never download.

npm i @verajs/core @verajs/renderer

Quick start

wire([renderer]) is the only wiring. Core's html tag already produces the shape this accepts, so there is no second call to make.

import { init, createStore, render, wire, html } from '@verajs/core';
import { renderer } from '@verajs/renderer';

wire([renderer]);

customElements.define(
  'click-counter',
  class extends HTMLElement {
    connectedCallback() {
      init(this, { mode: 'open' });
      const state = createStore({ count: 0 });
      render(() => html`<button @click=${() => state.count++}>Clicked ${state.count} times</button>`);
    }
  }
);

document.body.append(document.createElement('click-counter'));

Without it, core has no renderer at all: render() warns once in development and puts nothing on the page. @event, .prop and ?bool bindings are the first things to go missing.

renderInto(result, container)

The same write, called directly — no reactivity, no lifecycle, no custom element. wire([renderer]) registers exactly this function, so a component's render() ends up here; calling it yourself is how you draw into a plain node, which is the incremental-adoption path and the way to test a template without spinning up an element.

import { renderInto } from '@verajs/renderer';
import { html } from '@verajs/core';

renderInto(html`<p>${count}</p>`, document.querySelector('#app'));

It owns its own range and nothing else: the first call anchors a root part at a marker it appends, later calls with the same container reuse that part and walk only the value slots, and whatever was already in the container stays. It is not reactive — call it again to update, or use a component and let a store do it.

It was named render until 0.2.0, which collided with core's render — a different function, with a different arity, that declares a reactive template and commits a component's setup. Both are public and both are documented, so a reader who knew one misread the other.

Bindings

| Written | Means | | --- | --- | | <p>${value}</p> | child content — see Values | | <p title=${value}> | attribute. null/undefined remove it | | <p class="a ${b} ${c}"> | attribute built from several expressions and the static text between them | | <x-item .item=${value}> | property assignment, uncoerced — objects, arrays, functions | | <p ?hidden=${value}> | boolean attribute, present when truthy | | <input !checked=${value}> | property written from the live DOM rather than from what the binding last wrote — see below | | <button @click=${fn}> | event listener. An object with a handleEvent method works too — addEventListener takes both | | <button onClick=${fn}> | the same thing, React-style. Strictly on + a capital — a template-written onclick stays a plain attribute (yours, greppable); spread refuses it (see the spread section's security note) | | <input ${fn}> | element ref: a function is called with the element | | <input ${obj}> | element ref: an object gets the element assigned to .value, so core's ref() works here | | <input ${spread(props)}> | names resolved at runtime — see /spread |

A ref runs once per distinct value, not once per render.

A ref is released when its subtree is rendered away, and not when its component is removed from the document. Toggling ${show ? html<i ${box}> : 'gone'} sets box.value back to null (and calls a function ref with null); removing the whole component leaves box.value pointing at the element, now detached. That is deliberate rather than an oversight: here a disconnect is not a destruction — moving a node between parents fires one, and the component re-renders on reconnect — so releasing would blank every ref for the frame it takes a move to complete. Guard with box.value?.isConnected if you need to know, and note that a ref created inside the component (the way every example writes it) becomes garbage along with it either way.

An event handler is called with the element as this, and the listener is registered once — swapping the handler across renders never touches the DOM, so there is no add/remove churn and no way to end up with two. undefined, null and false all mean no handler, so @click=${enabled && onClick} binds conditionally without a ternary. Anything else that cannot listen — a string, a number, an object with no handleEvent — is inert, and development names it at the binding: a listener is only ever called when a user clicks, so left unchecked the mistake surfaces at a point where nothing on the stack says where the value came from.

!name — a live property

Every other binding skips a write when the value matches what it last wrote. That is what keeps a field someone has typed into, and it is exactly wrong for a control whose DOM state changes as a side effect of interacting with a sibling:

html`<input type="radio" name="pick" !checked=${state.picked === 'one'} />
     <input type="radio" name="pick" !checked=${state.picked === 'two'} />`

Clicking the second radio unchecks the first in the DOM, with no event on it. With .checked the first binding still says true, still matches what it committed, and never writes again — the model and the page diverge and no amount of re-rendering reconciles them. A <select>'s options are the same shape.

It is deliberately narrow, and it is a property binding only:

  • Not for text inputs. Bind those with .value and let a person's typing stand. !value exists and is authoritative, which is precisely why it is not the default.
  • Not offered for attributes or booleans. Nothing changes those behind the renderer's back, so there is nothing to re-read.
  • It yields during hydration. A click that happened before the bundle landed had no handler to report it, so adoption records the value without writing; live semantics resume on the first state-driven render.

spread({ '!checked': … }) means the same thing, and @verajs/ssr serializes it exactly as .checked — a server has nothing to re-read.

Bindings inside comments are not supported — the value is consumed and ignored. A dynamic tag name is a binding a template cannot express either, and has its own entry: /tag.

Values

What a child position does with each kind of value. These match lit-html exactly, null and undefined included.

| Value | Renders | | --- | --- | | string, number, 0 | as text | | true, false | as text — and development says so; see below | | null, undefined | nothing | | a template result | the template, updated in place while its shape holds | | an array or iterable | each entry in order; key them with keyed() | | a DOM node or fragment | itself, moved into place | | anything else | String(value) |

Strings render as text, always. There is no path by which an interpolated value becomes markup — see Trusted HTML.

A boolean child renders the word, and JSX is the exception

${items.length > 0 && html\…`}is the ordinary conditional idiom, and when the test fails the whole expression isfalse` — which renders the text "false" on the page. The value is legitimate and nothing throws, so development names it at the binding rather than leaving it to be found by looking at the page. Write the form that renders nothing:

html`<p>${cond ? html`<em>yes</em>` : null}</p>`     // or: ${(cond && html`…`) || null}

In JSX the same code needs no change@verajs/jsx compiles a boolean child away, which is React's rule and where React expectations live. That is the one value semantic on which JSX and a hand-written template differ, and this warning is what meets JSX-shaped code pasted into a template. 0 still renders in both, exactly as in React: the rule is about booleans, not falsiness.

A DOM node renders as itself, which is how a template holds something another library owns:

const chart = document.createElement('canvas');
new Chart(chart, config);

renderInto(html`<figure>${chart}<figcaption>${title}</figcaption></figure>`, host);

Lists — keyed()

import { renderInto } from '@verajs/renderer';
import { keyed } from '@verajs/renderer/keyed';

renderInto(html`<ul>${rows.map((row) => keyed(row.id, html`<li>${row.label}</li>`))}</ul>`, host);

keyed(key, result) tags a result with its identity, so a reorder moves the existing elements instead of rebuilding them — focus, scroll position, form state and running animations all survive. It is its own entry because most apps never reorder a list, and the algorithm that makes reordering cheap is 923 B gzipped they would otherwise carry. Importing keyed is the whole installation: nothing registers, and there is no wire() call — the marker stamps each result with the strategy that understands it, so a list always names its own reconciler and two strategies cannot disagree about one. Lit splits repeat out for the same reason; the difference is that this one arrives on the values rather than through a directive protocol.

Keep it on the same version as @verajs/renderer. It reaches the renderer through a handful of two-character members that are exempt from property mangling, and nothing checks that both sides agree about them — a keyed bundle paired with a different renderer release fails at runtime rather than at install. Both ship from this package and bump together, so a single version range covers it; the trap is pinning one and floating the other. @verajs/renderer/spread carries the same rule for the same reason.

It is additive, not a substitute. Unlike /hydrate and /profiler, this entry imports nothing at all — it reaches whatever renderer is present through a handful of mangling-exempt members — so it is safe alongside any of them, /hydrate included.

A list is keyed because keyed() marked it, not because its results have a key property. Setting .key by hand no longer makes a list keyed — the marker is what carries the algorithm, so it is what the renderer looks for, on the client and when adopting server markup alike.

Key every item in a list, or none of them. A list is keyed when its first item is, and an unkeyed item in a keyed list has no identity to match on.

An unkeyed list is not wrong — it updates each position in place, which is exactly right for a list whose order never changes.

Preserving DOM — hold()

renderInto(html`<div>${hold(editing ? editor(state) : viewer(state))}</div>`, host);

hold(result) parks the DOM it replaces instead of destroying it, keyed by template identity, and brings it back when that template returns. lit calls this cache. What survives is everything no attribute records: what the user typed, a checkbox or radio the user set, a <select>'s chosen option, a <details> left open, a media element's playback position — and the nodes themselves, so the element that had focus is the same element when it comes back.

A scroll offset does not survive, and cannot. Every engine resets scrollTop to zero the moment an element leaves the document — measured on Chromium, Firefox and WebKit, which report 0 while parked, 0 on return, and 0 even for a node moved directly between two attached parents. Nothing an applier does with the nodes can hold it, and lit's cache() cannot either. If a scroll position matters, read it before the toggle and restore it after.

Anything that is not a template passes straight through — there is nothing to park for a string, a list, null or false — so hold(editing && editor()) is safe to write.

It only re-adopts a template it has seen at that same call site — two hold() calls in different templates are two different templates, and neither adopts the other's DOM. The other direction is also safe, and reads less obviously from "call site": every keyed row of a list shares one call site, and each still holds its own state — the cache rides the row's own part, so two rows toggling through the same hold never adopt each other's parked DOM, including through a reorder while one is parked. Measured, and pinned in the combination matrix.

It keeps every shape it has parked, for as long as the part lives. That is the point — a tab strip of twelve panels holds twelve, and each comes back exactly as it was left — but it is a cache with no eviction, so hold over an unbounded set of templates retains an unbounded amount of DOM. Verified: fifty distinct shapes cycled through one hold and the first still re-adopted its own nodes. lit's cache() behaves the same way, and for the same reason — evicting would silently throw away the state the applier exists to preserve, which is worse than holding it. Use hold for a set you can name, and let an unbounded one rebuild.

Write stable shapes

Rendering the same elements every pass and toggling hidden is faster than swapping one subtree for another. Template identity holds, so values update in place rather than the subtree being torn down and rebuilt. Both forms are correct; this one is cheaper.

// fragile — two sibling parts, each swapping between a template and ''
html`<section>
  ${items.length === 0 ? html`<p>empty</p>` : html`<ul>${rows}</ul>`}
  ${busy ? html`<p>loading</p>` : ''}
</section>`;

// preferred — one shape, visibility toggled
html`<section>
  <p ?hidden=${items.length > 0}>empty</p>
  <ul ?hidden=${items.length === 0}>${rows}</ul>
  <p ?hidden=${!busy}>loading</p>
</section>`;

/profiler exists to make the difference visible: it counts templates committed in place against templates that replaced a different template.

Entries

| Import | What it adds | Ships in production | | --- | --- | --- | | @verajs/renderer | the renderer | yes | | @verajs/renderer/hydrate | a superset whose first render adopts server-rendered DOM | yes | | @verajs/renderer/keyed | keyed(key, result) — keyed list reconciliation | yes | | @verajs/renderer/spread | spread(props) — binding names resolved at runtime | yes | | @verajs/renderer/tag | tag`h1` — an element whose tag name is decided at runtime | yes | | @verajs/renderer/slots | slots (wire it) + slotted(host, name?) — light-DOM <slot> distribution | yes | | @verajs/renderer/profiler | a superset that measures template churn | no — development only |

/hydrate and /profiler each re-export the whole public API, so they are drop-in replacements for the base import. Never mix two of them in one app — that loads two renderers with two template caches.

Which means an app can have one of them, not both — so a hydrating app cannot be profiled. They each bundle their own renderer with its own instrumentation hook, so profiling while rendering through /hydrate observes an instance nothing renders into: measured, three renders reported zero frames while the page updated correctly. formatReport says so when it observed nothing, because a zero report is otherwise indistinguishable from an app with nothing to optimise.

@verajs/renderer/slots — light-DOM slots

Native <slot> needs a shadow root. This entry teaches the renderer to distribute a light-DOM component's own children into the <slot name="…"> positions of its template, so one component works in both modes — users write <div slot="title"> exactly as they would against shadow DOM.

Wire it at your app entry, before anything renders. A template resolves this once, at construction, and is interned per call site for the life of the page — so wiring slots after a component has already rendered does not reach that component, and it keeps showing its fallback content while the children the host was given sit beside it as stray markup. Development names it when it happens, but the rule is cheaper than the diagnostic: wire it in the same call as the renderer, wire([renderer, slots]).

Given this markup:

<my-card>
  <h2 slot="header">Hello</h2>
  Body text goes to the default slot.
</my-card>
import { init, render, wire, html } from '@verajs/core';
import { renderer } from '@verajs/renderer';
import { slots, slotted } from '@verajs/renderer/slots';

wire([renderer, slots]);

customElements.define(
  'my-card',
  class extends HTMLElement {
    connectedCallback() {
      init(this); // LIGHT DOM — no shadow options
      render(
        () => html`<article>
          <header><slot name="header" @slotchange=${(e) => this.onHeader(e.target)}>Untitled</slot></header>
          <main><slot>Nothing here yet.</slot></main>
        </article>`
      );
    }
    /** Written exactly as it would be against a shadow root, and it runs in both. */
    onHeader(slot) {
      console.log('header is now:', slot.assignedElements().map((el) => el.textContent));
    }
  }
);

const card = document.createElement('my-card');
card.innerHTML = '<h2 slot="header">Hello</h2>Body text goes to the default slot.';
document.body.append(card);

await new Promise((resolve) => requestAnimationFrame(resolve));
console.log(card.querySelector('header').textContent); // "Hello"
console.log(card.querySelector('main').textContent); //   "Body text goes to the default slot."
console.log(slotted(card, 'header').length); //           1

slots is the insert descriptor you wire; that is all a consumer touches.

Wire the renderer as a MODULE — wire([renderer, slots]) — not as a bare function. Both spellings register a renderer, and only the module form runs the descriptor's connect(), which is how the renderer receives the app's insert registry and therefore how it finds this seam at all. wire({ on: 'render', fn: renderInto, priority: 50 }) leaves that registry unset, so every <slot> renders its fallback and nothing says why. (That spelling is documented for wiring a DIFFERENT renderer — lit-html — where there is no seam to find.) The assignment follows the platform's own rules — elements to the slot their slot attribute names, text to the default slot, fallback shown only while a slot is unassigned and restored when it empties, direct children only. Live: appending, removing, or re-slotting children redistributes automatically, with one documented divergence — see Late children below. Re-renders leave slotted nodes in place, identity intact, so focus and input values survive; SSR emits already-distributed markup and hydration adopts it.

A <slot> inside another slot's fallback works, and takes over at the moment that fallback becomes visible — the same thing the platform does, verified by layout on three engines. Slots are handed over in document order, so a slot's nested slots are taken over first, while they still have a parent to anchor into.

::slotted() is shadow-only and deliberately not translated. In light DOM the slotted content is in the same tree, so an ordinary descendant selector reaches it — and reaches deeper than ::slotted() can, which only ever matched top-level assigned nodes. Measured on three engines, the two are exactly complementary: neither spelling reaches across. A component that renders both ways writes both, ::slotted(img), [part="body"] img, exactly as :host, :scope was needed before :host became translatable.

The asymmetry with :host is the reason one is translated and this is not. :host styles the component ITSELF and nothing else can supply it, so a component from npm that uses it is visibly broken in light mode. ::slotted() styles the USER'S content, which in light DOM the page author can already reach — so the failure is a missing default rather than a broken component, and making it work would mean writing framework attributes into the user's own markup.

Late children

A node added AFTER the first render joins its slot with full native semantics — bare text and attribute-less elements included, in document order, exactly as a shadow root would assign them. The renderer stamps everything it emits at a light host's top level with a hidden, non-enumerable property, so an unstamped node there is knowably the user's; slot=""/slot="name" still route, they are simply no longer required.

This used to be the feature's one documented divergence, and the rule existed because a light host's children after the first render are also the component's own rendered output, with nothing in the DOM to tell them apart. Ownership is written down now rather than inferred from position, so the ambiguity — and the rule — are gone. Two things remain worth knowing: whitespace appended to a light host suppresses the default fallback, which is parity (a shadow root does the same), and re-slotting a node (slot="a""b") rejoins in light-tree order rather than arrival order. Every position you can reach orders exactly as the platform does — what light has fewer of is positions you can name, since a distributed child is no longer a direct child of the host.

Cloning a RENDERED light component does not work, and cannot. cloneNode(true) copies a light host's children — which after a render are the component's own output with the user's slotted nodes already distributed into it. The clone then captures all of that as its slot content, so its rendered tree ends up nested inside its own default slot. A shadow component clones cleanly for the opposite reason: cloneNode does not copy a shadow root, so the clone re-renders and its light children are still just its light children.

Nothing can detect this — the ambiguity is the same one behind the late-children rule above, and the original user content was consumed at the first render, so there is nothing to recover. To duplicate a component, clone the SOURCE markup and let the copy render itself, rather than cloning a live instance. This matters most to anything that duplicates components as an operation: an editor canvas, a repeater, a drag-to-copy.

slotted(host, name?) is the component-internal accessor — what the user assigned to a slot, answered identically in shadow mode (native assignment) and light mode (the capture map). Omit name for the default slot. Component authors reach for this; app users do not.

The <slot> element is still the component's handle on the slot

A slot is not only a position, and a component that keeps up with what it was given binds to the element itself. All of that means here what it means in a shadow root:

render(() => html`<header>
  <slot name=${section} &ref=${(el) => (this.slot = el)}
        @slotchange=${(e) => wire(e.target.assignedElements())}>Nothing yet</slot>
</header>`);
  • @slotchange fires on first assignment and on every change after it — and only then, so a child added without a slot attribute fires nothing. The sequence and each event's assignedNodes() are asserted against real shadow DOM, which is the oracle for this. It fires for a slot whose rendering is currently displaced too (assignment is independent of rendering), exactly as native does for a slot that is not on screen.
  • node.assignedSlot answers null — ask the slot, not the node. The reverse lookup is a platform accessor tied to real shadow assignment, and overriding it on YOUR nodes is an intrusion this module refuses. The forward reads carry the same fact: slot.assignedNodes().includes(node) through a &ref, or slotted(host, name) from outside.
  • Bind slotchange directly — it does not bubble to the host. In a shadow root one listener on the root hears every slot by bubbling; here the slot handles are deliberately out of the document, so there is no tree for the event to climb and host.addEventListener('slotchange', …) hears silence. Use @slotchange on each slot, which also hands you the right event.target. (The same boundary as querySelector('slot'): fewer places to listen, not fewer events.)
  • assignedNodes(options) / assignedElements(options) answer from the live assignment, through event.target or a &ref. With nothing assigned, { flatten: true } gives the fallback actually on screen — slottables only, so a comment you wrote into fallback content is not in it, and a nested slot flattens through to whatever it is showing, both as the platform does.
  • Use child.before(node) / child.after(node), never host.insertBefore(node, child). A light host's children are physically moved into the slot they are assigned to, so a child you appended and kept a reference to is no longer a direct child of the host — and insertBefore throws NotFoundError when its reference node is not a child of the node it is called on. In a shadow root the same line works, because there nothing moves. before()/after() route through the node's own current parent, so they are correct in both modes, and the content lands in the order you asked for. This is the sharpest difference between the two modes and the easiest to hit.
  • A displaced node is disconnected here; native slotting never disconnects it. Shadow distribution is virtual — a slottable no slot names stays in the light tree, connected, merely unrendered. Light slots park it physically, so a custom element inside displaced content runs its disconnectedCallback (and a Vera component's effect cleanups) on the way out, and connectedCallback again when its slot returns — with its element identity, stores and typed-in state intact, and reactivity re-established by the re-init. Component authors already handle this pair for any appendChild move; the difference is only when it happens: a component that pauses a video on disconnect pauses while displaced here and keeps playing in a shadow root. The slot element is deliberately not in your DOM (see below), so it is unreachable by selector and reports isConnected === false. It is a live API object, not a position in the tree.
  • name can be a binding. <slot name=${section}> routes by the name it actually has, and re-routes if it changes between renders.

Keeping the <slot> in the tree — a strategy you can own

Every boundary above has one cause: the shipped strategy removes the <slot> element so your markup stays exactly what you wrote. The seam it registers through is public, single-registrant, and takes whole strategies — so if you would rather have the shadow tree's own structure (the slot element present, selectable, :first-child-countable, made layout-invisible by the same display: contents the UA stylesheet gives real slots), you can wire a strategy that keeps it. This one is complete enough to run — and it runs, in CI, as written:

import { init, render, wire, html } from '@verajs/core';
import { renderer } from '@verajs/renderer';

/** Distribution that KEEPS the <slot>: content moves INSIDE it, fallback shows when it is empty. */
const slotsInTree = {
  name: 'my-app/slots-in-tree',
  on: 'slot',
  priority: 50,
  fn(slot, root, name) {
    if (root.nodeType !== 1) return null; // a shadow root keeps native slotting
    const host = root;
    const fallback = [...slot.childNodes];
    const isMine = (n) =>
      n.nodeType === 1 ? (n.getAttribute('slot') ?? '') === name
        : name === '' && n.nodeType === 3 && n.data.trim() !== '';
    let shown = -1;
    const fill = () => {
      for (const n of [...host.childNodes]) if (isMine(n)) slot.append(n);
      const assigned = [...slot.childNodes].filter((n) => !fallback.includes(n));
      for (const n of fallback) (assigned.length ? n.remove() : slot.append(n));
      observer.takeRecords(); // our own moves are not the user's
      if (assigned.length !== shown) {
        shown = assigned.length;
        slot.dispatchEvent(new Event('slotchange', { bubbles: true })); // real ancestors — it CLIMBS
      }
    };
    const observer = new MutationObserver(fill);
    fill();
    observer.observe(host, { childList: true, subtree: true });
    return { _$park$: () => { observer.disconnect(); for (const n of [...slot.childNodes]) if (!fallback.includes(n)) host.append(n); } };
  },
};

wire([renderer, slotsInTree]);
document.head.insertAdjacentHTML('beforeend', '<style>slot{display:contents}</style>');

customElements.define('tree-card', class extends HTMLElement {
  connectedCallback() {
    init(this);
    render(() => html`<article><slot name="header">Untitled</slot></article>`);
  }
});

const card = document.createElement('tree-card');
card.innerHTML = '<h2 slot="header">Hello</h2>';
document.body.append(card);
await new Promise((resolve) => requestAnimationFrame(resolve));

/** Every documented boundary of the shipped strategy, working: */
let heard = 0;
card.addEventListener('slotchange', () => heard++); // a HOST-level listener — bubbling exists here
if (!card.querySelector('slot')) throw new Error('querySelector finds the slot');
if (card.querySelector('h2').parentElement.localName !== 'slot') throw new Error('reverse lookup');
card.querySelector('h2').remove();
await new Promise((resolve) => setTimeout(resolve, 0));
if (card.querySelector('article').textContent !== 'Untitled') throw new Error('fallback returns');
if (heard !== 1) throw new Error('slotchange bubbled to the host');

The trade is the one the platform itself makes: this is the shadow tree's structure, so the <slot> now appears in your host's serialized markup (as it appears in a shadowRoot's), your component CSS can select it — and structural selectors written against the template see it as the child it is, because selectors follow the tree, not layout. Wire it instead of slots — the seam is single-registrant, and wiring both says so in development, by name.

What this recipe deliberately does not do is the audited module's territory: light-tree ordering under re-slots and prepends, duplicate-name handover, nested slots in fallbacks, dynamic name=${…}, SSR and hydration. It is a starting point you own, not a drop-in peer — the measured design for a full sibling lives with the maintainers.

The one thing a light-DOM slot cannot carry is presentationclass, style, id and other plain attributes. A light host has no second tree, so the slot element is not rendered and there is nothing for them to apply to, while in a shadow root they do apply. Put them on a real element around the slot. Development builds say so, naming the attribute, rather than leaving it to be found.

Additive like keyed/spread: it imports no renderer and reaches the one present through the wired seam, so it is safe beside any renderer entry on a CDN page. The entry is 3.35 KB gzipped and only apps importing it pay; @verajs/renderer itself carries just the seam that records where a template's slots are. It is also Node-safe — it imports nothing and touches no global document — so a universal app can wire it on both sides.

@verajs/renderer/hydrate

import { renderer } from '@verajs/renderer/hydrate';   // instead of '@verajs/renderer'

Same name, same wire([renderer]), different entry — this one's renderer binds the adopting render below, so swapping the import (or the importmap target) really is the only change.

The first render into a container that already has children adopts them as server output of the same template: node identity is preserved, listeners attach, and updates mutate the adopted nodes. Hydration here is markerless — server HTML carries no framework comments, and the client repairs its own anchors into the adopted DOM.

Any disagreement with the server markup clears the container (keeping <style data-vm-sheet="styles"> tags) and renders fresh, so correctness never depends on the server output being right. A DOM node at a child position is the one thing the server cannot have rendered; it is inserted without giving up adoption of everything around it.

A fallback warns in development, naming the first place the two renders disagreed"expected <p> and found <div>", "<ul> contains <li>, which the template does not describe". The page is correct either way, which is the point of the fallback and also why it needs saying: that container's markup was just thrown away, and with nothing observable to notice, the only symptom is a first paint that is slower than the one you paid a server render for. An attribute that disagrees is simply re-set during adoption and is not a fallback at all.

A fallback costs one container, not the page. Adoption is decided per container, so components hydrating into their own roots are independent: one that disagrees rebuilds and warns, and every other one keeps the server's nodes. Measured in tests/hydrate-mismatch.test.mjs — three containers, one mismatch, one warning, two adoptions. Worth stating because the warning used to imply otherwise and sent the reader hunting for a page-wide cause.

Comments are outside the comparison, in both directions — a comment in your template is not required in the server markup, and one in the server markup that your template does not have is not a disagreement. A comment renders nothing, so neither direction changes what a reader sees; matching on them would only cost every commented template its adoption.

On a CDN page, point the import map's @verajs/renderer at vera-renderer-hydrate.min.js and nothing else changes. Apps that never hydrate download none of this.

Importing this in Node

@verajs/renderer needs a DOM to be imported at all, not merely to render. It captures document at module scope and builds two shared TreeWalkers there, which is what saves an allocation per instance — so import '@verajs/renderer' on a server throws ReferenceError: document is not defined before any of your code runs. The same is true of /hydrate and /profiler, and of @verajs/jsx/standalone, which contains a renderer.

This is worth stating because @verajs/router documents the opposite about itself, and the asymmetry is easy to read the wrong way. The rest of the family is Node-safe: @verajs/core, @verajs/inserts, @verajs/store and /collections, @verajs/router, @verajs/styles, @verajs/autoloader, @verajs/jsx (the transform), @verajs/ssrand @verajs/renderer/keyed, /spread and /tag, which hold no DOM of their own even though their parent entry does.

In a universal app, hand the renderer in rather than importing it in shared code — which is what examples/kitchen-sink/wiring.js does, taking it as a parameter so the server can pass null. tests/node-import-safety.test.mjs holds both halves of that list.

@verajs/renderer/spread

Spread a props object onto an element, with names resolved at runtime.

import { init, createStore, render, wire, html } from '@verajs/core';
import { renderer } from '@verajs/renderer';
import { spread } from '@verajs/renderer/spread';

wire([renderer]);

customElements.define(
  'x-field',
  class extends HTMLElement {
    connectedCallback() {
      init(this, { mode: 'open' });
      const state = createStore({ disabled: false });
      const props = {
        id: 'email',                                  // attribute
        placeholder: '[email protected]',               //   "
        '.value': '',                                 // property
        '?disabled': state.disabled,                  // boolean attribute
        onInput: (e) => console.log(e.target.value),  // event — @input works too
      };
      render(() => html`<input ${spread(props)} />`);
    }
  }
);

document.body.append(document.createElement('x-field'));

Keys carry the same sigils as written bindings, so a spread key and a written binding mean the same thing — .value, ?disabled, @click, onClick, and &ref for an element ref.

A key that cannot be written into markup is skipped, with a warning in development. That is any name holding whitespace, a quote, <, >, /, =, a backtick or a control character. Engines are more permissive than markup — measured across Chromium, Firefox and WebKit, setAttribute accepts ", ' and < — but a name that binds in the browser and cannot survive server rendering is worse than one that works nowhere, so both sides apply the same rule. Skipped rather than thrown: the keys are runtime data, and one bad name in a props bag should not cost the render.

Several spreads on one element are supported — each element position owns its own keys, so <div ${spread(a)} ${spread(b)}> works and neither releases the other's bindings.

Keys are strings carrying sigils, so TypeScript cannot check them against the element's attributes. That is a genuine step down from written bindings, and the trade for names that are not known until runtime.

props() — a typed bag of property bindings

For the common case where every key is a property, props() removes both the sigils and the typing gap:

import { props } from '@verajs/renderer/spread';

html`<calendar-day ${props({ date, events })}></calendar-day>`
<calendar-day {...props({ date, events })}></calendar-day>
<calendar-day date={date} events={events} />   // JSX only: bare props ARE props on a component tag

One function, both surfaces — JSX compiles {...x} to spread(x), and spread() recognises an already-branded result, so the two spellings are literally the same call. In JSX the bag is optional altogether: on a dash-named tag a bare prop compiles to the .name binding directly (@verajs/jsx's README has the two attribute carve-outs), so props() is the template's spelling and the bag for names not known until runtime. It exists because an attribute is always a string: an array, a Date or a store can only reach a custom element as a property — and while vera's JSX accepts the sigil spelling .date={d}, TSX's type-checker refuses it (TS1003), so the bag is the typed path.

Three rules, each earned:

  • Keys are property names, never sigilsprops({ date }) binds .date. Events and boolean attributes keep their own spellings (@click/onClick, ?disabled); this bag is properties only, by definition.
  • Prefer keys spelled conditionally over bags that change shape: props({ date: loaded ? date : null }) — key present from the first render — over props(loaded ? { date } : {}). Both work (a key that appears later on a component is adopted live, and one that disappears restores what the element held), but a stable shape means values update in place, the same reason templates prefer ?hidden over swapped subtrees.
  • A type argument makes the bag checked: props<CalendarDay>({ dat }) is a compile error naming the misspelling — the checking that sigil-keyed spread genuinely cannot have.

How a component receives them

Nothing to declare on the other side — no static properties, no props argument. A component that calls init() finds every bound property on this, reactively: reading this.date in a render tracks it, the parent's next commit re-renders, and a store or ref() passed through stays live because it arrives by identity. The full reception contract — including what happens when the component's module loads after the parent rendered — is documented with init() in @verajs/core's README; this package's half is the delivery:

  • A property is not an attribute. date="…" is an attribute — always a string, visible in markup. .date=${…}/props({ date }) is a property — any value, by identity, invisible to getAttribute. Components receive properties; attributes are for CSS hooks and static markup.
  • Delivery survives lazy definition. A property bound before the element's module runs would otherwise be destroyed by the class's field initializers at upgrade (at ES2022, item?: T emits a real item; that runs during upgrade). The renderer records what nothing received yet, and init() re-applies it — so a bound value outranks a class default, in both field spellings. Elements that never call init() keep the development warning and the declare advice instead.
  • Platform and foreign elements are untouched. A property with an accessor anywhere — .title, a Lit-style element, anything that already receives it — is delivered plainly and never recorded.
  • SSR delivers, never serializes. Under @verajs/ssr, a property bound on a rendered component tag reaches that child's server render by identity — the markup never carries it, and an unregistered tag passes through for the client to handle.

Removing a key

A key that disappears between renders restores what the element held before the binding existed.

renderInto(html`<input type="text" ${spread({ type: 'number' })} />`, host);  // type="number"
renderInto(html`<input type="text" ${spread({})} />`, host);                  // type="text" again

Not removed — restored. The usual framing, "what value means absent", has no answer for a property: delete cannot remove a prototype accessor, and assigning undefined puts the literal string "undefined" into a form field. Asked as "undo what this binding did" it is well defined for every kind, because it reads the element's own pristine state — "" for input.value, undefined for a custom element's property.

On a hydrated page it restores the server's value, because that is genuinely what was there before the binding: the server rendered this same spread, and a spread key replaces a static attribute in server markup exactly as it overwrites one on the client. The original is gone by construction, so bind null when you mean removal:

renderInto(html`<input ${spread({ id: null })} />`, host);   // removes, on either path

One residue worth knowing: .value, .checked and .selected are mirrored to attributes server-side so hydration can read them back, and releasing the property does not clear that attribute. The property is correct either way; the attribute lingers as the field's default value.

A released event binding stops dispatching; the listener itself stays registered, which is how written @event bindings behave too.

What it costs, and why it is a separate entry

@verajs/renderer grows 5 B gzipped for the protocol this uses, whether or not you import it — measured 2026-08-27 by deleting the _$apply$ branch and rebuilding, as a difference rather than a pair of totals — the totals move with every change to this package and the difference does not, which is the mistake this line already made once. llms.txt and this file disagreed about the figure for a while, at 16 B and 8 B respectively, and both were wrong. Nothing regenerates it, so it is dated; re-measure the same way if it matters. The entry itself is 1.60 KB gzipped, and only apps that import it pay for that.

Runtime is at parity with writing the bindings out: both do one comparison per binding per render, and the spread does one part-dispatch where five written bindings do five.

Template renderers bake attribute names into the template at parse time. That is what makes them small and fast, and it is why neither this renderer nor lit-html has spread built in — lit's spread PR has been an open draft since 2021.

The renderer itself holds only a protocol: a value at element position carrying _$apply$ applies itself. Everything else lives in this entry, which imports nothing — not even from the renderer — so it loads alongside any renderer that honours the protocol, including your own.

@verajs/renderer/profiler

import { renderInto, profile, formatReport } from '@verajs/renderer/profiler';

/** `profile` awaits an async driver — driving an app means awaiting frames, and the render
    scheduler is `requestAnimationFrame`, so nothing commits inside one synchronous turn. */
const { report } = await profile(async () => { /* click around, await frames */ });
console.log(formatReport(report));
// 39 updated in place, 2 created, 19 rebuilt (32% of commits)
// Template identity churn — these were torn down, not updated:
//   10x  at body > main#app > ul.todo-list
//       <li class="done"><s>${…}</s></li>
//    -> <li><label><input type="checkbox">${…}</label></li>

showProfiler() puts the same numbers in a live panel in the corner of the page and returns a function that removes it. The panel is plain DOM in a shadow root — it never renders itself through the renderer, so it does not appear in its own measurements.

Calling it again replaces the panel rather than adding one, and a second call's options take effect. That matters because the natural way to use this is a console — showProfiler(), look, showProfiler() again — where the first return value is already gone: two panels would sit on top of each other in the same corner, and the second one's teardown would stop profiling for the first, which kept repainting a frozen report.

Full API: startProfiling(), stopProfiling(), getReport(), isProfiling(), profile(fn), formatReport(report), showProfiler(options?). The first four are profile() unrolled, for a session that does not fit one callback — a long-running tab, a REPL:

import { startProfiling, stopProfiling, getReport, isProfiling, formatReport } from '@verajs/renderer/profiler';

startProfiling();
// … interact with the app for as long as you like …
if (isProfiling()) console.log(formatReport(getReport()));   // read mid-flight without stopping
stopProfiling();                                             // freezes the report

This costs production nothing, and there is nothing to strip: the instrumentation sits behind a __DEV__ constant the build folds to false, so vera-renderer.min.js is byte-identical whether or not this entry exists.

Trusted HTML, and why there is no unsafeHTML

Every interpolated value is escaped at the render boundary. There is no unsafeHTML and there will not be one: shipping a sanctioned opt-out puts an XSS sink in the public API, where it reads as blessed in tutorials and in review.

Trusted markup goes through a property binding, so you write the sink yourself:

renderInto(html`<div .innerHTML=${trustedMarkup}></div>`, host);

Greppable, obviously yours, reviewable as the security decision it is. Sanitize first (DOMPurify.sanitize) unless the markup is genuinely your own, and put it on an element whose children nothing else binds — the renderer owns the content of elements it renders into.

Security: spread refuses the injection sinks. The .innerHTML posture above rests on the template spelling being greppable, obviously yours, and reviewable — three properties a spread key does not have, because spread names arrive at runtime inside a props object that is often built from data. So spread() refuses .innerHTML/!outerHTML property keys, the srcdoc attribute, and any inline-handler attribute name (onclick and friends, any casing — on + Capital with a function remains the documented event spelling and still works), on the client AND in the SSR serializer alike. Development builds name each refused key and the sanctioned template spelling. If you genuinely need one of these dynamically, write the binding in the template where a reviewer can see it.

@verajs/renderer/tag

An element whose tag name is decided at runtime — a heading whose level comes from data, a component that renders <a> or <button>.

import { html, tag } from '@verajs/renderer/tag';

const HEADING = { 1: tag`h1`, 2: tag`h2`, 3: tag`h3` };

const H = HEADING[state.level];
renderInto(html`<${H} class="title">${state.text}</${H}>`, host);

A template renderer bakes tag names into its statics — that is what template identity is, and what every fast path here depends on. So a runtime tag cannot be a binding: it is spliced into the statics before the renderer sees the template. Downstream nothing changes. The renderer, @verajs/ssr and hydration all receive an ordinary template and are unaware this entry exists.

Note the html import: in a template containing a tag, use the one from here rather than core's.

In JSX

A tag is also a component. A capitalized JSX tag compiles to H({…}), and a tag is that function, so the same value works in both notations with no compiler change and no new syntax — <{expr}> is not valid JSX or TSX, and inventing it would break tsc, Prettier and every editor.

const H = HEADING[state.level];
return <H className="title" hidden={state.muted}>{state.text}</H>;

React's names are mapped here exactly as the compiler maps them on a written element, so <H className="t" hidden={false}> and <h1 className="t" hidden={false}> mean the same thing. That is a correctness matter, not an ergonomic one: passed through raw, hidden={false} becomes the attribute hidden="false" and any value at all applies it.

ref is mapped too — <H ref={r}> binds exactly as <h1 ref={r}> does. key never reaches the component: @verajs/jsx consumes it into keyed(…) for both spellings, and a hand-written H({ key }) drops it and says so in development, because a key marks a template for reconciliation and this call returns one rather than being one.

dangerouslySetInnerHTML is the one React name a tag cannot honour, and that is a security property rather than a gap. A tag reaches its element through /spread, whose names are only known at runtime — which is exactly what makes that sink unreviewable — so /spread refuses .innerHTML outright. Write the element directly, with the value sanitized first:

renderInto(html`<${H} .innerHTML=${trusted}>`, host);

tests/jsx-component-equivalence.test.mjs drives every one of these both ways, compiled and rendered, and compares the DOM — because the name-level pin that preceded it passed for the entire life of three defects.

What to know

  • A string can never become a tag. Only another tag may be interpolated, so the set of tags an app can produce is fixed by its source. That is what keeps a tag out of reach of a request — the same reasoning as there being no unsafeHTML — and it is what bounds the cache below.
  • Each tag is its own template. Switching tags rebuilds the subtree, which is correct: the element genuinely changed. Within a tag it updates in place like anything else.
  • The cache is per call site. Spliced statics hang off the call site's own strings array, so two template literals in the source are two entries however identical they look. Right for real code, where a template lives at one place in a render function — and the thing that catches people writing tests for it.
  • HTML only. There is no svg/mathml equivalent yet.

needs it to apply props whose names it cannot know. Additive, like /spread and unlike the other entries: it inlines no renderer internals, so it is safe alongside any of them.

This entry also exports jsxName and BOOLEAN_ATTRIBUTES, which are not API for applications. They are the table this entry uses to map React's names, and @verajs/jsx carries its own copy deliberately — the two are build-time and runtime, and a shared package would be a dependency where a test does the job. tests/jsx-name-mapping.test.mjs asserts the two agree on every key, and it can only do that against the built artifact, which is why they are exported at all. Read them if you are writing something that has to agree with both; do not build on them:

import { jsxName, BOOLEAN_ATTRIBUTES } from '@verajs/renderer/tag';

jsxName('className');              // 'class' — the runtime half of the React-name mapping
BOOLEAN_ATTRIBUTES.has('disabled'); // true — the names the tag entry toggles rather than assigns

Extending it — _$apply$ and _$child$

The renderer holds no directive system. It holds a protocol, at the two positions worth extending, and everything built on it is an ordinary package the renderer knows nothing about — @verajs/renderer/spread is the proof, at 5 B of protocol in this bundle and its own weight only for apps that import it.

| position | brand | called as | | --- | --- | --- | | element — <div ${value}> | _$apply$ | value._$apply$(element, part) | | child — <div>${value}</div> | _$child$ | value._$child$(part, previous) |

A child-position value carrying _$child$ applies itself. It is handed the part and whatever it returned last time at that part, and calls part._$commit$(value) to render content. That is the whole surface: until() is nine lines against it.

/** Hoisted — the applier's identity is its own continuity key. */
function applyUntil(part, previous) {
  if (previous && previous.promise === this.promise) return previous;
  if (previous) previous.live = false;
  const state = { promise: this.promise, live: true };
  part._$commit$(this.placeholder);
  this.promise.then((value) => { if (state.live) part._$commit$(value); });
  return state;
}
const until = (promise, placeholder) => ({ _$child$: applyUntil, promise, placeholder });

renderInto(html`<p>${until(fetchUser(), html`<em>loading…</em>`)}</p>`, host);

Three rules, each of which is a real trap:

  • Hoist the applier. Written as an object-literal method it is a new function per call, so the part can never recognise it and previous is always undefined. Its identity is what keeps two appliers at one part from reading each other's state.

  • Continuity lives in the return value, not in an applier instance. That is what makes this a protocol rather than a framework — no base class, no directive() factory, no lifecycle.

  • Teardown is opt-in, on the applier. applyThing._$detach$ = (previous) => … is called with whatever the applier last returned, when the subtree holding it is removed — replaced, dropped from a keyed list, or shrunk out of an unkeyed one. Declaring it is what arms the walk; an applier that does not declare it costs nothing, and neither does an app with no such applier anywhere.

    It notifies, it does not defer. The nodes are already going. To hold content on screen while it animates out, do not remove it: an applier owns what it commits, so it can simply not commit the removal until it is ready — see Deferring a removal below.

  • Why declaring _$detach$ is what arms it: _clear bulk-removes DOM — when the part owns its parent, one parent.textContent = '', which is what makes clearing a 1 000-row table ~5 ms against lit-html's ~22 ms. Notifying nested appliers means walking the part tree on removal, which is exactly the per-node work that fast path exists to skip — so the walk runs only once something, anywhere, has declared teardown, and an app with none pays nothing.

Deferring a removal

An applier owns the content it commits, so an exit animation needs nothing from the renderer — it just does not commit the removal until the animation has finished:

function applyTransition(part, previous) {
  const next = this.value;
  const state = previous ?? { shown: undefined, timer: null };
  if (next != null) { part._$commit$(next); state.shown = next; return state; }
  if (state.shown != null && state.timer === null)
    state.timer = setTimeout(() => { state.timer = null; part._$commit$(null); }, 300);
  return state;
}
const transition = (value) => ({ _$child$: applyTransition, value });

This works for a child position and for a whole list committed as one value. It does not work for one row inside a keyed list: that row's removal is decided by the reconciler, and nothing inside it is asked.

Both names survive minification by construction: the renderer mangles /^_[a-z]/, and _$…$ does not match it. tests/minification-contracts.test.mjs holds that.

The check costs the hot path nothing: it sits after the template branch, and a template — the common object at a child position — returns before ever reading it, so only arrays, nodes and appliers pay a property read. Measured with no runtime difference distinguishable from noise.

The whole protocol is 116 B gzipped — the check, the two fields holding an applier's state and whose it is, the save/restore in _$commit$ that stops an applier's own rendering from destroying its continuity, and the _$detach$ call. It was 94 B before teardown existed.

Animating things in and out

There is no transition component, and none is needed — but the shapes are worth knowing, because one of the obvious ones does not work in every engine.

Fading on ?hidden. The framework's own advice is to prefer a stable shape with ?hidden=${…} over swapping subtrees, and that is also what makes an exit transition possible: the element is still there to animate.

.fade          { opacity: 1; transition: opacity 200ms; }
.fade[hidden]  { display: block; opacity: 0; pointer-events: none; }

The display: block is load-bearing — it overrides the user agent's [hidden] { display: none }, which would otherwise remove the element from rendering instantly with nothing to fade.

Do not reach for transition-behavior: allow-discrete on display for this. Measured 2026-08-24 across all three engines, transitioning display with allow-discrete: Chromium and WebKit fade correctly, Firefox jumps straight to display: none and no transition runs. All three report CSS.supports('transition-behavior', 'allow-discrete') as true, so the feature test does not tell you. The opacity-only shape above behaves identically in all three.

Animating a removal, where the element really does leave the render, is the browser's job:

const flushSync = (fn) => {
  const previous = setRenderScheduler((run) => run());
  try { fn(); } finally { setRenderScheduler(previous); }
};

document.startViewTransition(() => flushSync(() => { state.rows = next; }));

The View Transitions API snapshots the DOM around the callback and cross-fades, so a row that disappears fades out and the rows below animate up. startViewTransition is present in Chromium, Firefox and WebKit. Two things it needs: the state change must happen inside the callback — which is what flushSync is for, since a render deferred to the next frame lands after the snapshot — and each row needs its own view-transition-name, or the whole page cross-fades as one image.

Or keep it in state. Mark the row leaving, animate, then drop it. Vue's <Transition> is sugar over exactly this, and it is the only one of the three that gives you a completion callback.

Absent on purpose

Directives other renderers ship, and what replaces them here.

| Elsewhere | Here | | --- | --- | | repeat() | keyed() | | cache() | hold() | | ref() | an element-position expression, <input ${myRef}> | | ifDefined() | built in — null/undefined remove an attribute | | classMap() / styleMap() | build the string: class="base ${extra}" | | guard() | reactivity already skips unchanged work | | until(), asyncReplace() | render a loading state and re-render from an effect — or nine lines against _$child$ | | unsafeHTML() | .innerHTML=${trusted}, above | | literal() / static-html | /tag — and a tag doubles as a JSX component | | live() | !name — a sigil, for the case that needs it |

Types

TemplateResult is exported for annotating what a template function returns. The rest of the surface is inferred.

For AI assistants — and anyone who wants the whole API on one page

The repository root's llms.txt is the complete, hand-maintained API reference for every package, written to be pasted into a model's context window: full export tables, the buildless CDN and JSX recipes, semantics that differ from other frameworks, and the mistakes that come up most. Its recipes are executed by the test suite, so they stay honest.

License

MIT