AvalonAvalon
GitHub

Islands Architecture

Learn how Avalon's islands system works and how to control hydration.

What is an island?

An island is an interactive component that hydrates on the client. Everything outside an island is static HTML — no JavaScript, no hydration overhead.

The term comes from the "islands architecture" pattern: a sea of static HTML with interactive islands scattered throughout.

The island prop

To make a component an island, import it and add the island prop:

import Counter from '../islands/Counter.tsx';

export default function Page() {
  return (
    <div>
      <Counter island={{ condition: 'on:client' }} />
    </div>
  );
}

The condition field controls when the island hydrates.

Hydration conditions

on:client

Hydrates immediately when the page loads. Use this for components that need to be interactive right away.

<Counter island={{ condition: 'on:client' }} />

on:visible

Hydrates when the island scrolls into the viewport. Uses IntersectionObserver under the hood. Ideal for below-the-fold content.

<Chart island={{ condition: 'on:visible' }} />

on:interaction

Hydrates on the first user interaction (click or hover). The component renders its SSR HTML immediately, then becomes interactive on demand. Great for forms, dropdowns, and other UI that doesn't need to be interactive until the user engages.

<Dropdown island={{ condition: 'on:interaction' }} />

on:idle

Hydrates when the browser is idle using requestIdleCallback. Use this for low-priority widgets that shouldn't compete with critical rendering.

<Analytics island={{ condition: 'on:idle' }} />

media:

Hydrates only when a CSS media query matches. Useful for components that only exist at certain breakpoints.

<MobileMenu island={{ condition: 'media:(max-width: 768px)' }} />

Island placement

Islands are auto-discovered — any component can become an island by adding the island prop. JSX components require the appropriate pragma for their renderer:

/** @jsxImportSource preact */
import { useState } from 'preact/hooks';

export default function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

Sidecar type declarations

Avalon pages use Preact JSX, but islands can be written in any supported framework. To make TypeScript happy when you use a Vue or Svelte component in a Preact page, Avalon auto-generates sidecar .d.ts files next to each island component.

For example, when you import Counter.vue in a page file, TypeScript can't parse .vue files — so it looks for Counter.d.vue.ts instead. Avalon generates this file automatically with a Preact-compatible type signature, including the island prop.

This works transparently for Vue and Svelte because TypeScript has no built-in understanding of .vue or .svelte files and falls back to the sidecar.

Your tsconfig.json needs allowArbitraryExtensions: true for this to work.

Type compatibility

For frameworks that use .ts or .tsx files (Lit and Qwik), sidecars don't help — TypeScript can parse these files natively, so it reads the source directly and ignores any sidecar declaration file.

The problem is that these frameworks export types incompatible with Preact JSX. Qwik's component$() returns a function with a different signature than Preact expects, and Lit components are classes, not functions.

Each of these integrations provides a defineXxxIsland() wrapper — a zero-cost identity function that returns the component unchanged at runtime but provides Preact-compatible types:

// Qwik
import { defineQwikIsland } from '@useavalon/qwik/island';
export default defineQwikIsland(MyQwikComponent);

// Lit
import { defineLitIsland } from '@useavalon/lit/island';
export default defineLitIsland(MyLitComponent);

These wrappers live in a separate island.ts file (not mod.ts) to avoid pulling server-only code into the browser. See the Lit and Qwik framework pages for full examples.