How to Put Your 3D Scene on a Website

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.

Which method should I use?

Method 1
Copy Embed Code
"I just want to paste something into Wix, Squarespace, WordPress, or Shopify."
Method 2
Script Tag + Scene ID
"I have a website and can edit its HTML. I want it to always show my latest edits."
Method 3
NPM Package (React, Vue, etc.)
"I'm building with React, Next.js, Vue, or another framework and want to install via npm."
Method 4
Self-Hosted Export
"I want to download everything and host it myself, with no dependency on StorySplat."
Method 5
Self-Hosted + iframe
"I want self-hosted files, but I can only use embeds/iframes on my site."

Before You Start

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.

Finding Your Scene ID

Your Scene ID is a unique code that identifies your scene (like abc123xyz). Here's how to find it:

From the Editor

Open your scene, click Upload (or Update), and look for the Developer Integration section. Your Scene ID is shown there with a Copy button.

From the Discover Page

Go to your scene on discover.storysplat.com, click the Embed button, and the embed code (containing your Scene ID) will be displayed.

1
Copy the Embed Code from StorySplat
Zero coding. Copy, paste, done. Works like embedding a YouTube video.
Easiest ~2 minutes StorySplat hosts everything

Step 1: Get the Embed Code

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:

HTML
<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.

Step 2: Paste It on Your Website

  1. Edit the page where you want the scene
  2. Click the + button to add a block, search for Custom HTML
  3. Paste the embed code
  4. Click Publish or Update
  1. Open the Wix Editor
  2. Click Add (+) > Embed Code > Embed HTML
  3. Paste the embed code in the HTML box
  4. Drag the corners to resize, then click Publish
  1. Edit your page and click + to add a block
  2. Choose Code block
  3. Paste the embed code
  4. Click Apply, then Save
  1. Go to Online Store > Pages (or edit a section)
  2. Click the HTML editor button (<>)
  3. Paste the embed code
  4. Click Save

Look for options labeled "Custom HTML", "Embed code", "HTML block", or "Code injection" and paste the iframe code there.

Pros
  • Zero coding required
  • Scene auto-updates when you edit
  • View analytics tracked
Cons
  • Requires internet connection
  • Depends on StorySplat servers
  • Less control over the viewer
2
Add the Viewer to Your Website with a Scene ID
Load the viewer directly on your page (not in an iframe). StorySplat hosts the files; you get more control.
Easy ~10 minutes A little HTML

Full-Page Example

Create a file called index.html and paste this:

index.html
<!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>
Double-click to open Save the file and double-click it to open in your browser. If the scene doesn't appear, try using a local server instead.

Adding to an Existing Website

Just add these two pieces wherever you want the scene:

HTML snippet
<!-- 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.

Customization Options

Options example
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
  }
);
For Developers: Using npm instead of a script tag
Terminal
npm install storysplat-viewer
JavaScript
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));
Pros
  • Always shows latest edits
  • Analytics tracked automatically
  • Full API for programmatic control
  • More control than iframe
Cons
  • Requires internet
  • Depends on StorySplat servers
  • Requires editing HTML
3
NPM Package in React, Vue, or Any Framework
Install the npm package and use it in your framework project. StorySplat hosts the files; you get full programmatic control.
Developer ~10 minutes React / Vue / Angular / Svelte / Next.js
Who is this for? This method is for developers who are building a website with a JavaScript framework (React, Vue, Next.js, Svelte, Angular, etc.) and use npm or yarn to manage packages. If you don't know what npm is, use Method 1 or Method 2 instead.
Install the package
Terminal
npm install storysplat-viewer
Get your Scene ID

Find it in the StorySplat editor or Discover page (see "Before You Start" above).

Create the viewer component
StorySplatScene.jsx
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:

App.jsx
import StorySplatScene from './StorySplatScene';

function App() {
  return (
    <div>
      <h1>My Website</h1>
      <StorySplatScene sceneId="YOUR_SCENE_ID" />
    </div>
  );
}
StorySplatScene.vue
<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:

App.vue
<template>
  <h1>My Website</h1>
  <StorySplatScene sceneId="YOUR_SCENE_ID" />
</template>

<script setup>
import StorySplatScene from './StorySplatScene.vue';
</script>
StorySplatScene.svelte
<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;"
/>
main.js (ES Module)
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);
}

Customization Options

Pass an options object as the third argument to createViewerFromSceneId:

Options example
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
});

Viewer API

Once you have a viewer instance, you can control it programmatically:

MethodWhat 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!)

Events

Events
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);
});
Important: Always call destroy() When your component unmounts (user navigates away, etc.), call viewer.destroy() to free memory. The React and Vue examples above do this automatically in their cleanup functions.
Pros
  • Full programmatic control (play, pause, events)
  • Works with any framework
  • Always shows latest scene edits
  • TypeScript types included
  • Analytics tracked automatically
Cons
  • Requires npm / a build tool
  • Developer knowledge needed
  • Requires internet (StorySplat-hosted files)
4
Self-Hosted Export (Full Control)
Download all files. Host everything yourself. No dependency on StorySplat after export.
Moderate ~20 minutes Requires a web server
Download the Self-Hosted Export

In the StorySplat editor, click Export > Self-Hosted Export. A .zip file will download.

Extract the ZIP

Windows: Right-click > "Extract All..."  |  Mac: Double-click the file

You'll get a folder like this:

Folder structure
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/
Test It Locally

You need a local server. Pick one:

Python (Mac/Linux have it built in)
cd ~/Downloads/my-scene-self-hosted
python3 -m http.server 8000
# Open http://localhost:8000
Node.js
cd ~/Downloads/my-scene-self-hosted
npx serve .
# Open the URL it shows you
Can't just double-click the file! Browsers block loading 3D files from file:// for security. You must use a local server. See Running a Local Server below.
Deploy to a Real Website

Upload the entire folder to your web host:

PlatformHow
NetlifyDrag the folder onto app.netlify.com/drop
VercelRun npx vercel in the folder
GitHub PagesPush to a repo, enable Pages in Settings
Shared hostingUpload via cPanel or FTP
Using the scene.json in your own project

If you want to integrate into your own page instead of using the exported index.html:

  1. Copy the scenes/my-scene/ folder into your project
  2. Add the viewer to your page:
HTML
<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>
Key difference: Use createViewer (not createViewerFromSceneId) when loading local scene data.
For Developers: npm with self-hosted files
JavaScript
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!'));
Handling Portals in Multi-Scene Exports

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:

Multi-scene folder structure
scenes/
  main-scene/
    scene.json
    *.ksplat
  linked-scene-a/
    scene.json
    *.ksplat
  linked-scene-b/
    scene.json
    *.ksplat

How Portal Navigation Works

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.

The portalActivated Event

When a portal is clicked, the viewer emits a portalActivated event with the following data object:

Event data properties
{
  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
}

Basic Implementation

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:

Basic portal handling
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);

Complete Example with Loading State & Error Handling

A more robust implementation with a folder mapping, loading indicator, and error handling:

Complete portal 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');
Important: You must re-register the portalActivated listener every time you create a new viewer instance. Each destroy() + createViewer() cycle creates a fresh event system.
Tip: The exported 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.
Pros
  • Fully independent
  • Works offline
  • Full file control
Cons
  • Must re-export to update
  • No analytics
  • Large file sizes
5
Host the Export + Embed via iframe
Self-hosted files, but your site only supports iframes. Host on Netlify/GitHub, then iframe it.
Easy-Moderate ~15 minutes
Export and extract

Follow Steps 1-2 from Method 4.

Upload to a free host

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.

Embed with an iframe

Paste this on your website, replacing the URL:

HTML
<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.).

Pros
  • Full file control
  • Works on any embed platform
  • Independent from StorySplat
Cons
  • Two things to manage
  • Must re-upload to update
  • No analytics

Interactive Playground

Try it live! Enter your Scene ID, adjust the styling, and copy the generated code.

Embed Playground
Customize your embed and see changes in real time

Enter your Scene ID below to load a live preview

Or use the demo Scene ID to try it out

Scene
Size
Width
Height
400px
Aspect Ratio Lock
Styling
Border Radius
12px
Shadow
Border
Background
#000000
Quick Presets
Generated Embed Code

        

Enter your Scene ID below to load a live preview

Uses the StorySplat viewer library directly

Scene
Container Size
Width
Height
Border Radius
0px
Viewer Options
Auto-Play Tour
Reveal Effect
Lazy Load
Quick Presets
Generated Code

        

Comparison

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

Running a Local Server

Why Do I Need a Server?

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.

ToolInstalled?CommandURL
Python 3Yes (Mac/Linux)python3 -m http.server 8000localhost:8000
npx serveIf Node.jsnpx serve .localhost:3000
PHPSometimesphp -S localhost:8000localhost:8000
VS CodeExtensionClick "Go Live"Auto-opens

Opening a Terminal in a Folder

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.

Troubleshooting

Black screen or nothing appears
Scene spins forever / never loads
"CORS error" in the console

You're opening the file directly instead of through a server. See Running a Local Server.

iframe not showing on my site