# Ilha > Ilha is a lightweight UI framework under 2,500 lines of code. Simple enough to fit in a single AI context window, powerful enough to build modern interfaces your way. # Ilha for Svelte Developers Source: https://ilha.build/blog/ilha-for-svelte-developers If you know Svelte, Ilha’s fine-grained reactivity should feel familiar. You write function components. You declare `atom()` values. Reads subscribe. Writes rerun the component and morph the DOM. This is not an argument that Ilha is better than Svelte. It is a map from familiar Svelte ideas to Ilha. ## What is Ilha? Ilha is a small isomorphic UI library. A component renders HTML on the server and activates in the browser. You do not turn the whole page into a client app by default. ```tsx twoslash import { atom } from "ilha"; export const Counter = () => { const count = atom(0); return ( ); }; ``` ## `$state` vs `atom()` Svelte reads and writes a variable. Ilha reads with `count()` and writes with `.set()` / `.update()`. In JSX, `{count}` subscribes. ```tsx twoslash import { atom } from "ilha"; export const Counter = () => { const count = atom(0); return ( ); }; ``` ## `$derived` vs `Atom.map` ```tsx twoslash import * as Atom from "effect/unstable/reactivity/Atom"; import { atom } from "ilha"; export const Totals = () => { const count = atom(0); const doubled = atom(Atom.map(count.atom, (n) => n * 2)); return
{doubled}
; }; ``` Async and streamed data use `when` / Stream views, not a derived accessor with `.loading`. ## `$effect` vs `watch()` ```tsx twoslash import { atom, watch } from "ilha"; const Title = () => { const title = atom("Hello"); watch(title, (value) => { document.title = value; }); return{title}
; }; ``` For stream-driven ongoing logic, use `when` with `Atom.toStream` — see [Streams](/guide/ui/streams). For write-site DOM effects, handlers are fine. ## Props Props are ordinary function arguments. There is no `$props()` rune. ## Boundaries A nested function shares the parent component. `mount` / `renderToString` on a function makes it a root. ## SSR `await renderToString(Counter)` for HTML. `mount(host, Counter, { hydrate: true })` to attach events. | Svelte | Ilha | | ------------------- | ---------------------------------------------------- | | `$state` | `atom(value)` | | `$derived` | `atom(Atom.map(...))` or `atom(Atom.transform(...))` | | `$effect` | `watch()` or event handlers | | `bind:value` | `oninput` + `atom` | | `.svelte` component | function component | --- # Astro Source: https://ilha.build/guide/astro `@ilha/astro` is an [Astro](https://astro.build) integration for Ilha. You register it once, then import any component into an `.astro` page and hydrate it with a [client directive](https://docs.astro.build/en/reference/directives-reference/#client-directives). It is a separate package from `ilha`. Add it when you build with Astro. ## Install ```package-install @ilha/astro ``` `astro` and `ilha` are peer dependencies. ## Quick start ```ts twoslash // astro.config.mjs import { defineConfig } from "astro/config"; import ilha from "@ilha/astro"; export default defineConfig({ integrations: [ilha()], }); ``` Set `jsxImportSource` to `ilha` — see [Render and hydrate](/guide/ui/render): ```json { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "ilha" } } ``` ```tsx twoslash // src/islands/counter.tsx import { atom } from "ilha"; export const Counter = ({ start = 0 }: { start?: number }) => { const count = atom(start); return ( ); }; ``` ```astro --- import { Counter } from "../islands/counter"; ---{count}
; }; ``` Derived values use Effect's `Atom.map` or `Atom.transform`: ```tsx twoslash import * as Atom from "effect/unstable/reactivity/Atom"; import { atom } from "ilha"; const Cart = () => { const items = atom([{ n: 1 }, { n: 2 }]); const total = atom( Atom.map(items.atom, (list) => list.reduce((sum, item) => sum + item.n, 0)) ); return{total}
; }; ``` Do not put JSX in an atom. Atoms hold data. Map arrays during render with `items().map(...)`, or paint a Stream for live server data. Use [`watch()`](/guide/ui/state#side-effects) for side effects on atom changes. ## JSX Prefer JSX. Interpolated values escape. Event props are lowercase (`onclick`, `onchange`). ```tsx twoslash import { atom } from "ilha"; const App = () => { const open = atom(false); return ( ); }; ``` Use a plain function for DOM events. You do not need a special action wrapper on the client. ## SSR and hydration `renderToString(component)` paints into a DOM, waits until idle, then returns HTML. By default it wraps the output in `Hello, ilha!
; const Counter = () => { const count = atom(0); return ( ); }; ``` ## Server-side rendering ```tsx twoslash import { atom, renderToString } from "ilha"; const Counter = () => { const count = atom(0); return ( ); }; const html = await renderToString(Counter); ``` `renderToString` waits until in-flight work is idle, then serializes. Pass `{ timeout: ms }` to cut off early. ## Client-side mounting ```tsx twoslash import { mount } from "ilha"; const Counter = () =>Count
; const root = document.getElementById("app"); if (root) { const unmount = mount(root, Counter); } ``` The returned function stops listeners and in-flight work. Call it when you remove the host. --- # Introduction Source: https://ilha.build/guide/getting-started/introduction import { example } from "./introduction.examples"; ilha is a tiny, isomorphic UI library for building reactive components. You render on the server and mount in the browser with signal-based updates. There is no virtual DOM and no compiler. Markup stays close to HTML. ## What ilha is A **component** is a function, async function, or generator that returns a view. The same component produces HTML through `renderToString()` and activates in the browser through `mount()`. You write a function component. You declare `atom()` values inside it. Reads during render subscribe that component; writes rerun it and morph the host. ## Why it exists Most UI stacks force you to choose between simplicity and interactivity. ilha keeps both close together: a small API, direct DOM updates, and one component for server and client. This makes ilha a good fit when you want: - Server-rendered markup. - Small interactive regions. - Explicit state. - No virtual DOM. ## How it feels to use A typical component reads like a small HTML-aware module:Ready
; ``` Add local state with `atom()`: ```tsx twoslash import { atom } from "ilha"; const Status = () => { const message = atom("Ready"); return{message}
; }; ``` A nested function without its own `mount()` / `renderToString()` belongs to the parent component. ## Core ideas ### Isomorphic rendering You produce HTML on the server and activate the same function in the browser. ### Fine-grained reactivity Atoms keep updates local. Changing one value does not rerender an entire application tree. ### Function components You write functions that return JSX. Generators `yield` streams and `yield*` Effects. Async functions can await before they return a view. ### JSX-first authoring Prefer JSX. It escapes interpolations. Use `h()` when you cannot use JSX. ## When to use ilha Use ilha when you want server HTML plus a few interactive regions, not a full client app by default. ## Basic mental model 1. Write a component that returns JSX. 2. Declare `atom()` values you read in the view. 3. Call `renderToString(component)` on the server. 4. Call `mount(element, component, { hydrate: true })` in the browser. --- # Without JSX Source: https://ilha.build/guide/recipes/h Prefer JSX. When a file cannot use the JSX runtime, call `h()`: ```ts twoslash import { atom, h } from "ilha"; const Counter = () => { const count = atom(0); return h( "button", { type: "button", onclick: () => count.update((n) => n + 1), }, "count: ", count ); }; ``` `h(type, props, ...children)` is the same factory JSX uses. There is no `html` tagged template in this API. ## Tagged templates If you want markup that looks like the old html tagged-template helper, look at [@knighted/jsx](https://github.com/knightedcodemonkey/jsx). It is a tagged-template JSX runtime. It is **not** built into Ilha. It is compatible with Ilha **only if** the tag emits `h()` vnodes (`$$ilha: 1`), not DOM nodes or `React.createElement`. The package defaults to React; override its `createElement` / `fragment` to `h` and `Fragment` from `ilha` if that option exists in the version you install. Use lowercase event props (`onclick`, not `onClick`). Ilha only binds all-lowercase `on*` keys. `class` and `className` both work. Prefer real JSX or `h()` for anything you ship. Treat `@knighted/jsx` as an optional authoring layer, not part of the Ilha contract. --- # Oxlint plugin Source: https://ilha.build/guide/recipes/oxlint TypeScript types your props and atoms. It cannot see call order, generator frames, or React-shaped event names. The Oxlint plugin flags those mistakes in the editor and in CI. The plugin ships as `@ilha/router/oxlint`. It is a JavaScript Oxlint plugin — no extra compiler. ## Install Install Oxlint and `@ilha/router` if the app does not already depend on the router. ```package-install npm i -D oxlint @ilha/router ``` ## Enable the plugin Point Oxlint at the export and turn on the rules you want. ```json { "jsPlugins": ["@ilha/router/oxlint"], "rules": { "oxlint-plugin-ilha/no-conditional-primitive": "error", "oxlint-plugin-ilha/no-primitive-outside-component": "error", "oxlint-plugin-ilha/no-instruction-outside-generator": "error", "oxlint-plugin-ilha/prefer-lowercase-events": "error", "oxlint-plugin-ilha/function-in-atom": "error" } } ``` Save this as `.oxlintrc.json` at the repo root. Run `oxlint` in your lint script. The plugin only treats `atom`, `watch`, and `when` as primitives when you import them from `"ilha"`. A router `action()` or a local binding does not match. ## Rules | Rule | Catches | | --- | --- | | `no-conditional-primitive` | `atom()` / `watch()` / `when()` inside `if` or a loop | | `no-primitive-outside-component` | Primitive calls at module top level — there is no fiber there | | `no-instruction-outside-generator` | `when` outside a generator, or without `yield*` | | `prefer-lowercase-events` | `onClick` instead of `onclick` | | `function-in-atom` | `atom(fn)` misread as a derived initializer instead of a stored function — use `atom(Atom.map(...))`, `atom(Atom.transform(...))`, or `atom.lazy(() => fn)` | --- # Server state with PubSub Source: https://ilha.build/guide/recipes/pubsub-state Server islands hold state in server memory. To push changes to every connected client, publish snapshots to an [Effect PubSub](https://effect.website/docs/v4/api/effect/PubSub) and expose one streaming action per module. ## The hub pattern ```tsx twoslash // src/lib/tasks.server.tsx import * as Effect from "effect/Effect"; import * as PubSub from "effect/PubSub"; import * as Stream from "effect/Stream"; import { action } from "oxidejs"; type Task = { id: string; text: string; completed: boolean }; const tasks: Task[] = []; const hub = Effect.runSync(PubSub.unboundedhello
; test("hydrates SSR markup", async () => { const html = await renderToString(App); const el = document.createElement("div"); el.innerHTML = html; // test host; markup is your own render output document.body.append(el); const unmount = mount(el, App, { hydrate: true }); await new Promise((r) => setTimeout(r, 5)); expect(el.textContent).toContain("hello"); unmount(); el.remove(); }); ``` A snapshot-order bug logs a `hydrate mismatch` warning and falls back to a full mount — assert on the warning in CI if hydration is load-bearing for you. ## Related | Topic | Guide | | --------------------- | ----------------------------------------------- | | Render and hydrate | [Render](/guide/ui/render) | | Streams in tests | [Streams](/guide/ui/streams) | | Server islands in dev | [Server islands](/guide/routing/server-islands) | --- # View transitions Source: https://ilha.build/guide/recipes/view-transitions You animate between UI states with the browser View Transition API. Ilha does not wrap it. Call `document.startViewTransition()` and update an atom inside the callback. ```tsx twoslash import { atom } from "ilha"; export default function Card() { const checked = atom(false); const toggle = () => { document.startViewTransition(() => { checked.update((value) => !value); }); }; return ( ); } ``` The callback must update state synchronously so the browser can snapshot both sides of the transition. --- # Showcase Source: https://ilha.build/guide/resources/showcase export const SHOWCASE = [ { title: "Ilha Website", image: "/showcase-ilha.jpg", link: "https://ilha.build", }, { title: "Areia UI", image: "/showcase-areia.jpg", link: "https://areia.ilha.build", }, { title: "thojensen.com", image: "/showcase-thojensen.jpg", link: "https://thojensen.com", }, ];{error.message}
{children}