UI that starts as HTML
Ship interactive HTML without a virtual DOM.
Start with a component. Add typed state and actions when it needs to move. Ilha renders plain HTML everywhere and hydrates only the islands you choose.
import ilha from "ilha";
import { Button, Checkbox, Input, LayerCard } from "areia";
let nextId = 4;
export default ilha
.state("tasks", [
{ id: 1, label: "Ship the landing page", done: true },
{ id: 2, label: "Write unit tests", done: false },
{ id: 3, label: "Update README", done: false },
])
.state("draft", "")
.derived("pending", ({ state }) =>
state.tasks().filter((task) => !task.done)
)
.action("add", (event: SubmitEvent, { state }) => {
event.preventDefault();
const label = state.draft().trim();
if (!label) return;
state.tasks((tasks) => [
...tasks,
{ id: nextId++, label, done: false },
]);
state.draft("");
})
.action("remove", (id: number, { state }) => {
state.tasks((tasks) =>
tasks.filter((task) => task.id !== id)
);
})
.render(({ state, derived, action }) => (
<LayerCard>
<LayerCard.Title>
My Tasks ({derived.pending().length})
</LayerCard.Title>
<LayerCard.Content class="p-0">
<ul class="divide-y divide-areia-border">
{state.tasks().map((task, index) => (
<li
key={task.id}
class="flex items-center gap-2 p-2"
>
<div class="flex-1">
<Checkbox
bind:checked={state.tasks.select(
(tasks) => tasks[index].done
)}
label={task.label}
/>
</div>
<Button
onclick={() => action.remove(task.id)}
size="sm"
>
✕
</Button>
</li>
))}
</ul>
<form
onsubmit={action.add}
class="flex gap-2 border-t border-areia-border p-2"
>
<Input
placeholder="New task…"
bind:value={state.draft}
class="flex-1"
/>
<Button type="submit" disabled={!state.draft()}>
Add
</Button>
</form>
</LayerCard.Content>
</LayerCard>
));Try this tasks island live in the playground.
No compilerTyped actions and signalsNo virtual DOM
Why Ilha
Use components first. Create an island when interaction earns it.
Plain functions compose markup without a runtime boundary. Wrap one withilha() for independent mounting. When it needs local capabilities, expand the shorthand into the builder and add state, actions, or lifecycle hooks.
Syntax
Read the feature from top to bottom.
Typed state, derived values, actions, and markup stay in one builder chain. The interaction is local, portable, and easy to delete.
- Lowercase native events
- Typed reusable actions
- No app shell required
import ilha, { mount } from "ilha";
const Signup = ilha
.state("email", "")
.derived("ready", ({ state }) =>
state.email().includes("@")
)
.action("join", async (event: SubmitEvent, { state }) => {
event.preventDefault();
await fetch("/api/waitlist", {
method: "POST",
body: JSON.stringify({ email: state.email() }),
});
})
.render(({ state, derived, action }) => (
<form class="card" onsubmit={action.join}>
<input
name="email"
bind:value={state.email}
placeholder="you@company.com"
/>
<button disabled={!derived.ready()}>
{action.join.pending ? "Joining…" : "Join waitlist"}
</button>
</form>
));
// Hydrate matching server-rendered Signup hosts.
mount({ Signup });Signals
Signals are functions, not ceremony.
Read a signal by calling it. Write a value or pass an updater. Async derived work cancels when its dependencies change.
- Functional setters
- Abortable async work
- No app-wide render loop
import ilha from "ilha";
const Search = ilha
.state("query", "")
.derived("results", async ({ state, signal }) => {
if (!state.query()) return [];
const res = await fetch(
`/api/search?q=${encodeURIComponent(state.query())}`,
{ signal }
);
return res.json() as Promise<string[]>;
})
.render(({ state, derived }) => (
<section class="card">
<input
name="q"
placeholder="Search…"
bind:value={state.query}
/>
<Results items={derived.results() ?? []} />
</section>
));Rendering
Render each island the way the page needs.
Call .toString() for synchronous HTML, await the island for async SSR, or emit hydratable markup with explicit state snapshots.
- Synchronous HTML
- Awaited server rendering
- Independent hydration
import { mount } from "ilha";
import { ProductCard } from "./product-card";
// Static HTML — instant first paint.
const html = ProductCard.toString({ featured: true });
// Hydrate only where you need interactivity.
const island = await ProductCard.hydratable(
{ featured: true },
{ name: "ProductCard", snapshot: true },
);
// Or render directly into a client-side host.
const host = document.querySelector("#product-card")!;
ProductCard.mount(host, { featured: true });Libraries
Grow the stack only when the product asks.
Start with a single import. Add file-based routes or signal-based shared stores when navigation or shared state shows up — not before.
- File-based routes and dynamic pages
- Shared cart and session state
- Astro islands with client directives
// vite.config.ts
import { pages } from "@ilha/router/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [pages()],
});
// File-based routes under src/pages/
// index.tsx → /
// pricing.tsx → /pricing
// blog/[slug].tsx → /blog/:slug
import { pageRouter, registry } from "ilha:pages/client";
pageRouter.hydrate(registry);Start
Start in your stack. Keep your server.
Pick a minimal starter for Vite or your server runtime. You get TypeScript, SSR, hydration, and a working island without adopting an application framework.
Next
Your first island takes five minutes.
Learn the component-to-island path, then build a typed interactive counter one capability at a time.