Regions
Timing & nesting
The schedules that decide when an island's JavaScript runs or a hole's HTML arrives, and the one rule for regions inside regions.
On this page
This page is the reference for two things every region depends on: when it wakes, and what happens when regions are nested inside each other.
The schedules
Two keys share one set of schedule words, so you only learn them once. wake carries the schedule for islands, and fetch carries it for server islands (render: 'deferred'):
| Value | When it fires | Good for |
|---|---|---|
load | Immediately after the runtime starts | UI above the fold that must be interactive now |
idle | On requestIdleCallback | Widgets that can wait |
visible | When scrolled into the viewport | All content below the fold |
interaction | On the first pointer, key, or focus inside it | UI that most visitors never touch |
'(media query)' | When the CSS media query matches | UI for mobile only or desktop only |
On an island (where render defaults to static), wake says when the JavaScript runs. On a server island (render: 'deferred'), fetch says when the HTML is fetched. On a server island, interaction makes an on-demand hole that fetches on the first hover or focus inside its fallback. A server island never takes wake, because it has no JavaScript of its own.
<script>
import Now from '$lib/Now.svelte' with { wake: 'load' };
import Later from '$lib/Later.svelte' with { wake: 'idle' };
import Below from '$lib/Below.svelte' with { wake: 'visible' };
import Phone from '$lib/Phone.svelte' with { wake: '(max-width: 600px)' };
</script>Each of the four demos below is a real island with that schedule:
Live since —
Idle after …
--:--:--
In view · —
0px · no match
interaction: JS only for what people use
wake: 'interaction' is the laziest schedule: the island ships no JavaScript until someone uses it. A pointer press, a keystroke or focus inside the region wakes it, and the interaction that woke it is not lost:
- The first click counts. ogygia captures it while the island wakes and replays it once the island is live, so a button, a checkbox or a link activates exactly once.
- Typed text stays. Characters land in the field natively, because it is real HTML. After hydration, the typed value, the selection and the focus are restored, and
bind:syncs. - Hover warms it. When the pointer enters the region, the chunk is prefetched, so the wake is usually immediate by the time the press lands. For an on-demand hole (
render: 'deferred', fetch: 'interaction'), the hover fetches and swaps in one go. Addprefetch: 'idle'to warm its HTML at page idle instead, so the first hover finds it already there (seeprefetchinpresets).
The same applies to every other schedule until the island is live. A load, idle, visible or media island is server markup with no handlers until it wakes, so a press on it before then wakes it at once, and the click is replayed when it is live. A Ctrl, Cmd, Shift or middle click on a link opens it the way the browser normally would, since a replay could not.
One caution: the replayed first event is not a trusted browser gesture, so gesture-gated APIs (window.open, the clipboard, fullscreen) are blocked when called from it. A component that ignores untrusted clicks (if (!event.isTrusted) return, often written to skip its own synthetic clicks) ignores the replay too, so narrow such a guard. A component can detect this case and adapt:
<script>
import { hydratedBy } from 'ogygia';
// 'interaction' when a visitor's input woke it (its first click is a replay), else its schedule:
// 'load' | 'idle' | 'visible' | a media query — or null during SSR.
// Call during setup, like getContext.
const woke = hydratedBy();
</script>
{#if woke === 'interaction'}
<!-- first activation was a replay: render a real <a target="_blank"> instead of window.open -->
{/if}Keep gesture-gated actions away from interaction. Other UI, such as counters, carts, menus, forms and editors, works correctly and costs nothing until someone touches it.
Tuning visible
visible uses an IntersectionObserver. You can widen its trigger margin per import, or set a default for the whole app:
import Chart from '$lib/Chart.svelte' with { wake: 'visible', margin: '200px' };// default margin for every `visible` island
ogygia({ visible: { margin: '0px' } });How the bytes arrive
The wake decides when an island’s code downloads, not only when it runs. None of this needs configuration:
No island code moves before your hero has arrived. Every wake except
interactionwaits until the page has painted and every image or preload you markedfetchpriority="high"has loaded. That is your LCP resource, and ogygia does not guess it. The visitor’s first click or key ends the wait at once (the browser stops measuring LCP there), and so does a 2.5 s cap. A page with no marked image waits only for the paint. Mark your hero like this:<img src="/hero.jpg" fetchpriority="high" alt="…" /> <!-- or, for a CSS background or <picture> hero --> <link rel="preload" as="image" href="/hero.jpg" fetchpriority="high" />Downloads are queued by who is waiting for them. An island or hole on screen downloads at normal priority. One that is not on screen yet (an
idleisland, one a screen ahead of avisibleone, a hole’sprefetch) and the router’s page prefetch download at low priority, a few at a time, so they never crowd out what the visitor sees. Something that scrolls into view moves up the queue. A click never waits, and neither does a hole’s HTML on screen, since it is the content itself.When an island wakes, its whole graph downloads at once. The page carries each island’s chunk list as data, in one small JSON script that names every chunk once. At the wake, the runtime adds a
<link rel="modulepreload">for each chunk the island needs beside the island’s ownimport(), so the browser fetches them in parallel instead of discovering each import only after the previous file has parsed.An island that never wakes downloads nothing. A
visibleisland fetches when it intersects (visible.margininogygia()sets the lead time), anidleone in idle time, a media-query one when the query matches, and aninteractionone on the first pointer-over, focus or touch inside it (the hover warm-up, with the waking click replayed). The router’s hover prefetch warms the next page’s islands the same way.
On a csr = true page, Kit wakes the islands instead of the runtime, so there a load island keeps a fetchpriority="low" modulepreload hint at the end of the body.
Props and the page seed come after the content. Each island’s serialized props sit at the end of the body, in one <script type="application/ogygia-props"> per distinct island keyed by the region’s fingerprint, and never next to the island. The page seed (page.data for islands that read $page) comes last, and only when an island on the page actually reads it. A deferred hole’s <link rel="preload" as="fetch"> stays in the head, because it fetches content rather than code.
Props do not copy what the seed already carries. When the seed ships, an island’s props are written relative to it. A plain object or array that is also a node of page.data, whether the page passed that very object or a clone of it, becomes a short path into the seed instead of a second copy. The island still receives its own copy of the data when it wakes, so nothing it does to its props reaches the seed or another island. A CMS page that hands twelve blocks their slices of one content tree ships the tree once plus twelve paths.
CSS follows the same rule: the render decides. On a csr = false page, the server pass links a stylesheet for each island that actually renders (<link rel="stylesheet" data-ogygia-region-css>, one per sheet per request) and nothing for the rest. A marked import the page never places, or the other blocks of a registry that rendered one of them, cost no stylesheet and no chunk hint. Kit’s own links cover the page’s static CSS (the styles of the page and layout components). Island CSS is deliberately left out of that graph, so a 300-block registry does not turn into 300 render-blocking sheets. Kit’s inlineStyleThreshold governs both channels: a region sheet under the threshold ships as <style data-ogygia-region-css> in the head instead of a blocking <link> (a hole answer’s inlined sheet is hoisted and deduplicated the same way), so twenty small island sheets cost no requests before first paint.
Deferred content that is also interactive
A render: 'deferred' region is content only and never ships JavaScript, so its schedule is fetch. In this example the hole waits until it scrolls into view and costs no request before then:
import Panel from '$lib/Panel.svelte' with { render: 'deferred', fetch: 'visible' };When a fetched hole also needs interactivity, don’t stack two schedules on one import. Nest an ordinary wake island inside the server island’s component instead, as Server islands describes.
Nesting: one rule
To find out what a nested region does, walk up the tree: the closest marked parent decides.
| Nesting | What happens |
|---|---|
| Island on the page | Gets its own JS |
| Island inside an island | Shares the JS of its parent: one interactive tree, not two |
| Lake inside an island | Stays static HTML. The JS of the lake never ships |
| Island inside a lake | Gets its own JS again |
| Server island inside an island | Renders inline as static HTML, like a lake (render: 'deferred' ignored). Its JS never ships, and an island inside it gets its own JS |
| Server island inside a live snippet | Stays deferred: its fallback ships, its HTML arrives on its schedule |
page → island → lake → island → …Because an island inside an island shares one tree, the child’s schedule is ignored and it hydrates with its parent. Dev warns when it drops a schedule that has no effect.
Do / don’t
- Do set islands below the fold to
visible. It costs nothing and saves work. - Do use
idlefor UI that people don’t touch right away. - Don’t try to make a
render: 'deferred'region interactive on its own, because it ships no JavaScript. Nest awakeisland inside it.