Skip to content

Regions

Client islands

Mark an import with `wake` and that component ships JS on a schedule. The binding becomes a portable island you can pass around.

On this page

A client island is a component that becomes interactive. You mark its import with wake, and ogygia ships JS for that component on a schedule. The rest of the page stays server HTML with zero client JS.

When to use

Reach for a client island whenever a piece of the page needs to respond to the user: a counter, a menu, a form widget, a chart. If it never needs JS, leave it unmarked (it stays HTML) or make it a lake.

<script>
  import Counter from './Counter.svelte' with { wake: 'load' };
</script>

<Counter start={3} />

The rest of the page is untouched. Only Counter gets JS, and only on the schedule you named.

The schedules

wake takes a timing word that decides when the island’s JS runs:

ValueJS runs
loadright away
visiblewhen scrolled into view
interactionon the first pointer / key / focus inside it: the waking click replays, typing survives

There are two more (idle and CSS media queries), plus visible margin tuning and the nesting rules. The full schedule reference lives in Timing & nesting.

Each demo below is a real island on this page, marked with a different schedule. Reload with JS disabled and the markup is still here; the widgets just stay static.

wake: 'load'

Live since —

wake: 'idle'

Idle after …

--:--:--

wake: 'visible'

In view · —

The Live since … / Idle after … / In view … stamps are written by each island’s $effect after it wakes, so they prove the component actually hydrated rather than shipping a baked value. The idle and visible ones wake later than load.

The binding is the island

When you mark an import, ogygia rewrites the binding itself into a virtual island wrapper. The template still says <Counter />, but Counter is now a portable component you can use anywhere a Svelte component goes: assign it, pass it around, render it in a list.

<script>
  import A from './A.svelte' with { wake: 'load' };
</script>

<A start={3} />

<!-- dynamic -->
{@const Active = A}
<Active start={3} />

<!-- in a list -->
{#each [{ Comp: A, props }] as { Comp, props }}
  <Comp {...props} />
{/each}

No <svelte:component> needed. This is Svelte 5. Because the marked import is a normal component value, islands compose the way Svelte components already do. There is no special island slot or registry to learn.

Dedupe by identity

Wrappers are deduped by component path + strategy/options, not by tag site or host. The same marked import used a thousand times across a page produces one wrapper module and one client entry chunk. Every instance shares that entry URL, and each still gets its own region and props payload at SSR (a deferred island’s signature stays per-instance).

So <A /> × 1000 ships one module, not one thousand.

Here is one marked import rendered three times on this page. All three share a single client entry chunk, yet each hydrates into its own independent state:

Live since —

Live since —

Live since —

Props, not children

Props are real Svelte props into the wrapper, serialized with devalue for the island payload. So they must be serializable: numbers, strings, plain objects, arrays. What cannot cross the boundary:

  • Functions (an onSave callback): devalue can’t carry a closure. Do the work inside the island, or call a remote function.
  • Host children / snippets: put the island’s UI and its lakes inside the island’s own .svelte file. The one exception is the reserved ogygiaFallback snippet on a server island.
  • Live class instances: unless the class opts in as shared state.

See Constraints for the full list of what crosses the boundary and what does not.

A worked example: a cart badge

A header badge that reflects a cart, hydrated on load so it is live immediately:

<script>
  let { count = 0 } = $props();
</script>

<span class="badge">{count}</span>
<script>
  import CartBadge from '$lib/CartBadge.svelte' with { wake: 'load' };
</script>

<CartBadge count={items.length} />

To let a separate “Add to cart” island update this badge, the two share one live object. See Shared state.

The boot island: run code, not UI

On a csr = false page the +page.svelte / +layout.svelte script never runs in the browser. Top-level onMount, $effect, listeners, telemetry, store hydration are all inert (see Constraints). An island is the only place client code runs, so when you need to run something rather than render something, make an island that renders nothing. That is a boot island: a headless component whose only job is its $effect.

<script>
  let { dsn } = $props();

  $effect(() => {
    const t = init_telemetry(dsn);
    return () => t.stop();   // teardown runs when the island unmounts
  });
</script>
<!-- no markup -->
<script>
  import Telemetry from '$lib/Telemetry.svelte' with { wake: 'load' };
</script>

<Telemetry dsn={data.dsn} />
{@render children()}
  • Values are props. The page script is inert, so hand the island what it needs as serializable props (dsn={data.dsn}). It is the same boundary rule as any island.
  • Lifecycle follows the schedule. A page-level boot island re-runs its $effect on each navigation (the page remounts). A layout-level one should usually persist across navigation instead of restarting a socket every route. Add keep so its live self relocates onto the new page rather than remounting:
import Telemetry from '$lib/Telemetry.svelte' with { wake: 'load', keep: 'telemetry' };

A module store often needs no boot at all

Top-level code in a .svelte.ts module runs the moment the first island imports it. If some island already reads your store, the store initializes itself. You only need a boot island for effects that must run even on pages with no islands (site-wide analytics, a global keydown listener).

Do / don’t

DoDon’t
Default anything below the fold to wake: 'visible'. It is free performancePass callbacks or class instances as props expecting them to work. Move the logic inside, or reach for a remote function or shared state
Keep islands small and focused. The smaller the island, the less JS shipsWrap the whole page in one island. That is just csr = true with extra steps