Fits in an AI prompt. At under 1.5 kLOC, the entire library source fits inside a single LLM context window. AI assistants can reason about the full framework when helping you build, giving you more accurate code generation than any full-size framework can offer.
No build step required. React needs a JSX transform, Svelte needs its compiler. Ilha runs from a single import.
Island and app framework. A single interactive widget or an entire SSR application — the same API scales to both.
Familiar if you know Svelte. Signals, reactive state, derived values, and event handling follow Svelte's mental model, minus the compiler.
Ilha accepts any Standard Schema v1 compliant schema library for input validation (e.g. Zod, Valibot, ArkType). Zod is recommended for anything beyond simple islands — see type() for the built-in lightweight alternative.
Any Standard Schema v1 compatible schema (Zod, Valibot, etc.)
Returns a new builder with TInput typed to the schema's output.
Calling .input() resets all previously accumulated state, derived, and event definitions — use it as the first call in the chain.
import { z } from "zod";const counter = ilha .input(z.object({ count: z.number().default(0) })) .render(({ input }) => `<p>${input.count}</p>`);
If invalid props are provided at call-time or mount-time, Ilha throws with a [ilha]-prefixed message:
[ilha] Validation failed: - Expected number, received string
Tip: For the simplest islands, the built-in type() helper avoids a full schema library dependency. For any island with non-trivial validation, use Zod or Valibot.
Initial value or factory function receiving resolved input
Returns a new builder with the state key added to TStateMap.
init can be a plain value (e.g. 0, "hello", []) or a function that receives the resolved input.
State signals are available in render, effect, onMount, on handlers, and derived functions as state.key — a signal accessor that reads (state.key()) and writes (state.key(newValue)) the signal.
const counter = ilha .input(z.object({ count: z.number().default(0) })) .state("count", ({ count }) => count) // initialized from input .state("step", 1) // plain value .render(({ state }) => `<p>${state.count()}</p>`);
Compute a value from state and/or input. Re-computed whenever its reactive dependencies change.
.derived(key, fn)
Parameter
Type
Description
key
string
Name of the derived value
fn
(ctx: DerivedFnContext) => V | Promise<V>
Sync or async factory function
DerivedFnContext:
Property
Type
Description
state
IslandState<TStateMap>
All state signal accessors
input
TInput
Resolved input props
signal
AbortSignal
Aborted when state changes or island unmounts (for async)
Returns a new builder with the derived key added to TDerivedMap.
In render, derived values are accessed via derived.key as a DerivedValue<V>:
interface DerivedValue<T> { loading: boolean; // true while async fn is pending value: T | undefined; error: Error | undefined;}
Handling errors: Check derived.key.error in your render function to surface failures gracefully:
.render(({ derived }) => { if (derived.results.loading) return `<p>Loading…</p>`; if (derived.results.error) return `<p>Error: ${derived.results.error.message}</p>`; return `<ul>${derived.results.value!.map(r => `<li>${r}</li>`).join("")}</ul>`;})
Sync derived:
const island = ilha .state("n", 4) .derived("doubled", ({ state }) => state.n() * 2) .render(({ derived }) => `<p>${derived.doubled.value}</p>`);
Async derived with stale-while-revalidate:
const island = ilha .state("query", "hello") .derived("results", async ({ state, signal }) => { const res = await fetch(`/search?q=${state.query()}`, { signal }); return res.json(); }) .render(({ derived }) => derived.results.loading ? `<p>Loading… (prev: ${derived.results.value ?? "none"})</p>` : `<p>${JSON.stringify(derived.results.value)}</p>`, );
Note: When state changes, the previous async result is preserved in value while loading is true (stale-while-revalidate pattern). The AbortSignal is aborted for superseded requests.
SSR behaviour: Sync derived values resolve immediately during SSR. Async derived values always render with loading: true during SSR unless .hydratable() is used with snapshot: { derived: true }.
Attach a DOM event handler to elements within the island. No-op during SSR.
.on(selectorOrCombined, handler)
Combined @-syntax (recommended):
"[selector]@eventName[:modifier[:modifier]]"
Part
Description
[selector]
CSS selector for target elements inside the island root. Omit for root element.
@eventName
Any HTMLElementEventMap event name (e.g. click, keydown, input)
:modifier
Optional: once, capture, passive
ilha .state("count", 0) .on("[data-inc]@click", ({ state }) => state.count(state.count() + 1)) .on("[data-inc]@click:once", ({ state }) => console.log("first click")) .on("@click", ({ state }) => console.log("root clicked")) .render(({ state }) => `<p>${state.count()}</p><button data-inc>+</button>`);
Dev warning: If the CSS selector provided to .on() matches no elements at mount time, Ilha emits a [ilha]-prefixed console.warn in development. This prevents silent event listener failures that are otherwise hard to debug.
Handler context:
Property
Type
Description
state
IslandState<TStateMap>
State signal accessors
input
TInput
Resolved input props
host
Element
The island root element
target
Element
The element that received the event
event
Typed event (e.g. MouseEvent for click)
The DOM event
Modifiers:
Modifier
Equivalent addEventListener option
once
{ once: true }
capture
{ capture: true }
passive
{ passive: true }
Multiple modifiers can be chained: @click:once:passive.
The once modifier is tracked across re-renders — a handler marked :once fires exactly once per island instance regardless of how many DOM morphs occur between mount and the first event.
Define enter/leave animation hooks for mount and unmount.
.transition(options)
Option
Type
Description
enter
(host: Element) => void | Promise<void>
Called right after mounting
leave
(host: Element) => void | Promise<void>
Called before teardown; may be async
If leave returns a Promise, teardown (event listener removal, effect cleanup) is deferred until the promise resolves.
Note:enter fires immediately on mount. leave only fires when unmount() is explicitly called — it does not fire automatically during navigation or SSR hydration flows. Call unmount() manually whenever you need leave transitions to run.
The object returned by .render() is callable as a function for server-side rendering:
const html = island(props?) // returns string or Promise<string>const html = island.toString(props?) // always returns string (async derived → loading: true)`<section>${island}</section>` // implicit toString, uses schema defaults
Parameter
Description
props
Optional Partial<TInput>. If omitted, schema defaults are used.
.on() handlers and .effect() callbacks are ignored during SSR.
If the island has async derived() functions, calling it as a function returns a Promise<string>; calling .toString() returns a plain string with async derived values showing loading: true.
Throws [ilha] Validation failed if props fail schema validation.
Activate an island on a DOM element for client-side reactivity.
const unmount = island.mount(host, props?)
Parameter
Type
Description
host
Element
The root DOM element for this island instance
props
Partial<TInput>
Optional props. Falls back to data-ilha-props, then data-ilha-state, then schema defaults.
Returns an unmount function directly. Calling it:
Removes all event listeners registered via .on().
Cancels and cleans up all .effect() subscriptions.
Aborts any pending async derived fetches.
Calls all cleanup functions returned from .onMount().
Awaits the .transition({ leave }) hook before full teardown (the function is idempotent — calling it more than once is safe).
const unmount = counter.mount(document.querySelector("#counter")!, { count: 5 });// Later — tear down this specific instance:unmount();
Prop resolution priority (highest → lowest):
Explicit props argument to mount()
data-ilha-state attribute (server-side state snapshot)
data-ilha-props attribute (set by hydratable())
Schema defaults
Dev warning: Calling mount() on an element that is already mounted emits a [ilha]-prefixed console.warn and returns a no-op unmount function. Call the previous unmount() first to avoid memory leaks and duplicate event listeners.
Auto-discover all [data-ilha] elements in the DOM and mount registered islands.
const { unmount } = mount(registry, options?)
Parameter
Type
Description
registry
Record<string, Island>
Map of island name → island instance
options.root
Element
Scope discovery to this element's subtree (default: document.body)
options.lazy
boolean
Use IntersectionObserver to mount islands only when they enter the viewport
Returns{ unmount: () => void } that tears down all discovered instances. When lazy: true, calling unmount() before an island enters the viewport safely cancels the pending observer without leaking listeners.
import { mount } from "ilha";import { counter } from "./islands/counter";import { dropdown } from "./islands/dropdown";const { unmount } = mount({ counter, dropdown });// Tear everything down:unmount();
Malformed data-ilha-props JSON is handled gracefully — a [ilha]-prefixed warning is logged and the element is skipped.
Create or retrieve a globally shared reactive signal identified by a string key.
Client-only. Context signals are not serialized during SSR. If your island reads a context signal on the server, it will receive the initial value. For SSR/hydration to share context state, pass the value as an explicit island prop instead.
const signal = context(key, initial);
Parameter
Type
Description
key
string
Unique string key for the context
initial
T
Initial value (used only on first call for this key)
Returns a signal accessor { (): T; (value: T): void }.
Calling context() with the same key always returns the same signal, regardless of initial value on subsequent calls.
All islands that read the signal will re-render when it is written.
import { context } from "ilha";const theme = context("theme", "light");theme(); // "light"theme("dark"); // triggers re-renders in all subscribed islands
XSS-safe HTML template literal tag that auto-escapes interpolated values.
import { html } from "ilha";html`<p>${userContent}</p>`;
Interpolation behaviour:
Value type
Behaviour
string, number
HTML-escaped
null, undefined
Omitted (empty string)
raw(...) object
Inserted unescaped
Array
Each item processed by the same rules above and concatenated
SlotAccessor
Rendered via .toString(), inserted unescaped
Signal accessor (state.x)
Reads the signal, HTML-escapes the result
Function () => string
Called and result is HTML-escaped
Also strips common leading indentation from multiline templates (dedent).
Array interpolation: Arrays are supported natively — each element is processed individually and results are concatenated. Use raw() items inside the array to output trusted markup, and plain strings for user content that should be escaped: