Essential patterns for adding 3D to SvelteKit. Copy-paste starting points.
Page Setup (REQUIRED)
Every 3D page needs:
// +page.ts
export const ssr = false; // REQUIRED: No server rendering
export const prerender = false; // REQUIRED: Dynamic content
Basic Vanilla Three.js Scene
<!-- /showcase/3d/basic-scene/+page.svelte -->
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import type * as THREE from 'three';
let container = $state<HTMLDivElement>();
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let renderer: THREE.WebGLRenderer;
let cube: THREE.Mesh;
onMount(async () => {
if (!browser || !container) return;
// Dynamic import (never at top level!)
const THREE = await import('three');
// Scene setup
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(container.clientWidth, container.clientHeight);
container.appendChild(renderer.domElement);
// Add cube
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
cube = new THREE.Mesh(geometry, material);
scene.add(cube);
camera.position.z = 5;
// Animation loop
let frameId: number;
const animate = () => {
frameId = requestAnimationFrame(animate);
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
renderer.render(scene, camera);
};
animate();
// REQUIRED: Cleanup
return () => {
cancelAnimationFrame(frameId);
geometry.dispose();
material.dispose();
renderer.dispose();
};
});
</script>
{#if browser}
<div bind:this={container} class="scene"></div>
{:else}
<div class="loading">Loading 3D scene...</div>
{/if}
<style>
.scene { width: 100%; height: 100vh; }
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
</style>
Basic Threlte Scene
# Add to package.json, restart container
{
"dependencies": {
"@threlte/core": "^8.3.1",
"@threlte/extras": "^9.0.0",
"three": "^0.170.0"
}
}
<!-- +page.svelte -->
<script lang="ts">
import { Canvas } from '@threlte/core';
import Scene from './Scene.svelte';
</script>
<Canvas>
<Scene />
</Canvas>
<style>
:global(canvas) { display: block; }
</style>
<!-- Scene.svelte -->
<script lang="ts">
import { T } from '@threlte/core';
import { OrbitControls } from '@threlte/extras';
import { useFrame } from '@threlte/core';
let rotation = $state(0);
useFrame((state, delta) => {
rotation += delta;
});
</script>
<T.PerspectiveCamera makeDefault position={[0, 0, 5]} />
<T.DirectionalLight position={[5, 10, 5]} />
<T.AmbientLight intensity={0.5} />
<T.Mesh rotation.y={rotation}>
<T.BoxGeometry />
<T.MeshStandardMaterial color="hotpink" />
</T.Mesh>
<OrbitControls />
Reactive Controls Pattern
<script lang="ts">
import type * as THREE from 'three';
// Reactive state
let color = $state('#ff0000');
let wireframe = $state(false);
let material: THREE.MeshStandardMaterial;
// React to changes
$effect(() => {
if (!material) return;
material.color.set(color);
material.wireframe = wireframe;
material.needsUpdate = true;
});
</script>
<div class="controls">
<label>
Color: <input type="color" bind:value={color} />
</label>
<label>
<input type="checkbox" bind:checked={wireframe} />
Wireframe
</label>
</div>
GLTF Model Loading
<script lang="ts">
import { onMount } from 'svelte';
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js';
let { data } = $props();
let progress = $state(0);
let error = $state<string>();
onMount(async () => {
const THREE = await import('three');
const { GLTFLoader } = await import('three/examples/jsm/loaders/GLTFLoader.js');
const loader = new GLTFLoader();
loader.load(
data.model.url,
(gltf: GLTF) => {
scene.add(gltf.scene);
progress = 100;
},
(xhr) => {
progress = (xhr.loaded / xhr.total) * 100;
},
(err) => {
error = 'Failed to load model';
}
);
});
</script>
{#if error}
<div class="error">{error}</div>
{:else if progress < 100}
<div>Loading... {progress.toFixed(0)}%</div>
{/if}
WebGL Detection
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
let hasWebGL = $state<boolean>();
function detectWebGL(): boolean {
if (!browser) return false;
try {
const canvas = document.createElement('canvas');
return !!(window.WebGLRenderingContext &&
(canvas.getContext('webgl') || canvas.getContext('experimental-webgl')));
} catch {
return false;
}
}
onMount(() => {
hasWebGL = detectWebGL();
});
</script>
{#if hasWebGL === false}
<div class="fallback">
<h2>WebGL Not Supported</h2>
<img src={data.thumbnail} alt="Preview" />
</div>
{:else if hasWebGL === true}
<!-- 3D scene here -->
{/if}
Performance Checklist
- Disable SSR:
export const ssr = false - Dynamic import Three.js
- Dispose geometry/materials on unmount
- Use
renderer.dispose()+forceContextLoss() - Cancel animation frame in cleanup
- Lazy load 3D pages (don't import in root layout)
- Compress models (Draco for GLTF)
- Show loading progress for large assets
- Detect device capabilities (mobile vs desktop)
- Provide static fallback images
Common Mistakes
❌ Top-Level Import
<!-- WRONG -->
<script lang="ts">
import * as THREE from 'three'; // Breaks SSR!
</script>
✅ Dynamic Import
<!-- RIGHT -->
<script lang="ts">
import { onMount } from 'svelte';
onMount(async () => {
const THREE = await import('three');
});
</script>
❌ Missing Cleanup
<!-- WRONG -->
<script lang="ts">
onMount(() => {
const renderer = new THREE.WebGLRenderer();
// No cleanup!
});
</script>
✅ Proper Cleanup
<!-- RIGHT -->
<script lang="ts">
onMount(() => {
const renderer = new THREE.WebGLRenderer();
return () => {
renderer.dispose();
renderer.forceContextLoss();
};
});
</script>
❌ Animation in $effect
<!-- WRONG -->
<script lang="ts">
$effect(() => {
const animate = () => {
requestAnimationFrame(animate); // Never stops!
};
animate();
});
</script>
✅ Animation in onMount
<!-- RIGHT -->
<script lang="ts">
onMount(() => {
let frameId: number;
const animate = () => {
frameId = requestAnimationFrame(animate);
// ... render
};
animate();
return () => cancelAnimationFrame(frameId);
});
</script>
Route Structure Template
src/routes/showcase/3d/
├── +layout.svelte # Dark theme, controls overlay
├── +page.svelte # Landing page
├── +page.ts # export const ssr = false
├── basic-scene/
│ ├── +page.svelte # Vanilla Three.js
│ └── +page.ts
├── gltf-viewer/
│ ├── +page.svelte # Model loading
│ ├── +page.server.ts # Model metadata
│ └── +page.ts
└── interactive/
├── +page.svelte # Raycasting, clicks
└── +page.ts
Next Steps
- Read full guide:
/docs/blueprint/3d-integration.md - Create basic scene
- Add OrbitControls
- Load GLTF model
- Add reactive controls
- Optimize performance
- Test WebGL fallback
References
- Full Guide:
/docs/blueprint/3d-integration.md - Threlte Docs: https://threlte.xyz/docs
- Three.js Docs: https://threejs.org/docs