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

ilha

v0.6.0

Published

A tiny, framework-free island architecture library

Downloads

1,465

Readme

ilha

A tiny, isomorphic island framework for building reactive UI components. Runs in the browser with fine-grained signal reactivity and on the server as a synchronous HTML string renderer. Powered by alien-signals — zero virtual DOM, no compiler required.


Installation

npm install ilha
# or Bun
bun add ilha

Quick Start

import ilha, { html } from "ilha";

const Counter = ilha
  .state("count", 0)
  .on("button@click", ({ state }) => state.count(state.count() + 1))
  .render(
    ({ state }) => html`
      <div>
        <p>Count: ${state.count()}</p>
        <button>Increment</button>
      </div>
    `,
  );

// SSR
Counter.toString(); // → '<div><p>Count: 0</p><button>Increment</button></div>'

// Client
Counter.mount(document.getElementById("app"));

Core Concepts

Islands are self-contained reactive components that know how to render themselves to an HTML string (SSR) and mount themselves into the DOM (client). You build an island using a fluent builder chain: declare inputs, state, events, effects, then call .render() to get a callable Island object.

State is managed with signals — when a signal changes, only the affected island re-renders using a minimal DOM morph. No virtual DOM diffing, no framework overhead.


Builder API

Every island starts from the ilha builder object (or ilha.input<T>() / ilha.input(schema) if you need typed props).

ilha.input<T>() / ilha.input(schema)

Declares the island's external input type. Two forms:

1. Type-only (no runtime validation):

const MyIsland = ilha
  .input<{ name: string }>()
  .render(({ input }) => `<p>Hello, ${input.name}!</p>`);

2. With a Standard Schema validator (Zod, Valibot, ArkType, etc.) — runs validation at render time and uses the schema's inferred output type:

import { z } from "zod";

const MyIsland = ilha
  .input(z.object({ name: z.string().default("World") }))
  .render(({ input }) => `<p>Hello, ${input.name}!</p>`);

MyIsland.toString({ name: "Ilha" }); // → '<p>Hello, Ilha!</p>'

Async schemas are not supported.


.state(key, init?)

Declares a reactive state signal. The initial value can be a static value or a function receiving the resolved input.

ilha
  .state("count", 0)
  .state("name", "anonymous")
  .state("double", ({ count }) => count * 2) // init from input
  .render(({ state }) => `<p>${state.count()}</p>`);

State accessors are getters and setters — call without arguments to read, call with a value to write:

state.count(); // → 0  (read)
state.count(5); // → sets to 5 (write)

Inside html\`, you can interpolate signal accessors directly **without calling them** — ilha` detects signal accessors and calls them for you, also applying HTML escaping:

html`<p>${state.count}</p>`; // same as html`<p>${state.count()}</p>`

.derived(key, fn)

Declares an async (or sync) derived value. The function receives { state, input, signal } where signal is an AbortSignal that aborts on re-run. Re-runs automatically when any reactive dependency changes.

ilha
  .state("userId", 1)
  .derived("user", async ({ state, signal }) => {
    const res = await fetch(`/api/users/${state.userId()}`, { signal });
    return res.json();
  })
  .render(({ derived }) => {
    if (derived.user.loading) return `<p>Loading…</p>`;
    if (derived.user.error) return `<p>Error: ${derived.user.error.message}</p>`;
    return `<p>${derived.user.value.name}</p>`;
  });

Each derived value exposes { loading, value, error }.


.on(selector, handler)

Attaches a delegated event listener. The selector string uses the format "cssSelector@eventName". Omit the selector part to target the island host itself.

ilha
  .state("count", 0)
  .on("@click", ({ state }) => state.count(state.count() + 1)) // host click
  .on("button.inc@click", ({ state }) => state.count(state.count() + 1)) // child click
  .on("input@input", ({ state, event }) => {
    state.query((event.target as HTMLInputElement).value);
  })
  .render(({ state }) => html`<div><button class="inc">+</button></div>`);

Event modifiers — append after a : separator:

| Modifier | Description | | ----------- | ------------------------------------------------------------------------- | | once | Listener fires only once | | capture | Capture phase | | passive | { passive: true } | | abortable | ctx.signal aborts when the same listener fires again on the same target |

Multiple modifiers can be combined: @click:once:capture.

The handler receives a HandlerContext:

{
  state: IslandState; // reactive state signals
  derived: IslandDerived; // derived values
  input: TInput; // resolved input props
  host: Element; // island root element
  target: Element; // element that fired the event (typed per event name)
  event: Event; // the native event (typed per event name)
  signal: AbortSignal; // aborts on unmount, and on next fire if `:abortable`
}

Cancelling async work with ctx.signal — pass it to fetch or any abort-aware API to cancel stale requests when the island unmounts:

ilha
  .state("results", [])
  .on("button@click", async ({ state, signal }) => {
    const res = await fetch("/api/data", { signal });
    state.results(await res.json());
  })
  .render(
    () =>
      html`<button>Load</button>
        <ul></ul>`,
  );

Race-cancellation with :abortable — when the same listener fires again on the same target, the previous invocation's signal aborts. Useful for search-as-you-type and other patterns where only the latest invocation should win:

ilha
  .on("input@input:abortable", async ({ state, event, signal }) => {
    const q = (event.target as HTMLInputElement).value;
    const res = await fetch(`/search?q=${q}`, { signal }); // earlier requests cancelled
    if (signal.aborted) return;
    state.results(await res.json());
  })
  .render(
    () =>
      html`<input />
        <ul></ul>`,
  );

Race-cancellation is scoped per-target — clicking button A doesn't cancel an in-flight handler on button B.

Implicit batching — multiple synchronous state writes in a single handler produce one re-render, not one per write:

.on("@click", ({ state }) => {
  state.a(1);
  state.b(2);
  state.c(3); // → one render, not three
})

AbortError rejections from cancelled async work are filtered out automatically — they do not reach .onError() or console.error.


.effect(fn)

Registers a reactive effect that runs after mount and re-runs when any signal it reads changes. Optionally returns a cleanup function.

ilha
  .state("title", "Hello")
  .effect(({ state }) => {
    document.title = state.title();
    return () => {
      document.title = "";
    }; // cleanup on unmount or re-run
  })
  .render(({ state }) => `<p>${state.title()}</p>`);

The handler receives an EffectContext:

{
  state: IslandState;
  input: TInput;
  host: Element;
  signal: AbortSignal; // aborts when the effect re-runs OR the island unmounts
}

Cancelling async work with ctx.signal — unlike .on(), race-cancellation is the default behaviour for effects (no opt-in modifier needed) because dependency changes invariably make the previous run stale. Pass signal to async work to bail out of stale invocations without needing a manual cleanup function:

ilha
  .state("userId", 1)
  .state("user", null)
  .effect(({ state, signal }) => {
    (async () => {
      try {
        const res = await fetch(`/api/users/${state.userId()}`, { signal });
        if (signal.aborted) return;
        state.user(await res.json());
      } catch (err) {
        if (err && (err as Error).name === "AbortError") return;
        throw err;
      }
    })();
  })
  .render(({ state }) => html`<p>${state.user?.name ?? "Loading…"}</p>`);

Both the user-supplied cleanup function (if any) and the signal abort fire when the effect re-runs, so you can mix patterns.

Implicit batching — multiple synchronous state writes inside an effect run produce a single propagation pass.


.onMount(fn)

Runs once after the island is mounted into the DOM. Receives { state, derived, input, host, hydrated } where hydrated is true when the island was mounted over existing SSR content. Optionally returns a cleanup function called on unmount.

ilha
  .onMount(({ host, hydrated }) => {
    console.log("mounted", hydrated ? "(hydrated)" : "(fresh)");
    return () => console.log("unmounted");
  })
  .render(() => `<div>hello</div>`);

.onMount() is skipped when snapshot.skipOnMount is set via .hydratable().


.onError(fn)

Registers an error handler that catches errors thrown by .on() handlers (sync throws and async rejections) and .effect() runs (sync throws). Multiple .onError() calls compose — all run in declaration order. If no .onError() is registered, errors fall back to console.error so they are never silently swallowed.

ilha
  .state("count", 0)
  .on("@click", ({ state }) => {
    if (state.count() > 5) throw new Error("too many clicks");
    state.count(state.count() + 1);
  })
  .onError(({ error, source }) => {
    console.error(`[${source}] ${error.message}`);
    Sentry.captureException(error);
  })
  .render(({ state }) => `<button>${state.count()}</button>`);

The handler receives an ErrorContext:

{
  error: Error; // always wrapped to Error if a non-Error was thrown
  source: "on" | "effect";
  state: IslandState;
  derived: IslandDerived;
  input: TInput;
  host: Element;
}

AbortError rejections from .on() handlers are not routed to .onError() — they are the expected outcome of cancellation (via :abortable race-cancel or unmount) and would otherwise pollute error tracking.

An error thrown inside an .onError() handler does not break other registered handlers; it is logged to console.error and execution continues with the next handler.


.css(strings, ...values)

Attaches scoped styles to the island. Accepts a tagged template literal or a plain string. The CSS is automatically wrapped in a @scope rule bounded to the island host, so styles are contained within the island and do not leak into child islands.

import { css } from "ilha";

const Card = ilha.state("active", false).css`
    .title { font-weight: 700; }
    button { background: teal; color: white; }
  `.render(
  ({ state }) => html`
    <div>
      <p class="title">Hello</p>
      <button>Toggle</button>
    </div>
  `,
);

Interpolations are supported:

const accent = "teal";

ilha.css`button { background: ${accent}; }`.render(() => `<button>Go</button>`);

You can also pass a plain string (e.g. from an external .css file):

import styles from "./card.css?raw";

ilha.css(styles).render(() => `<div class="card">…</div>`);

SSR output — a <style data-ilha-css> tag is prepended as the first child of the island's rendered HTML:

<style data-ilha-css>
  @scope (:scope) to ([data-ilha]) {
    .title {
      font-weight: 700;
    }
  }
</style>
<div>…</div>

Client mount — the style element is injected once as the first child of the host and preserved across re-renders (morph never replaces it). During hydration, the SSR-emitted <style> node is reused and not duplicated.

.hydratable() integration — the style tag is included inside the data-ilha wrapper regardless of the snapshot option.

Note: Calling .css() more than once on the same builder chain is not supported. In dev mode a warning is logged and only the last stylesheet is used. Compose all your styles into a single .css() call.


Composing Islands

Child islands are interpolated directly inside a parent's html`` template. During SSR the child's HTML is rendered inline; during client mount the child is activated independently inside its own host element.

const Icon = ilha.render(() => `<svg>…</svg>`);

const Card = ilha.render(
  () => html`
    <div class="card">
      ${Icon}
      <p>Card content</p>
    </div>
  `,
);

Passing props — call the child island with a props object:

const Badge = ilha
  .input(z.object({ label: z.string(), color: z.string().default("teal") }))
  .render(({ input }) => html`<span style="background:${input.color}">${input.label}</span>`);

const Card = ilha.render(
  () => html`
    <div>
      ${Badge({ label: "New", color: "coral" })}
      <p>Content</p>
    </div>
  `,
);

Keyed children — use .key() when a child may reorder or appear conditionally. Keys must be unique within a parent render:

const List = ilha.render(
  () =>
    html`<ul>
      ${items.map((item) => html`<li>${Item.key(item.id)({ name: item.name })}</li>`)}
    </ul>`,
);

.transition(opts)

Attaches enter/leave transition callbacks called on mount and unmount respectively.

ilha
  .transition({
    enter: async (host) => {
      host.animate([{ opacity: 0 }, { opacity: 1 }], 300).finished;
    },
    leave: async (host) => {
      await host.animate([{ opacity: 1 }, { opacity: 0 }], 300).finished;
    },
  })
  .render(() => `<div>content</div>`);

The leave transition is awaited before cleanup runs.


.render(fn)

Finalises the builder and returns an Island. The render function receives { state, derived, input } and must return a string or RawHtml.

const MyIsland = ilha.state("x", 1).render(({ state, input }) => html`<p>${state.x}</p>`);

Island Interface

Every island produced by .render() exposes:

island(props?) / island.toString(props?)

Render the island to an HTML string synchronously. island.toString() is always synchronous. If .derived() entries have async functions, they render in loading: true state when called synchronously.

Calling island(props) returns a string (or Promise<string> when derived values are async and awaited).

MyIsland.toString(); // always sync
MyIsland.toString({ name: "Ilha" }); // with props
await MyIsland({ name: "Ilha" }); // async — awaits derived

island.mount(host, props?)

Mounts the island into a DOM element. Reads data-ilha-props and data-ilha-state from the host element automatically — no need to pass props when hydrating SSR output.

Returns an unmount function.

const unmount = MyIsland.mount(document.getElementById("app"));
unmount(); // → stops effects, removes listeners, runs leave transition

In dev mode, double-mounting the same element logs a warning and returns a no-op.


island.hydratable(props, options)

Async method that renders the island wrapped in a data-ilha hydration container. Used for SSR+hydration pipelines.

const html = await MyIsland.hydratable(
  { name: "Ilha" },
  {
    name: "MyIsland", // registry key for client-side activation
    as: "div", // wrapper tag (default: "div")
    snapshot: true, // embed state + derived as data-ilha-state
    skipOnMount: false, // skip onMount on hydration (default: true when snapshot)
  },
);
// → '<div data-ilha="MyIsland" data-ilha-props="…" data-ilha-state="…">…</div>'

snapshot option:

| Value | Behaviour | | --------------------------------- | --------------------------------------------- | | false | No snapshot — onMount always runs | | true | Snapshots both state and derived values | | { state: true, derived: false } | Fine-grained control over what is snapshotted |


Top-level Helpers

ilha.mount(registry, options?) / mount(registry, options?)

Auto-discovers all [data-ilha] elements in the DOM and mounts the corresponding island from the registry.

import { mount } from "ilha";

const { unmount } = mount(
  { counter: Counter, card: Card },
  {
    root: document.getElementById("app"), // default: document.body
    lazy: true, // use IntersectionObserver (mount on visibility)
  },
);

unmount(); // → unmounts all discovered islands

ilha.from(selector, island, props?) / from(selector, island, props?)

Mounts a single island into the first element matching selector. Returns the unmount function, or null if the element is not found.

import { from } from "ilha";

const unmount = from("#hero", HeroIsland, { title: "Welcome" });

signal(initial)

Creates a free-standing reactive signal that lives outside any island. Useful for sharing state across multiple islands without prop drilling, or for binding form inputs to module-level state.

import { signal } from "ilha";

const count = signal(0);

count(); // → 0  (read)
count(5); // → sets to 5 (write)

Reading the signal inside any reactive scope — .render(), .derived(), .effect() — automatically subscribes that scope, so when the signal changes, dependents re-run as if it were local state.

import ilha, { signal, html } from "ilha";

const username = signal("anonymous");

const Header = ilha.render(() => html`<header>Hi, ${username()}!</header>`);
const Footer = ilha.render(() => html`<footer>Logged in as ${username()}</footer>`);

// Both islands re-render when `username` changes from anywhere.
username("alice");

Works naturally with bind: template syntax for two-way form bindings against module-level state.


context(key, initial)

Creates a global context signal — a named reactive signal shared across all islands. Identical keys always return the same signal instance, which makes it useful for app-wide singletons (theme, locale, current user) where you want the registry semantics.

import { context } from "ilha";

const theme = context("app.theme", "light");

theme(); // → "light"
theme("dark"); // → sets to "dark"

Safe to call in both SSR and browser environments.

signal() vs context() — both return the same accessor shape and can be used with bind: template syntax. Use signal() for one-off shared state where you'd hold the reference yourself; use context() when you want a name-keyed registry so the same signal can be looked up from anywhere by string key.


batch(fn)

Runs fn as an atomic batch — multiple signal writes inside the callback produce a single propagation pass, so dependents see the final state and run once instead of once per write. Returns whatever fn returns.

import { signal, batch } from "ilha";

const a = signal(0);
const b = signal(0);

// Without batch: each write triggers a propagation pass.
a(1); // → effects re-run
b(2); // → effects re-run

// With batch: both writes flush together.
batch(() => {
  a(10);
  b(20);
}); // → effects re-run once

.on() handlers and .effect() runs are batched implicitly, so you only need batch() when triggering multiple writes from outside an island — e.g. from a top-level event listener, a setTimeout callback, or a WebSocket message handler. Nested batch() calls are safe and only flush when the outermost batch ends.


untrack(fn)

Runs fn with reactive tracking suspended. Reading signals inside fn returns their current value without subscribing the surrounding scope. Use this in effects or deriveds when you want to peek at state without causing a re-run on its changes.

import ilha, { signal, untrack } from "ilha";

const tracked = signal(0);
const peeked = signal("hello");

ilha
  .effect(() => {
    // Re-runs when `tracked` changes, but NOT when `peeked` changes.
    console.log(
      tracked(),
      untrack(() => peeked()),
    );
  })
  .render(() => `<p>x</p>`);

Returns whatever fn returns.


html\`` tagged template

XSS-safe HTML template tag. Interpolated values are HTML-escaped by default. Pass raw() to opt out of escaping.

import { html, raw } from "ilha";

const name = "<script>alert(1)</script>";
html`<p>${name}</p>`; // → <p>&lt;script&gt;…</p>  (escaped)
html`<p>${raw("<b>hi</b>")}</p>`; // → <p><b>hi</b></p>      (raw)

Interpolation rules:

| Value type | Behaviour | | -------------------- | ------------------------------------------- | | string / number | HTML-escaped | | null / undefined | Omitted (empty string) | | raw(str) | Inserted as-is (no escaping) | | html\…` | Inserted as-is (already safe) | | Signal accessor | Called and escaped | | Island / Island call | Emitted asdata-ilha-slot` host element | | Array | Each item processed recursively (no commas) |

Template bindings — use bind:property=${signal} inside html\`` to create two-way bindings between form elements and signals:

ilha.state("name", "").render(
  ({ state }) => html`
    <input bind:value=${state.name} />
    <p>Hello, ${state.name()}!</p>
  `,
);

Supported bindings:

| Binding | Element | Bound property | Trigger event | | -------------------- | ------------------------------------------------- | ------------------- | ------------- | | bind:value | <input>, <textarea>, <select> | value | input | | bind:valueAsNumber | <input type="number"> | valueAsNumber | input | | bind:valueAsDate | <input type="date"> | valueAsDate | input | | bind:checked | <input type="checkbox"> | checked | change | | bind:group | <input type="radio">, <input type="checkbox"> | checked / value | change | | bind:open | <details> | open | toggle | | bind:files | <input type="file"> | files | change | | bind:this | Any element | element reference | — |

bind:group connects multiple inputs to a single signal — radio buttons hold the selected value, checkboxes hold an array of checked values. bind:this writes the DOM element into a signal on mount and null on unmount. External signals from signal() or context() work as binding targets too, enabling shared state across islands.

List rendering pattern:

const items = ["apple", "banana", "cherry"];
html`<ul>
  ${items.map((item) => html`<li>${item}</li>`)}
</ul>`;

raw(value)

Marks a string as trusted raw HTML, bypassing escaping when used inside html\``.

import { raw } from "ilha";

raw("<strong>bold</strong>"); // → passes through unescaped

css\`` tagged template

A passthrough tagged template for CSS strings. Functionally identical to a plain template literal — no runtime transformation occurs. Its purpose is purely to enable editor tooling (LSP syntax highlighting, Prettier formatting) to recognise the contents as CSS.

import { css } from "ilha";

const styles = css`
  button {
    background: teal;
    color: white;
  }
  .label {
    font-weight: 700;
  }
`;

ilha.css(styles).render(() => `<button class="label">Go</button>`);

Interpolations work as normal string concatenation:

const accent = "coral";
const styles = css`
  button {
    background: ${accent};
  }
`;

Note: css (the named export) is the plain passthrough tag for tooling. ilha.css is the builder chain method that attaches styles to an island. They are intentionally separate.


SSR + Hydration

The recommended SSR + hydration pattern uses .hydratable() on the server and ilha.mount() on the client.

Server

import { MyIsland } from "./islands";

const html = await MyIsland.hydratable({ count: 42 }, { name: "my-island", snapshot: true });

return `<!doctype html><html><body>${html}</body></html>`;

Client

import { mount } from "ilha";
import { MyIsland } from "./islands";

mount({ MyIsland });

The client reads data-ilha-state to restore signal values from the snapshot, skipping a needless re-render and calling .onMount() only if skipOnMount is not set.

State snapshot flow

server                                    client
──────────────────────────────────────    ──────────────────────────────────────────
.hydratable({ count: 42 }, {              mount({ MyIsland })
  name: "my-island",                        → reads data-ilha-state
  snapshot: true                            → restores signals from snapshot
})                                          → skips onMount (skipOnMount: true)
→ data-ilha-state='{"count":42}'            → attaches event listeners
                                            → starts effects + derived watchers

TypeScript

Key exported types:

import type {
  Island,
  IslandState,
  IslandDerived,
  DerivedValue,
  KeyedIsland,
  HydratableOptions,
  OnMountContext,
  HandlerContext,
  HandlerContextFor,
  ErrorContext,
  ErrorSource,
  ExternalSignal,
  MountOptions,
  MountResult,
} from "ilha";

License

MIT