Five methods, from zero-code to full developer control. Pick the one that fits your situation, then use the Interactive Playground to customize your embed.
You need a StorySplat account with at least one scene that has been uploaded (saved to the cloud). If your scene is only saved locally, click the Upload button in the editor first.
Your Scene ID is a unique code that identifies your scene (like abc123xyz). Here's how to find it:
Open your scene, click Upload (or Update), and look for the Developer Integration section. Your Scene ID is shown there with a Copy button.
Go to your scene on discover.storysplat.com, click the Embed button, and the embed code (containing your Scene ID) will be displayed.
Option A: Go to your scene on discover.storysplat.com, click Embed, select the iframe tab, and click Copy.
Option B: Write it yourself using your Scene ID:
<iframe src="https://discover.storysplat.com/api/v2-html/YOUR_SCENE_ID" width="100%" height="500" frameborder="0" allow="accelerometer; gyroscope; xr-spatial-tracking" allowfullscreen></iframe>
Replace YOUR_SCENE_ID with your actual Scene ID.
+ button to add a block, search for Custom HTML+ to add a block<>)Look for options labeled "Custom HTML", "Embed code", "HTML block", or "Code injection" and paste the iframe code there.
Create a file called index.html and paste this:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>My 3D Scene</title> <style> * { margin: 0; padding: 0; } html, body { width: 100%; height: 100%; } #viewer { width: 100%; height: 100%; } </style> </head> <body> <div id="viewer"></div> <!-- Load the StorySplat viewer from CDN --> <script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script> <script> // Replace YOUR_SCENE_ID with your actual Scene ID StorySplatViewer.createViewerFromSceneId( document.getElementById('viewer'), 'YOUR_SCENE_ID' ); </script> </body> </html>
Just add these two pieces wherever you want the scene:
<!-- 1. Load the viewer (add once per page) --> <script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script> <!-- 2. The viewer container + initialization --> <div id="viewer" style="width: 100%; height: 500px;"></div> <script> StorySplatViewer.createViewerFromSceneId( document.getElementById('viewer'), 'YOUR_SCENE_ID' ); </script>
Change height: 500px to whatever you want. Use height: 100vh for full screen.
StorySplatViewer.createViewerFromSceneId( document.getElementById('viewer'), 'YOUR_SCENE_ID', { autoPlay: true, // Auto-start the tour revealEffect: 'medium', // Fade-in: 'fast', 'medium', 'slow', 'none' lazyLoad: true, // Show thumbnail first, load on click lazyLoadButtonText: 'Start Tour' // Text on the start button } );
npm install storysplat-viewer
import { createViewerFromSceneId } from 'storysplat-viewer'; const viewer = await createViewerFromSceneId( document.getElementById('viewer'), 'YOUR_SCENE_ID' ); // Control the viewer programmatically viewer.play(); // Auto-play through waypoints viewer.pause(); // Pause viewer.nextWaypoint(); // Next waypoint viewer.goToWaypoint(2); // Jump to waypoint #3 (0-indexed) viewer.on('ready', () => console.log('Loaded!')); viewer.on('waypointChange', ({ index }) => console.log('Waypoint', index));
npm or yarn to manage packages. If you don't know what npm is, use Method 1 or Method 2 instead.
npm install storysplat-viewer
Find it in the StorySplat editor or Discover page (see "Before You Start" above).
import { useEffect, useRef } from 'react'; import { createViewerFromSceneId } from 'storysplat-viewer'; function StorySplatScene({ sceneId }) { const containerRef = useRef(null); const viewerRef = useRef(null); useEffect(() => { let mounted = true; async function initViewer() { if (!containerRef.current) return; try { const viewer = await createViewerFromSceneId( containerRef.current, sceneId ); if (mounted) { viewerRef.current = viewer; } else { viewer.destroy(); } } catch (error) { console.error('Failed to create viewer:', error); } } initViewer(); return () => { mounted = false; viewerRef.current?.destroy(); }; }, [sceneId]); return ( <div ref={containerRef} style={{ width: '100%', height: '500px' }} /> ); } export default StorySplatScene;
Use it in any page:
import StorySplatScene from './StorySplatScene'; function App() { return ( <div> <h1>My Website</h1> <StorySplatScene sceneId="YOUR_SCENE_ID" /> </div> ); }
<template> <div ref="container" :style="{ width: '100%', height: '500px' }" /> </template> <script setup> import { ref, onMounted, onUnmounted, watch } from 'vue'; import { createViewerFromSceneId } from 'storysplat-viewer'; const props = defineProps({ sceneId: String }); const container = ref(null); let viewer = null; async function initViewer() { if (viewer) { viewer.destroy(); viewer = null; } if (!container.value || !props.sceneId) return; try { viewer = await createViewerFromSceneId( container.value, props.sceneId ); } catch (err) { console.error('Failed to create viewer:', err); } } onMounted(() => initViewer()); onUnmounted(() => viewer?.destroy()); watch(() => props.sceneId, () => initViewer()); </script>
Use it in any page:
<template> <h1>My Website</h1> <StorySplatScene sceneId="YOUR_SCENE_ID" /> </template> <script setup> import StorySplatScene from './StorySplatScene.vue'; </script>
<script> import { onMount, onDestroy } from 'svelte'; import { createViewerFromSceneId } from 'storysplat-viewer'; export let sceneId; let container; let viewer = null; onMount(async () => { try { viewer = await createViewerFromSceneId(container, sceneId); } catch (err) { console.error('Failed to create viewer:', err); } }); onDestroy(() => viewer?.destroy()); </script> <div bind:this={container} style="width: 100%; height: 500px;" />
import { createViewerFromSceneId } from 'storysplat-viewer'; const container = document.getElementById('viewer'); try { const viewer = await createViewerFromSceneId( container, 'YOUR_SCENE_ID' ); // Control the viewer programmatically viewer.play(); // Auto-play through waypoints viewer.pause(); // Pause viewer.nextWaypoint(); // Next waypoint viewer.goToWaypoint(2); // Jump to waypoint #3 (0-indexed) // Listen for events viewer.on('ready', () => console.log('Loaded!')); viewer.on('waypointChange', ({ index }) => console.log('Waypoint', index)); } catch (err) { console.error('Failed to load scene:', err); }
Pass an options object as the third argument to createViewerFromSceneId:
const viewer = await createViewerFromSceneId(container, sceneId, { autoPlay: true, // Auto-start the tour revealEffect: 'medium', // 'fast', 'medium', 'slow', 'none' lazyLoad: true, // Show thumbnail first, load on click lazyLoadButtonText: 'Start Tour', // Button text });
Once you have a viewer instance, you can control it programmatically:
| Method | What it does |
|---|---|
viewer.play() | Auto-play through waypoints |
viewer.pause() | Pause auto-play |
viewer.nextWaypoint() | Go to the next waypoint |
viewer.prevWaypoint() | Go to the previous waypoint |
viewer.goToWaypoint(n) | Jump to waypoint #n (0-indexed) |
viewer.destroy() | Clean up the viewer (call on unmount!) |
viewer.on('ready', () => { /* viewer is fully loaded */ }); viewer.on('loaded', (data) => { /* 3D data finished loading */ }); viewer.on('waypointChange', ({ index, name }) => { console.log('Now at waypoint', index, name); });
viewer.destroy() to free memory. The React and Vue examples above do this automatically in their cleanup functions.
In the StorySplat editor, click Export > Self-Hosted Export. A .zip file will download.
Windows: Right-click > "Extract All..." | Mac: Double-click the file
You'll get a folder like this:
my-scene-self-hosted/
index.html <-- Ready-to-use webpage (already works!)
server.js <-- Optional Node.js server
README.md
scenes/
my-scene/
scene.json <-- Scene configuration
*.ksplat <-- 3D model file(s)
lod/ <-- Streaming data (if applicable)
assets/ <-- Images, videos, audio
other-scene/ <-- Additional scenes (if portals are used)
scene.json
*.ksplat
lod/
assets/
You need a local server. Pick one:
cd ~/Downloads/my-scene-self-hosted
python3 -m http.server 8000
# Open http://localhost:8000
cd ~/Downloads/my-scene-self-hosted
npx serve .
# Open the URL it shows you
file:// for security. You must use a local server. See Running a Local Server below.
Upload the entire folder to your web host:
| Platform | How |
|---|---|
| Netlify | Drag the folder onto app.netlify.com/drop |
| Vercel | Run npx vercel in the folder |
| GitHub Pages | Push to a repo, enable Pages in Settings |
| Shared hosting | Upload via cPanel or FTP |
If you want to integrate into your own page instead of using the exported index.html:
scenes/my-scene/ folder into your project<script src="https://cdn.jsdelivr.net/npm/storysplat-viewer@2/dist/storysplat-viewer.bundled.umd.js"></script> <div id="viewer" style="width:100%; height:100vh;"></div> <script> // Fetch local scene data, then create the viewer fetch('./scenes/my-scene/scene.json') .then(function(r) { return r.json(); }) .then(function(sceneData) { StorySplatViewer.createViewer( document.getElementById('viewer'), sceneData ); }); </script>
createViewer (not createViewerFromSceneId) when loading local scene data.
import { createViewer } from 'storysplat-viewer'; const response = await fetch('/scenes/my-scene/scene.json'); const sceneData = await response.json(); // createViewer is synchronous -- no await needed const viewer = createViewer( document.getElementById('viewer'), sceneData ); viewer.play(); viewer.on('ready', () => console.log('Loaded from local files!'));
If your scene uses portals (links to other scenes), the self-hosted export automatically bundles all linked scenes. Each portal-linked scene gets its own folder:
scenes/
main-scene/
scene.json
*.ksplat
linked-scene-a/
scene.json
*.ksplat
linked-scene-b/
scene.json
*.ksplat
By default, when a user clicks a portal, the viewer attempts to fetch scene data from the StorySplat API. For self-hosted deployments, you need to intercept this by registering a portalActivated event listener.
The key mechanism: if the viewer detects that a portalActivated listener is registered, it will skip its built-in API fetch and defer navigation entirely to your code. This means you are responsible for loading the target scene and creating a new viewer.
portalActivated EventWhen a portal is clicked, the viewer emits a portalActivated event with the following data object:
{
portalId: "abc123", // Unique ID of the portal that was clicked
targetSceneId: "xyz789", // ID of the destination scene (matches folder name)
targetSceneName: "Lobby" // Display name of the destination scene
}
The generated index.html handles portal navigation automatically. If you're building your own integration, listen for the portalActivated event and load the target scene from your local files:
let viewer = createViewer(container, initialSceneData); function handlePortal(data) { // data.targetSceneId matches a folder name under scenes/ fetch(`./scenes/${data.targetSceneId}/scene.json`) .then(function(r) { return r.json(); }) .then(function(sceneData) { viewer.destroy(); viewer = createViewer(container, sceneData); // Re-register the listener on the new viewer instance viewer.on('portalActivated', handlePortal); }); } // Registering this listener tells the viewer to skip its built-in API fetch viewer.on('portalActivated', handlePortal);
A more robust implementation with a folder mapping, loading indicator, and error handling:
// Map scene IDs to their local folder paths // (exported index.html generates this as SCENE_FOLDERS) const SCENE_FOLDERS = { 'scene-id-abc': 'main-scene', 'scene-id-xyz': 'linked-scene-a', 'scene-id-123': 'linked-scene-b', }; const container = document.getElementById('viewer'); let viewer = null; async function loadScene(sceneId) { // Look up the local folder for this scene ID const folder = SCENE_FOLDERS[sceneId]; if (!folder) { console.error('No local folder found for scene:', sceneId); return; } // Show a loading indicator (optional) container.style.opacity = '0.5'; try { // Fetch scene data from local files const response = await fetch(`./scenes/${folder}/scene.json`); const sceneData = await response.json(); // Destroy the current viewer if (viewer) viewer.destroy(); // Create a new viewer with the target scene viewer = createViewer(container, sceneData); // Re-register portal handler on the new viewer viewer.on('portalActivated', function(data) { loadScene(data.targetSceneId); }); viewer.on('ready', function() { container.style.opacity = '1'; }); } catch (err) { console.error('Failed to load scene:', err); container.style.opacity = '1'; } } // Load the initial scene loadScene('scene-id-abc');
portalActivated listener every time you create a new viewer instance. Each destroy() + createViewer() cycle creates a fresh event system.
index.html already includes this logic with a SCENE_FOLDERS mapping generated from your scene data. You only need custom portal handling if you're building your own page.
Follow Steps 1-2 from Method 4.
Easiest: drag the folder onto Netlify Drop. You'll get a URL like https://cool-name-abc.netlify.app. Open it to confirm it works.
Paste this on your website, replacing the URL:
<iframe src="https://your-hosted-url.netlify.app" width="100%" height="600" frameborder="0" allow="accelerometer; gyroscope; xr-spatial-tracking; fullscreen" allowfullscreen style="border: none; border-radius: 8px;"></iframe>
Paste this using the same platform instructions from Method 1 (WordPress Custom HTML, Wix Embed Code, etc.).
Try it live! Enter your Scene ID, adjust the styling, and copy the generated code.
Enter your Scene ID below to load a live preview
Or use the demo Scene ID to try it out
Enter your Scene ID below to load a live preview
Uses the StorySplat viewer library directly
| 1: iframe | 2: Script Tag | 3: npm Package | 4: Self-Hosted | 5: Self-Host + iframe | |
|---|---|---|---|---|---|
| Difficulty | Easiest | Easy | Developer | Moderate | Easy-Moderate |
| Coding? | None | A little HTML | JS framework | HTML + server | None on your site |
| Files on | StorySplat | StorySplat | StorySplat | Your server | Netlify / GitHub |
| Auto-updates? | Yes | Yes | Yes | No | No |
| Offline? | No | No | No | Yes | No |
| Analytics? | Yes | Yes | Yes | No | No |
| API control? | No | Limited | Full | Full | No |
When you double-click an HTML file, your browser uses file://. Browsers block loading 3D files from file:// for security. A local server uses http:// instead, which lets everything load properly.
| Tool | Installed? | Command | URL |
|---|---|---|---|
| Python 3 | Yes (Mac/Linux) | python3 -m http.server 8000 | localhost:8000 |
| npx serve | If Node.js | npx serve . | localhost:3000 |
| PHP | Sometimes | php -S localhost:8000 | localhost:8000 |
| VS Code | Extension | Click "Go Live" | Auto-opens |
Mac: Right-click the folder in Finder > New Terminal at Folder. Or open Terminal and drag the folder into it after typing cd .
Windows: Open File Explorer, click the address bar, type cmd, press Enter.
<div> has a height set (e.g., height: 500px). No height = no space to render.You're opening the file directly instead of through a server. See Running a Local Server.
</iframe>.