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

@zakkster/lite-element

v1.1.0

Published

Zero-GC reactive Custom Elements on @zakkster/lite-signal. No virtual DOM, no templating. State survives synchronous reparents (sort, drag-and-drop, insertBefore) -- the moves that destroy React/Vue/Lit. 1.1 adds reactive observed attributes, typed proper

Readme

@zakkster/lite-element

npm version sponsor zero-gc npm bundle size npm downloads npm total downloads TypeScript lite-signal peer license

Zero-GC reactive Custom Elements built on @zakkster/lite-signal. No virtual DOM. State survives synchronous reparents -- the move operations that destroy React, Vue, and Lit components are no-ops here.

npm i @zakkster/lite-element @zakkster/lite-signal
import { define } from "@zakkster/lite-element";

define("counter-el", (host, { state, bind, on }) => {
    const n = state(0);
    host.innerHTML = `<button>+</button><span></span>`;
    const [btn, out] = host.children;
    on(btn, "click", () => n.update(v => v + 1));
    bind(out, "textContent", () => `Count: ${n()}`);
});

Headline (measured on Node 23, see Benchmarks): at steady state, ~6 million bind writes per second. Component lifecycle: ~340K mount+unmount/sec, ~3 B retained per cycle. A synchronous reparent (sort, drag-and-drop, insertBefore) is ~13× faster than the React/Vue/Lit destroy+rebuild path and allocates ~1.8 KB less per move.


Table of contents


Why this exists

A small set of design constraints picked deliberately:

  • One concept. Setup runs once. You build the DOM yourself or query it. Every binding is a real lite-signal effect writing into a real node. There is no VDOM, no template compiler, no JSX runtime, no hydration phase.
  • Zero-GC steady state. Mounting N reactive nodes allocates nodes.push(handle) once per node -- flat array push, no per-node closure. Steady-state bind writes hit lite-signal's Object.is cutoff and re-write nothing when the result is unchanged.
  • Survives moves. When the browser fires disconnectedCallback immediately followed by connectedCallback -- the actual shape of a sort, a drag-and-drop, or an insertBefore -- the scope is not torn down and rebuilt. Component state is preserved. (Details: The reparent story.)
  • Pool-clean teardown. On real disconnect, every node the scope created is disposed, every listener removed, every onCleanup runs. stats().activeNodes returns to baseline. We test this with a 1000-cycle audit.

If you want a router, an SSR runtime, a templating language, slots, or a marketplace of UI primitives -- this is the wrong library. Three exports in 138 lines.

What you get

import { mount, define } from "@zakkster/lite-element";
  • mount(host, setup) -> unmount -- drop a reactive scope on any node. Returns a synchronous unmount. Framework-free; ideal for islands and portals.
  • define(name, setup, options?) -> Class -- register a reactive Custom Element. Same setup(host, scope) shape as mount, with the reparent gate wired in.
  • The scope. Every method registers a teardown automatically:
    • state(value, opts?) - computed(fn, opts?) - effect(fn)
    • bind(el, prop, fn) - attr(el, name, fn) - show(el, fn)
    • on(el, type, handler, opts?) - onCleanup(fn)
    • batch(fn) -- re-export of lite-signal's batch

That's the whole surface.


The reparent story

This is the differentiator. Pay attention to the timing.

What the browser actually does on a sort

When you do this:

parentB.insertBefore(elementFromParentA, parentB.firstChild);

...the browser fires, synchronously, in the same tick:

  1. elementFromParentA.disconnectedCallback() (from parentA)
  2. elementFromParentA.connectedCallback() (now in parentB)

A naive Custom Element implementation tears down on (1) and rebuilds on (2). Every signal is destroyed, every effect re-created, every listener re-attached, every internal counter reset to its initial value. React and Vue exhibit the same destruction pattern when a keyed component changes parents in a way their reconciler cannot trace.

What lite-element does

sequenceDiagram
    participant Browser
    participant LiteEl as lite-element
    participant Scope

    Note over Browser,Scope: Synchronous reparent (insertBefore)
    Browser->>LiteEl: disconnectedCallback()
    LiteEl->>LiteEl: queueMicrotask(teardownCheck)
    Browser->>LiteEl: connectedCallback()
    LiteEl->>LiteEl: __unmount still set -> no-op
    Note over Scope: state preserved
    Note over Browser,LiteEl: --- next microtask ---
    LiteEl->>LiteEl: teardownCheck: isConnected?
    Note over LiteEl: true -> keep scope alive

    Note over Browser,Scope: Real disconnect (removeChild, never reattached)
    Browser->>LiteEl: disconnectedCallback()
    LiteEl->>LiteEl: queueMicrotask(teardownCheck)
    Note over Browser,LiteEl: --- next microtask ---
    LiteEl->>LiteEl: teardownCheck: isConnected?
    Note over LiteEl: false -> scope.dispose()
    LiteEl->>Scope: dispose all nodes + cleanups

The whole mechanism is six lines:

disconnectedCallback() {
    queueMicrotask(() => {
        if (!this.isConnected && this.__unmount) {
            this.__unmount();
            this.__unmount = null;
        }
    });
}

queueMicrotask (not setTimeout) means a genuine disconnect tears down at the end of the current task -- fast enough that nothing keeps the event loop alive, slow enough that a synchronous reparent slips under the gate.

Why this matters

Sorting a list of 1,000 stateful items used to be:

  • React keyed list with a key={item.id}: in many cases, fine -- but only when React's reconciler can trace the key. Cross-parent moves and dynamic containers regularly invalidate this and rebuild.
  • Vue <TransitionGroup>: same caveat.
  • Lit: each LitElement runs disconnectedCallback + connectedCallback, tearing down its ReactiveController chain on every move.

Sorting a list of 1,000 lite-element items: the browser moves the nodes and lite-element does literally nothing. Internal state -- counters, scroll positions, expanded/collapsed flags, the <input> value inside the card -- all of it survives. No rebuild cost, no rebind cost, no flicker.


Scope API

Every method on the scope creates a primitive that is disposed on unmount. The only way to leak is to allocate something outside the scope and forget to register it with onCleanup.

state(value, opts?)

Component-scoped writable signal. Identical to lite-signal's signal -- same peek, set, update, subscribe -- but the underlying handle is reclaimed when the scope tears down.

const count = state(0);
count();           // read (tracked)
count.peek();      // read (untracked)
count.set(5);
count.update(v => v + 1);

computed(fn, opts?)

Derived value. Lazy, memoised, dirty-tracked. Same as lite-signal's computed.

const dbl = computed(() => count() * 2);
const sum = computed(() => a() + b());

effect(fn)

A side effect that runs once now and again on every dep change. Disposed on unmount. Use for anything that doesn't fit bind/attr/show (logging, sending analytics, etc).

bind(el, prop, fn)

The workhorse. Writes el[prop] = fn() whenever a dep changes. Reading the same value writes nothing because lite-signal's Object.is cutoff stops propagation upstream.

bind(out, "textContent", () => `Count: ${n()}`);
bind(input, "value", () => model());
bind(progress, "style.transform", () => `translateX(${pos()}px)`); // string props only

attr(el, name, fn)

Set an HTML attribute reactively. The value is coerced by type:

| Returned value | Effect | |--------------------|---------------------------------| | null / undefined / false | removeAttribute(name) | | true | setAttribute(name, "") | | anything else | setAttribute(name, String(v)) |

attr(btn, "disabled", () => loading());
attr(li, "aria-selected", () => selected());
attr(card, "data-state", () => mode());

show(el, fn)

Reactive el.hidden = !fn(). Shorthand for the common visibility toggle.

on(el, type, handler, opts?)

Add an event listener; auto-removed on unmount. Returns an early-removal function -- call it to detach immediately.

on(btn, "click", () => count.update(v => v + 1));

const off = on(input, "keydown", handleKey);
// ...later:
off();   // detach before unmount

onCleanup(fn)

Register arbitrary teardown -- timers, observers, third-party subscriptions. Cleanups run in reverse insertion order; a throwing cleanup does not stop the rest from firing.

onCleanup(() => clearInterval(pollId));
onCleanup(() => observer.disconnect());

batch(fn)

Re-export of lite-signal's batch. Multiple .set() calls inside coalesce into one propagation.

batch(() => {
    firstName.set("Zahary");
    lastName.set("Shinikchiev");
});
// All bound effects re-run exactly once.

useAttr(name) (1.1)

Reactive read of an observed attribute. Returns a getter that tracks the attribute's value (string or null) and updates on attributeChangedCallback. Only attributes listed in the element's observedAttributes are reactive.

define("user-badge", (host, { useAttr, bind }) => {
    const name = useAttr("name");
    bind(host, "textContent", () => name() ?? "anonymous");
}, { observedAttributes: ["name"] });
// <user-badge name="Zahary"></user-badge>  ->  re-renders when `name` changes

prop(name, initial, options?) (1.1)

Declares a reactive property backed by a disposed-on-unmount signal, with a host[name] getter/setter. Options: type (Number / Boolean / String coercion for the attribute), attribute (mirror name, defaults to the property name), and reflect (write the value back to the attribute on change). When the attribute is observed, external attribute changes flow into the property too -- and the Object.is cutoff keeps the reflect <-> observe pair from looping.

define("x-stepper", (host, { prop, bind }) => {
    const value = prop("value", 0, { type: Number, reflect: true });
    bind(host, "textContent", () => String(value()));
}, { observedAttributes: ["value"] });
// el.value = 5;  // -> attribute "value" becomes "5", text re-renders
// el.setAttribute("value", "9");  // -> el.value === 9

root, hydrating, internals (1.1)

  • root -- the rendering root: the shadow root when the element was defined with { shadow }, otherwise the host. Build your DOM into this.
  • hydrating -- true when the host already had server-rendered content at connect time (light children, or a declarative-shadow-DOM root). Skip your innerHTML assignment and bind to the existing nodes.
  • internals -- the ElementInternals when defined with { formAssociated }, else null. Use internals.setFormValue(v), setValidity(...), etc.

Lifecycle hooks (1.1)

onAdopted(cb), onFormReset(cb), onFormDisabled(cb), onFormStateRestore(cb) register callbacks for the matching Custom Element lifecycle. They are disposed with the scope and only fire on elements created via {@link define}.


mount and define

These are the same function with different lifecycle binding.

mount(host, setup) -> unmount

Framework-free. Pass any node, get a synchronous unmount.

const unmount = mount(document.getElementById("widget"), (host, { state, bind, on }) => {
    const n = state(0);
    bind(host, "textContent", () => `Count: ${n()}`);
    on(host, "click", () => n.update(v => v + 1));
});

// Later, when the widget should die:
unmount();

Calling unmount twice is safe. Mounting twice on the same host stacks two scopes; each unmount disposes one.

define(name, setup, options?) -> CustomElementConstructor

Registers a <custom-tag> element. Setup runs on first connect; teardown is gated on the microtask check above.

define("price-tag", (host, { state, attr, bind, on, onCleanup }) => {
    const price = state(0);
    const currency = state("USD");

    bind(host, "textContent", () => `${currency()} ${price().toFixed(2)}`);
    attr(host, "data-price", () => price());

    // External refresh source -- track its teardown explicitly.
    const sub = priceFeed.subscribe(host.dataset.symbol, p => price.set(p));
    onCleanup(() => sub.unsubscribe());
});

The optional third argument is a config object (1.1):

| field | type | effect | |---|---|---| | observedAttributes | string[] | attributes that become reactive via useAttr / prop | | formAssociated | boolean | attaches ElementInternals (scope.internals) + form lifecycle callbacks | | shadow | "open" \| "closed" | attaches (or adopts an existing declarative) shadow root, exposed as scope.root | | extends | string | customized built-in tag, forwarded to customElements.define |

Server rendering and hydration (1.1)

lite-element components are imperative setup functions, so server rendering runs setup against a server DOM and serializes the result; the client adopts that markup instead of rebuilding it.

import { renderToString, toDeclarativeShadow } from "@zakkster/lite-element";

// --- server: build a host from any server DOM (happy-dom, linkedom, jsdom) ---
const html = renderToString(serverDoc.createElement("x-card"), cardSetup);
// or emit an encapsulated shadow root as declarative shadow DOM:
const dsd = renderToString(serverDoc.createElement("x-card"), cardSetup, { shadow: "open" });
// `dsd` is `<template shadowrootmode="open">...</template>` -- drop it inside <x-card>.

// --- client: define the same component; on connect scope.hydrating is true ---
define("x-card", (host, scope) => {
    if (!scope.hydrating) scope.root.innerHTML = `<h3></h3><p></p>`;   // create only if fresh
    const [title] = scope.root.children;
    scope.bind(title, "textContent", () => titleSig());                // bind either way
}, { shadow: "open" });

renderToString runs setup once and disposes the scope immediately (no leak). toDeclarativeShadow(innerHTML, mode?) is the bare string wrapper if you build the markup yourself.


Patterns

Counter

define("counter-el", (host, { state, bind, on }) => {
    const n = state(0);
    host.innerHTML = `<button>+</button><span></span>`;
    const [btn, out] = host.children;
    on(btn, "click", () => n.update(v => v + 1));
    bind(out, "textContent", () => `Count: ${n()}`);
});

Form input with two-way binding

define("text-field", (host, { state, bind, on }) => {
    const value = state("");
    host.innerHTML = `<input type="text">`;
    const input = host.firstChild;
    bind(input, "value", () => value());
    on(input, "input", e => value.set(e.target.value));
});

Form-associated control (1.1)

A custom element that participates in a real <form> -- its value is submitted, and it resets and restores with the form.

define("rating-input", (host, { state, internals, bind, on, onFormReset }) => {
    const score = state(0);
    host.innerHTML = `<button type="button">-</button><output></output><button type="button">+</button>`;
    const [dec, out, inc] = host.children;

    const commit = () => internals.setFormValue(String(score()));   // submitted with the form
    bind(out, "textContent", () => score());
    on(dec, "click", () => { score.update(v => Math.max(0, v - 1)); commit(); });
    on(inc, "click", () => { score.update(v => v + 1); commit(); });
    commit();

    onFormReset(() => { score.set(0); commit(); });                  // form.reset() clears it
}, { formAssociated: true });
// <form><rating-input name="rating"></rating-input></form>  ->  submits rating=<score>

A list that survives reorder

define("sortable-list", (host, { state, effect, on }) => {
    const items = state([{ id: 1, text: "alpha" }, { id: 2, text: "beta" }]);

    effect(() => {
        // Crude: re-render the wrapper, but per-item state lives in the cards.
        host.innerHTML = items().map(i => `<sortable-card data-id="${i.id}">${i.text}</sortable-card>`).join("");
    });

    on(host, "shuffle", () => {
        items.update(arr => [...arr].sort(() => Math.random() - 0.5));
    });
});

define("sortable-card", (host, { state, bind, on }) => {
    // Each card has its OWN click count. Sort the parent -- counts stay put.
    const clicks = state(0);
    on(host, "click", () => clicks.update(v => v + 1));
    bind(host, "title", () => `clicked ${clicks()} times`);
});

Island on a server-rendered page

mount(document.getElementById("hero-cta"), (host, { state, on, bind }) => {
    const visits = state(parseInt(localStorage.visits || "0", 10));
    on(host, "click", () => {
        visits.update(v => v + 1);
        localStorage.visits = visits.peek();
    });
    bind(host.querySelector(".count"), "textContent", () => visits());
});

External subscription with cleanup

define("clock-el", (host, { state, bind, onCleanup }) => {
    const tick = state(Date.now());
    const id = setInterval(() => tick.set(Date.now()), 1000);
    onCleanup(() => clearInterval(id));
    bind(host, "textContent", () => new Date(tick()).toLocaleTimeString());
});

vs React / Vue / Lit

| | lite-element | React | Vue | Lit | |-----------------------------------|:------------:|:-----:|:---:|:---:| | Virtual DOM | none | yes | yes | partial | | Templating language | none (you write HTML) | JSX | template DSL | tagged templates | | Fine-grained reactivity | yes (lite-signal) | no (re-runs render) | yes | partial (lit-html) | | Sync disconnect -> connect preserves state | yes | no rebuilds | no rebuilds | no rebuilds | | Bundle size | ~3 KB + peer | ~45 KB | ~35 KB | ~6 KB | | Lifecycle hooks | one (setup) | many | many | many | | You write DOM directly | yes | through JSX | through templates | through templates |

You give up: a templating language, declarative children, hot ecosystem plugins, dev-tools that draw component trees, an opinionated form library.

You get: components that survive sort, drag, and re-parent. A 3 KB engine. No mental model beyond "setup runs once, effect writes to the DOM".


Benchmarks

Measured on Node 22.22 with --expose-gc. Run yourself: npm run bench.

| Scenario | N | ops/sec | transient/op | retained/op | |-------------------------------------------------------|-------:|----------:|-------------:|------------:| | A) mount+unmount, small (1 sig, 1 bind, 1 on) | 20K | ~340K | ~420 B | ~3 B | | B) mount+unmount, medium (2 sig + 2 comp + 2 bind + attr + 2 on) | 10K | ~190K | ~660 B | ~0 B | | C) bind throughput, single cell (signal.set -> DOM) | 200K | ~6.2M | ~8 B | 0 B | | D) bind throughput, 100 cells × 1 shared signal | 20K | ~95K | ~30 B | 0 B | | E) reparent (lite-element survives, zero work) | 50K | ~1.97M| ~265 B | ~265 B | | F) rebuild (React/Vue/Lit-style destroy+remount) | 5K | ~143K | ~2.1 KB | ~14 B |

Headline:

  • bind writes hit ~6 million/sec on a single reactive cell -- that's lite-signal's effect-propagation cost; lite-element adds nothing measurable on top.
  • a reparent is ~13× faster than a rebuild (the bench prints the exact ratio each run; we see 7-15× across hardware and Node versions) and allocates ~1.8 KB less per move. At 60 fps with 100 moving items per frame, that's the difference between a smooth frame and a dropped one.
  • mount+unmount retains ~3-5 bytes per cycle. stats().activeNodes returns to 0 after the full benchmark -- verified by the post-bench audit and locked in by test/zero-gc.test.js's 1000-cycle leak guard.

Numbers vary ~15% run-to-run with GC timing. The bench file is bench/bench.mjs; copy it, modify, re-run.


Edge cases pinned down

  • unmount() is idempotent. Calling it twice is safe.
  • Mounting twice on the same host stacks two independent scopes. Each unmount disposes one. Last bind wins for any contested property.
  • A throwing onCleanup does not block the rest. Teardown continues; the error is swallowed silently (we are tearing down -- there's no caller to rethrow to).
  • Nodes are disposed before cleanups. Effects unsubscribe from their deps before listeners detach, so an effect cannot fire on a half-disposed scope.
  • Effects are torn down in reverse registration order. The last bind you wrote is the first to stop writing.
  • Setup runs untrack'd. Reading a signal inside setup does NOT make setup itself reactive. To wire reactivity, use effect/bind/attr/show.
  • The reparent gate is exactly one microtask. Anything that fires disconnect -> connect within the current task is preserved. Anything that yields (setTimeout, await, idle callback) and then reconnects gets a fresh scope.
  • Custom Element options are forwarded verbatim to customElements.define -- { extends: "button" } works as expected.

What this is not

  • Not a templating library. host.innerHTML = "<button></button>" is the expected idiom. If you want JSX, use a JSX runtime; lite-element doesn't care.
  • SSR is render-then-serialize, not a framework. renderToString runs setup against a server DOM you supply and serializes the output; the client adopts that markup via scope.hydrating. There is no streaming or build-step integration.
  • Shadow DOM is opt-in, not an abstraction. { shadow } attaches (or adopts a declarative) shadow root exposed as scope.root; lite-element does not provide styling, slotting, or part/theme helpers.
  • Form association is wiring, not semantics. { formAssociated } gives you scope.internals and the form lifecycle callbacks; validation UI and submission semantics remain the platform's.
  • Not a routing library, a form library, or a data-fetching library. Bring whatever you like.
  • Not a virtual DOM. Bindings write to real nodes immediately. There is no reconcile pass.

Browser / runtime support

| Runtime | Notes | |-----------------|-------| | Chrome 67+ | full | | Firefox 63+ | full | | Safari 13+ | full | | Edge 79+ | full | | Node 18+ | full (Custom Elements need a stub; mount() works directly) | | Deno / Bun | full (same as Node) |

Requires customElements, queueMicrotask, ES2017+ for async, Object.is, and module syntax. No polyfills shipped.


FAQ

Why one microtask and not zero / two / setTimeout?

  • Zero is what naive Custom Elements do: they tear down on disconnect. Reparent fails.
  • setTimeout(0) keeps the event loop alive long enough that a Node process that mounts and disconnects an element will hang. queueMicrotask doesn't.
  • Two microtasks would let await-yielding code re-attach. That's a different guarantee (and not a useful one); we want the in-task reparent only.

Why no props mechanism? HTML attributes are the props mechanism. Read them in setup via host.getAttribute(...). If you want them reactive, observe them with a MutationObserver and pipe into a state() -- six lines.

Can I use this without lite-signal? No. The reactivity primitive is lite-signal -- it's the peer dependency. Bring your own DOM library; you can't bring your own signals.

Does bind work for non-string properties? Yes -- anything you can assign to el[prop]. style.transform (string), disabled (boolean), value (string), data-* (use attr()).

What about lists with key tracking? The library doesn't ship a <For> primitive. Render your list however you want. The reparent guarantee means a sort of N items costs 0 in lite-element overhead -- the browser does the DOM moves, lite-element does nothing.

Custom Elements in SSR? The library doesn't render on the server. The custom-element class is registered in the browser when the module loads.

Why are the type definitions so loose around bind? The strict overload constrains K extends keyof E, so bind(input, "value", ...) infers string. The fallback overload accepts an arbitrary string for cases where TS doesn't know the element type. The runtime is identical.

Is Element.js really only 138 lines? Yes. Including blank lines and comments.


Files

  • Element.js -- the library (single file, 138 lines)
  • Element.d.ts -- TypeScript definitions
  • README.md -- this file
  • llms.txt -- machine-readable spec for LLM agents
  • LICENSE.txt -- MIT
  • test/*.test.js -- 35-test suite (npm test)
  • bench/bench.mjs -- six-scenario benchmark (npm run bench)
  • demo/index.html -- interactive four-scene demo

Peer dependency

This package requires @zakkster/lite-signal ^1.1.3 to be installed alongside.

License

MIT (c) Zahary Shinikchiev


Part of the @zakkster/lite-* ecosystem: zero-GC micro-libraries with honest benchmarks. See also: lite-signal, lite-time, lite-bmfont, lite-vram.