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.
npm install ilha
# or Bun
bun add ilhaimport 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"));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.
Every island starts from the ilha builder object (or ilha.input() if you need typed props).
Declares the island's external input type using any Standard Schema compatible validator (e.g. Zod, Valibot, ArkType).
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.
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>`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 }.
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:debounce", ({ 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 } |
Multiple modifiers can be combined: @click:once:capture.
The handler receives a HandlerContext:
{
state: IslandState; // reactive state signals
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)
}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>`);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().
Two-way binds a form element to a state key or an external signal. Handles input, select, textarea, checkbox, radio, and number inputs automatically.
ilha
.state("name", "")
.state("agreed", false)
.bind("input.name", "name")
.bind("input[type=checkbox]", "agreed")
.render(
({ state }) => html`
<form>
<input class="name" value="${state.name}" />
<input type="checkbox" />
<p>Hello, ${state.name}! Agreed: ${state.agreed}</p>
</form>
`,
);You can also bind to an external signal created with context():
.bind("input", myContextSignal)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.
Embeds a child island as a named slot. The child island is mounted and managed independently. During SSR the slot renders the child's HTML inline; during client mount the child island is activated for interactivity.
const Icon = ilha.render(() => `<svg>…</svg>`);
const Card = ilha.slot("icon", Icon).render(
({ slots }) => html`
<div class="card">
${slots.icon()}
<p>Card content</p>
</div>
`,
);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.
Finalises the builder and returns an Island. The render function receives { state, derived, input, slots } and must return a string or RawHtml.
const MyIsland = ilha.state("x", 1).render(({ state, input }) => html`<p>${state.x}</p>`);Every island produced by .render() exposes:
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 derivedMounts 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 transitionIn dev mode, double-mounting the same element logs a warning and returns a no-op.
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: "my-island", // 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="my-island" 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 |
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 islandsMounts 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" });Creates a global context signal — a named reactive signal shared across all islands. Identical keys always return the same signal instance.
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.
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><script>…</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 |
| Array | Each item processed recursively (no commas) |
List rendering pattern:
const items = ["apple", "banana", "cherry"];
html`<ul>
${items.map((item) => html`<li>${item}</li>`)}
</ul>`;Marks a string as trusted raw HTML, bypassing escaping when used inside html\``.
import { raw } from "ilha";
raw("<strong>bold</strong>"); // → passes through unescapedA 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.cssis the builder chain method that attaches styles to an island. They are intentionally separate.
Creates a lightweight Standard Schema validator for use with .input() — useful when you don't want a full validation library.
import { type } from "ilha";
const MyIsland = ilha
.input(type((v: unknown) => v as { count: number }))
.render(({ input }) => `<p>${input.count}</p>`);The recommended SSR + hydration pattern uses .hydratable() on the server and ilha.mount() on the client.
import { MyIsland } from "./islands";
const html = await MyIsland.hydratable({ count: 42 }, { name: "my-island", snapshot: true });
return `<!doctype html><html><body>${html}</body></html>`;import { mount } from "ilha";
import { MyIsland } from "./islands";
mount({ "my-island": 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.
server client
────────────────────────────────────── ──────────────────────────────────────────
.hydratable({ count: 42 }, { mount({ "my-island": 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
Key exported types:
import type {
Island,
IslandState,
IslandDerived,
DerivedValue,
SlotAccessor,
HydratableOptions,
OnMountContext,
HandlerContext,
HandlerContextFor,
MountOptions,
MountResult,
} from "ilha";MIT