© 2026 StellarDragoon
License: GPL‑3.0‑or‑later (see LICENSE)
Spawn3D is a bridge component that synchronizes a DOM element's 2D screen position with a 3D object in the Threlte scene. It allows you to place 3D objects in the flow of your HTML layout, create 3D HUDs, or detach objects into World Space based on scroll logic.
A key use case is building scrollytelling websites where 3D objects seamlessly interact with your DOM elements.
e.g. writing a DOM animation with GSAP and sync the movement to a 3D object
Imagine a long, scrollable HTML page with a fixed 3D scene in the background. By default:
Spawn3D works by projecting an invisible reference plane directly in front of the camera. This plane follows the camera’s position and rotation and serves as the coordinate surface for syncing the 3D object with its DOM counterpart.
Spawning a 3D object with Spawn3D inside a DOM element positions it at the equivalent location on that plane.
Even if your 3D camera moves wildly, locking to the element or camera keeps the object relatively fixed in the camera’s view.
The “plane” is conceptual; you can adjust each object’s distance from the camera.
type Props = {
/**
* The locking behavior of the 3D object.
* - "element": Continuously tracks the DOM element (moves with scroll).
* - "camera": Locks to the screen position where the element *was* (HUD mode).
* - "none": Stops updating position entirely (drifts in World Space).
* @default "element"
*/
lockAt?: "element" | "camera" | "none";
/**
* Distance from the camera in world units. Conceptually similar to z-index.
* @default 5
*/
distance?: number;
/**
* Controls how the object scales visually.
* - false: "Pixel Perfect". The object scales up as it gets further away
* to maintain the same size on screen (1px DOM = 1px 3D).
* - true: "World Accurate". The object is sized as if it were at distance=5.
* If you move it to distance=10, it will appear half as large.
* @default false
*/
absoluteSizing?: boolean;
/**
* Manual visibility override.
* If false, the object is hidden regardless of DOM visibility.
* @default true
*/
visible?: boolean;
/**
* By default (true), the DOM container expands to fill its parent container.
* If false, the DOM container doesn't take space.
* - Be careful if you sync the 3D object width/height with the container. "false" will make your 3D object scales to 0 (invisible).
* @default true
*/
fill?: boolean;
/**
* Automatically hides the object when it is not visible in the viewport.
* Useful for performance optimization.
* @default true
*/
autoHide?: boolean;
/**
* The 3D content to render.
* This snippet receives the current item state as an argument.
*/
children?: Snippet;
};
# using npm
npm install @stellardragoon/spawn3d
# or with pnpm
pnpm add @stellardragoon/spawn3d
Spawn3D has the following peer dependencies which must be satisfied by your project:
svelte (>=4)three (>=0.182)@threlte/core (>=8)After installing you can import components like this:
<script lang="ts">
import Spawn3D, { Spawn3DManager } from '@stellardragoon/spawn3d'
</script>
The object behaves like a standard HTML image but exists in the 3D scene. It scrolls with the page and respects the DOM layout.
<script>
import Spawn3D from './Spawn3D.svelte'
import { T } from '@threlte/core'
</script>
<div class="card">
<Spawn3D>
<T.Mesh>
<T.BoxGeometry args={[1, 1, 1]} />
<T.MeshStandardMaterial color="orange" />
</T.Mesh>
</Spawn3D>
</div>
position: sticky but for a 3D object (Reactive Locking)An object starts as part of the page layout, then snaps to the camera lens (HUD) when the user scrolls past a certain point.
<script>
import { windowScroll } from './stores' // your reactive scroll store
import Spawn3D from './Spawn3D.svelte'
let scrollY = $derived($windowScroll.y)
// Logic: "Track the element until pixel 500, then stick to camera"
let lockMode = $derived(scrollY > 500 ? 'camera' : 'element')
</script>
<div class="h-[200vh]">
<Spawn3D lockAt={lockMode} distance={3}>
<My3DLogo />
</Spawn3D>
</div>
An object spawns at the DOM position, but once detached (lockAt="none"), it stays behind in the 3D world while the camera moves away.
<script>
let isDetached = $state(false)
</script>
<button onclick={() => (isDetached = true)}> Release Balloon </button>
<Spawn3D lockAt={isDetached ? 'none' : 'element'} absoluteSizing={true}>
<BalloonModel />
</Spawn3D>
The children snippet receives the current Spawn3DItem state as an argument. This allows you to access real-time metrics like width and height to size your 3D geometry exactly to the DOM element.
Since absoluteSizing defaults to false, the 3D object scales 1:1 with the DOM element (1px = 1 world unit).
<script>
import Spawn3D from './Spawn3D.svelte'
import { T } from '@threlte/core'
import { TextureLoader } from 'three'
import { useLoader } from '@threlte/core'
const texture = useLoader(TextureLoader).load('/my-image.jpg')
</script>
<div class="h-64 w-full border-2 border-dashed border-gray-500">
<Spawn3D>
{#snippet children({ width, height })}
{#if $texture}
<T.Mesh>
<T.PlaneGeometry args={[width, height]} />
<T.MeshBasicMaterial map={$texture} />
</T.Mesh>
{/if}
{/snippet}
</Spawn3D>
</div>
It's useful when you use CSS to resize the div (e.g., width: 50% or responding to mobile layouts), the width and height arguments in the snippet will update automatically. Threlte will reconstruct the geometry, ensuring your 3D image always fits the DOM container perfectly without stretching or guessing.
Here is how you would use this in your app to create a scrolling page where an object spawns, locks to the camera (HUD), and then detaches.
<script lang="ts">
import { T, Canvas } from '@threlte/core'
import Spawn3DManager from '../components/Spawn3DManager.svelte'
import Spawn3D from '../components/Spawn3D.svelte'
import { spring } from 'svelte/motion'
// Simple scroll store for demo
let scrollY = $state(0)
const handleScroll = () => (scrollY = window.scrollY)
</script>
<svelte:window on:scroll={handleScroll} />
<div class="fixed inset-0 -z-10">
<Canvas>
<Spawn3DManager />
<T.PerspectiveCamera makeDefault position={[0, 0, 10]} />
<T.AmbientLight intensity={0.5} />
<T.DirectionalLight position={[10, 10, 10]} />
</Canvas>
</div>
<div class="h-[300vh] p-10">
<div class="h-[500px]">Spacer... Scroll down</div>
<div class="h-64 w-64 rounded-lg border border-blue-500/30">
{@const mode = scrollY > 1200 ? 'none' : scrollY > 600 ? 'camera' : 'element'}
<Spawn3D lockAt={mode} distance={5} absoluteSizing={false}>
<T.Mesh>
<T.BoxGeometry args={[100, 100, 100]} />
<T.MeshStandardMaterial color="hotpink" />
</T.Mesh>
</Spawn3D>
</div>
</div>
Since Spawn3D lives in the HTML tree but renders in the Canvas, you must place the Manager component inside your main Threlte <Canvas>.
App.svelte (or Layout)
<Canvas>
<Spawn3DManager />
<PerspectiveCamera makeDefault position={[0, 0, 10]} />
</Canvas>
<main>
<Spawn3D>...</Spawn3D>
</main>
When you write:
<Spawn3D lockAt="camera" distance={3}>
<MyMesh />
</Spawn3D>
Spawn3D.svelte inserts a hidden <div> into the DOM, registers a Spawn3DItem in a shared store, and the canvas‑side Spawn3DManager spawns a lightweight Spawn3DObject that wraps your mesh. An rAF loop on the manager keeps the object’s world position, scale, and visibility in sync with its anchor’s screen coordinates.
Under the hood the code is split into four files:
src/lib/Spawn3D/
├─ Spawn3D.svelte # public component proxy & DOM anchor
├─ Spawn3DManager.svelte # scene-side manager inside <Canvas>
├─ Spawn3DObject.svelte # thin wrapper that billsboards twice a frame
├─ spawn3dStore.svelte.ts# shared store & update loop logic
Rather than firing rays at the DOM element, the manager projects a view plane directly in front of the camera. This plane is perpendicular to the view direction and sits at distance units. Every frame the store reads an anchor’s getBoundingClientRect(), converts its center point to NDC ([-1,1]), then unprojects that vector into world space on the view plane:
items.forEach((item) => {
const rect = item.anchor.getBoundingClientRect();
const ndc = {
x: ((rect.left + rect.width / 2) / window.innerWidth) * 2 - 1,
y: (-(rect.top + rect.height / 2) / window.innerHeight) * 2 + 1,
};
const worldPos = camera.clone().unproject(ndcVector).setLength(item.distance);
item.object.position.copy(worldPos);
});
Scaling uses the plane‑perpendicular distance to avoid the “fish‑eye” effect at the screen edges. Billboarding (object always facing the camera) happens in Spawn3DObject.svelte via a simple object.lookAt(camera.position) call executed each frame.
lockAt drives update frequency:
The store caches the last NDC for camera mode and pauses the loop for none. Example helper:
const setLock = (item, mode) => {
item.lock = mode;
if (mode === "camera") item.cacheNdc();
if (mode === "none") item.pause();
};
autoHide toggles object.visible using an IntersectionObserver (or manual fallback). Off‑screen items are skipped to save GPU cycles. The fill prop controls whether the anchor fills its parent (position:absolute; inset:0) or collapses to zero size, which affects how width/height are read for geometry resizing.
The main loop is a single requestAnimationFrame that iterates only active items; cleanup happens on onDestroy.
interface Spawn3DItem {
id: string;
anchor: HTMLElement;
object: THREE.Object3D;
distance: number;
lock: "element" | "camera" | "none";
visible: boolean;
width: number;
height: number;
cache?: { ndc: Vector2 };
}