Skip to content

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.

npx giget@latest gh:ilhajs/ilha/templates/vite-spa my-app
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.

Start with a function
Write plain components first. Add an island boundary only when the UI needs one.
Grow one capability at a time
Add state, derived values, actions, and lifecycle hooks through one typed builder.
Keep your server
Any HTTP server can return Ilha’s rendered HTML. Client hydration stays opt-in.
Hydrate on demand
Ship plain HTML first. Mount only the independently interactive islands that need JavaScript.

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
signup.tsxtsx
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
signals.tsxtsx
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
product-card.tsxtsx
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.

Pick a template
Open Sandbox

Next

Your first island takes five minutes.

Learn the component-to-island path, then build a typed interactive counter one capability at a time.

Type to search…

↑↓ navigate↵ selectEsc close