Research Date: 2026-03-29 Context: BibleWeb atmospheric background rendering — Canvas 2D with OffscreenCanvas offloading in SvelteKit + Svelte 5 runes + TypeScript.
bind:this and the $effect BridgeThe fundamental pattern for Canvas in Svelte 5 is binding the element reference with bind:this and using $effect as the bridge from reactive state to imperative canvas calls. Unlike Svelte 4's onMount, $effect re-runs whenever its tracked dependencies change — making it the natural fit for canvas work that must respond to state.
<script lang="ts">
let canvas = $state<HTMLCanvasElement | null>(null);
let scene = $state<'dawn' | 'day' | 'dusk' | 'night'>('day');
$effect(() => {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Reading `scene` here registers it as a dependency —
// this effect re-runs whenever `scene` changes.
drawBackground(ctx, scene, canvas.width, canvas.height);
});
</script>
<canvas bind:this={canvas} width={800} height={600} />
Key rule: $effect tracks every $state or $derived value that is read during its execution. If you call drawBackground(ctx, scene, ...), scene is now a tracked dependency. The effect re-runs when scene changes but not for unrelated state.
onMount Still Has a RoleonMount remains useful when you only need one-time initialization and do not want automatic re-runs on state change. Use it for wiring up a long-lived animation loop:
<script lang="ts">
import { onMount } from 'svelte';
let canvas = $state<HTMLCanvasElement | null>(null);
let frameId: number;
onMount(() => {
const ctx = canvas!.getContext('2d')!;
function loop() {
render(ctx);
frameId = requestAnimationFrame(loop);
}
frameId = requestAnimationFrame(loop);
// onMount cleanup — returned function runs on destroy
return () => cancelAnimationFrame(frameId);
});
</script>
Pitfall: Do not start a requestAnimationFrame loop inside $effect without guarding it — $effect can re-run mid-loop if a dependency changes, spawning multiple concurrent loops.
Canvas does not exist on the server. Three strategies exist; choose based on how critical the background is to initial render.
browser Guard (Simplest)<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
let canvas = $state<HTMLCanvasElement | null>(null);
onMount(() => {
// Safe — onMount only runs in the browser.
// But `browser` guard makes intent explicit.
if (!browser) return;
initCanvas(canvas!);
});
</script>
{#if browser}
<canvas bind:this={canvas} />
{/if}
ssr: false Per RouteFor pages where the canvas is the entire point of the page (a full-screen atmospheric view), disable SSR at the route level in +page.ts:
// src/routes/study/+page.ts
export const ssr = false;
This renders a lightweight shell HTML on the server and hands off all rendering to the client. Good for interactive study pages; not appropriate for SEO-sensitive content pages.
Wrap the entire canvas component in a dynamic import inside onMount to guarantee the module never executes server-side:
<script lang="ts">
import { onMount } from 'svelte';
import type { ComponentType } from 'svelte';
let AtmosphereComponent = $state<ComponentType | null>(null);
onMount(async () => {
const mod = await import('$lib/atmosphere/AtmosphereCanvas.svelte');
AtmosphereComponent = mod.default;
});
</script>
{#if AtmosphereComponent}
<svelte:component this={AtmosphereComponent} />
{/if}
Recommendation for BibleWeb: Use Strategy A for most components (the browser guard is zero overhead). Use ssr: false on the dedicated study route if the background is a critical visual element. Dynamic imports are best when you want to keep bundle splitting clean.
OffscreenCanvas moves all GPU-bound draw calls off the main thread. For atmospheric particle systems or procedural background generation, this is a significant win — the main thread stays free for UI interactions.
src/lib/workers/atmosphere.worker.ts)// Vite convention: suffix with .worker.ts, import with ?worker
type WorkerIncoming =
| { type: 'init'; canvas: OffscreenCanvas; width: number; height: number }
| { type: 'resize'; width: number; height: number }
| { type: 'setScene'; scene: string };
type WorkerOutgoing =
| { type: 'ready' }
| { type: 'error'; message: string };
let ctx: OffscreenCanvasRenderingContext2D | null = null;
let frameId: number | null = null;
self.onmessage = (e: MessageEvent<WorkerIncoming>) => {
const msg = e.data;
switch (msg.type) {
case 'init': {
ctx = msg.canvas.getContext('2d');
startLoop();
self.postMessage({ type: 'ready' } satisfies WorkerOutgoing);
break;
}
case 'resize': {
if (ctx) {
ctx.canvas.width = msg.width;
ctx.canvas.height = msg.height;
}
break;
}
case 'setScene': {
// Update scene state for the render loop
break;
}
}
};
function startLoop() {
if (!ctx) return;
function render() {
// draw to ctx ...
frameId = requestAnimationFrame(render);
}
frameId = requestAnimationFrame(render);
}
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
// Vite ?worker import — returns constructor, not module
import AtmosphereWorker from '$lib/workers/atmosphere.worker?worker';
let canvas = $state<HTMLCanvasElement | null>(null);
let worker: Worker | null = null;
let scene = $state('dawn');
onMount(() => {
if (!canvas) return;
worker = new AtmosphereWorker();
const offscreen = canvas.transferControlToOffscreen();
// Pass OffscreenCanvas as transferable — second arg is the transfer list
worker.postMessage(
{ type: 'init', canvas: offscreen, width: canvas.width, height: canvas.height },
[offscreen] // <-- transferable: ownership moves to worker
);
return () => {
worker?.terminate();
worker = null;
};
});
// React to scene changes — send message to worker
$effect(() => {
if (!worker) return;
worker.postMessage({ type: 'setScene', scene });
});
</script>
<canvas bind:this={canvas} />
Critical: After calling transferControlToOffscreen(), the main thread can no longer call canvas.getContext() or draw to it directly. All drawing must go through the worker from that point on.
TypeScript Pitfall: Older TypeScript versions (before 4.4) did not recognize OffscreenCanvas as Transferable. Ensure tsconfig.json targets ES2020 or later and includes "lib": ["DOM", "ES2020"].
Browser Support: OffscreenCanvas has been available in all major browsers since 2023. Safari added support in v16.4. No polyfill needed for modern targets; add a 'transferControlToOffscreen' in HTMLCanvasElement.prototype guard if you need older Safari fallback.
$state + $effect as the Reactive-Imperative BridgeThis is the pattern you will use most often. $state holds the app's declarative model; $effect translates model changes into imperative canvas calls.
<script lang="ts">
import { browser } from '$app/environment';
let canvas = $state<HTMLCanvasElement | null>(null);
// Driven by parent props or app state
let { passage, timeOfDay } = $props<{
passage: string;
timeOfDay: 'morning' | 'afternoon' | 'evening' | 'night';
}>();
// Derived value — recalculated whenever props change
let palette = $derived(getPaletteForTime(timeOfDay));
$effect(() => {
if (!canvas || !browser) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Both `palette` and `passage` are tracked dependencies
drawScene(ctx, palette, passage);
});
</script>
$effect for Canvas$effect is designed for side-effects that respond to state. For a continuous requestAnimationFrame loop that updates every frame regardless of state changes, onMount is the right choice — $effect would restart the loop on every dependency change.
Hybrid pattern — combine both:
<script lang="ts">
import { onMount } from 'svelte';
let canvas = $state<HTMLCanvasElement | null>(null);
let palette = $state(defaultPalette);
let frameId: number;
let currentPalette = palette; // mutable ref for the loop to read
// $effect syncs reactive state into a mutable variable
$effect(() => {
currentPalette = palette; // no DOM work here — just copy the value
});
onMount(() => {
const ctx = canvas!.getContext('2d')!;
function loop() {
// Reads currentPalette directly — no reactivity overhead in hot path
renderFrame(ctx, currentPalette);
frameId = requestAnimationFrame(loop);
}
frameId = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameId);
});
</script>
This keeps the hot render loop free from Svelte's reactivity bookkeeping while still reacting to state changes.
Directly using window.innerWidth misses the element's actual layout size and gets the DPR wrong. The correct approach uses ResizeObserver with devicePixelContentBoxSize:
<script lang="ts">
import { onMount } from 'svelte';
let canvas = $state<HTMLCanvasElement | null>(null);
onMount(() => {
if (!canvas) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
// Use devicePixelContentBoxSize for exact physical pixel count
// Falls back to contentBoxSize × devicePixelRatio for older browsers
const dpcbSize = entry.devicePixelContentBoxSize?.[0];
if (dpcbSize) {
canvas!.width = dpcbSize.inlineSize;
canvas!.height = dpcbSize.blockSize;
} else {
const dpr = window.devicePixelRatio ?? 1;
canvas!.width = Math.round(entry.contentBoxSize[0].inlineSize * dpr);
canvas!.height = Math.round(entry.contentBoxSize[0].blockSize * dpr);
}
}
});
// "device-pixel-content-box" gives physical pixel dimensions directly
observer.observe(canvas, { box: 'device-pixel-content-box' });
return () => observer.disconnect();
});
</script>
<canvas
bind:this={canvas}
style="width: 100%; height: 100%; display: block;"
/>
CSS vs. Canvas Dimensions: The width and height attributes control the canvas pixel buffer size. CSS controls display size. Always set style="width: 100%; height: 100%" on the canvas element and set the attributes through JS to match the physical pixel count.
Pitfall: Setting canvas.width or canvas.height clears the canvas. If you use a continuous render loop, this is harmless (the next frame redraws). If you use $effect-driven one-shot drawing, trigger a redraw after resize.
When using a worker, forward resize events through postMessage:
observer.observe(canvas, { box: 'device-pixel-content-box' });
// In the ResizeObserver callback:
worker?.postMessage({ type: 'resize', width: newWidth, height: newHeight });
The worker sets ctx.canvas.width and ctx.canvas.height on the OffscreenCanvas directly.
Monolithic canvas components become hard to maintain. A better pattern separates concerns into a narrow component tree:
<AtmosphereScene> ← Manages OffscreenCanvas + Worker lifecycle
<AtmosphereController> ← Reads app state ($state props), sends messages to worker
<canvas /> ← The actual DOM element
For scenes with a foreground + background distinction (e.g., sky + text overlay), use two stacked canvases:
<!-- AtmosphereScene.svelte -->
<div class="scene-container" style="position: relative;">
<!-- Background: heavy procedural sky — offloaded to worker -->
<canvas
bind:this={bgCanvas}
class="layer bg-layer"
style="position: absolute; inset: 0;"
/>
<!-- Foreground: lighter overlays, particles — main thread -->
<canvas
bind:this={fgCanvas}
class="layer fg-layer"
style="position: absolute; inset: 0; pointer-events: none;"
/>
<!-- UI: Svelte HTML components sit above the canvas layers -->
<div class="content" style="position: relative; z-index: 10;">
<slot />
</div>
</div>
Layer Cake pattern (from the layercake.graphics library): Each canvas layer shares a coordinate system defined by the parent. Background is canvas; interactive overlays can be SVG or HTML layered on top. This gives you native browser hit-testing for interactive elements without needing a canvas-level event system.
Rule of thumb: One canvas per independently-updateable visual layer. Three layers max (background sky, mid particles, UI overlay). More than three canvases on one page is a performance concern.
Svelte's transition:fade, transition:fly, etc. do not work on canvas content — they apply CSS transforms and opacity to the DOM element (the <canvas> tag itself), not to what is drawn inside it. You can fade the entire canvas element in/out, but you cannot use Svelte transitions to animate individual objects drawn on the canvas.
<!-- This fades the whole canvas element — valid but coarse -->
<canvas bind:this={canvas} transition:fade={{ duration: 500 }} />
For smooth canvas animations (scene transitions, particle movement, atmospheric shifts), implement the animation imperatively:
// Scene cross-fade: render old scene to a hidden offscreen, blend with new
function crossFade(
ctx: CanvasRenderingContext2D,
from: SceneSnapshot,
to: SceneSnapshot,
progress: number // 0 → 1
) {
ctx.globalAlpha = 1 - progress;
renderScene(ctx, from);
ctx.globalAlpha = progress;
renderScene(ctx, to);
ctx.globalAlpha = 1;
}
Use Svelte's tweened and spring stores to drive canvas parameters smoothly. These are still valid in Svelte 5:
<script lang="ts">
import { tweened } from 'svelte/motion';
import { cubicInOut } from 'svelte/easing';
const skyBrightness = tweened(0.5, { duration: 2000, easing: cubicInOut });
// When passage changes to a dawn chapter:
function transitionToDawn() {
skyBrightness.set(1.0); // tweened store animates the value
}
$effect(() => {
// $skyBrightness is read here — effect re-runs as tweened animates
// But this re-runs every frame during the tween → beware performance
drawSky(ctx, $skyBrightness);
});
</script>
Caveat: The $effect approach with tweened stores will re-run the effect on every frame of the tween. For smooth 60fps animation, the requestAnimationFrame loop pattern is more efficient — read the tweened store's get() value inside the loop instead.
$effect Return FunctionIn Svelte 5 runes mode, returning a function from $effect is the primary cleanup mechanism. It runs before the effect re-executes (when dependencies change) and when the component unmounts:
<script lang="ts">
let canvas = $state<HTMLCanvasElement | null>(null);
$effect(() => {
if (!canvas) return;
const ctx = canvas.getContext('2d')!;
let frameId: number;
// Guard: only start one loop
function loop() {
render(ctx);
frameId = requestAnimationFrame(loop);
}
frameId = requestAnimationFrame(loop);
// Cleanup: runs on unmount or before re-run
return () => {
cancelAnimationFrame(frameId);
};
});
</script>
onMount Return FunctiononMount also accepts a cleanup return value, and is the right place for one-time setup with cleanup:
onMount(() => {
const worker = new AtmosphereWorker();
const observer = new ResizeObserver(handleResize);
observer.observe(canvas!);
return () => {
cancelAnimationFrame(frameId);
worker.terminate();
observer.disconnect();
// Canvas contexts do not need explicit disposal — GC handles them
// But nulling refs helps GC collect sooner
canvas = null;
};
});
| Resource | Cleanup Method |
|---|---|
requestAnimationFrame |
cancelAnimationFrame(id) |
| Worker | worker.terminate() |
| ResizeObserver | observer.disconnect() |
| Event listeners | element.removeEventListener(...) |
| Tweened/Spring stores | No cleanup needed (GC handles) |
| Canvas context | No explicit dispose — canvas.width = 0 can free GPU memory if needed |
| OffscreenCanvas | Freed when worker terminates |
Svelte 5's dev mode may run $effect twice (similar to React strict mode) to detect side-effect bugs. Guard against double-start:
$effect(() => {
if (!canvas) return;
let running = true;
let frameId: number;
function loop() {
if (!running) return; // Guard stops orphaned loop
render();
frameId = requestAnimationFrame(loop);
}
frameId = requestAnimationFrame(loop);
return () => {
running = false;
cancelAnimationFrame(frameId);
};
});
Given the BibleWeb requirements (atmospheric canvas background, Svelte 5 runes, TypeScript):
src/lib/atmosphere/
AtmosphereCanvas.svelte ← canvas DOM element, ResizeObserver, worker bridge
AtmosphereController.svelte ← reads passage/theme $state, sends scene changes
workers/
atmosphere.worker.ts ← OffscreenCanvas render loop, all draw calls
scenes/
dawn.ts ← scene definition objects (pure data, no Svelte)
dusk.ts
night.ts
AtmosphereCanvas creates the <canvas>, transfers it to the worker via transferControlToOffscreen(), sets up ResizeObserver, and handles cleanup.AtmosphereController holds $state for scene parameters. A $effect watches these and sends postMessage to the worker when they change.requestAnimationFrame loop and all draw calls — the main thread stays fully unblocked.ssr: false on the study route, or a {#if browser} guard around <AtmosphereCanvas>, to prevent server-side errors.