Three.js From Zero · Article s14-04

Three.js + Web Components

Three.js + Web Components is Article s14-04 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.

← Three.js From ZeroS14-04 · Setup & Tooling

Season 14 · Article 04 · Setup & Tooling

Wrap a Three.js scene in a Custom Element. Drop it into any site — Webflow, Notion (some embeds), Framer, Squarespace, plain HTML. No React, no build step, no framework lock-in. The most portable 3D you'll ever ship.

The minimum custom element

class ThreeScene extends HTMLElement {
  connectedCallback() {
    const w = this.clientWidth || 400;
    const h = this.clientHeight || 300;

    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(50, w/h, 0.1, 50);
    camera.position.z = 5;

    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(w, h);
    this.appendChild(renderer.domElement);

    const cube = new THREE.Mesh(
      new THREE.BoxGeometry(),
      new THREE.MeshNormalMaterial(),
    );
    scene.add(cube);

    renderer.setAnimationLoop((t) => {
      cube.rotation.x = cube.rotation.y = t * 0.001;
      renderer.render(scene, camera);
    });

    this._renderer = renderer;
    this._scene = scene;
  }

  disconnectedCallback() {
    this._renderer.dispose();
    this._scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); });
  }
}

customElements.define('three-scene', ThreeScene);

Now anywhere on any page: <three-scene style="width:400px;height:300px"></three-scene> and it works.

Attributes as props

static get observedAttributes() { return ['color', 'shape']; }

attributeChangedCallback(name, oldValue, newValue) {
  if (name === 'color' && this._cube) {
    this._cube.material.color.set(newValue);
  }
}

Now the parent can pass props via HTML attributes:

<three-scene color="#ec4899" shape="torus"></three-scene>
<script>
  // Or change dynamically
  document.querySelector('three-scene').setAttribute('color', '#22d3ee');
</script>

Shadow DOM to isolate styles

constructor() {
  super();
  this.attachShadow({ mode: 'open' });
}

connectedCallback() {
  // ...
  this.shadowRoot.appendChild(renderer.domElement);
}

Shadow DOM prevents the host page's CSS from affecting your canvas (and vice versa). For embeddable widgets, this is essential — Webflow has a million global styles that will break your canvas without isolation.

Bundling for embed

// vite.config — library mode for distribution
export default {
  build: {
    lib: {
      entry: 'src/three-scene.ts',
      name: 'ThreeScene',
      formats: ['iife'],          // single <script> tag
      fileName: 'three-scene',
    },
  },
};

Produces three-scene.iife.js — a single file users drop in their site with <script src="..."></script>. Three.js bundled in. Total size ~150KB gzip for a typical scene.

Loading models from a URL attribute

<three-scene src="https://yoursite.com/model.glb"></three-scene>
attributeChangedCallback(name, _, newValue) {
  if (name === 'src') {
    new GLTFLoader().load(newValue, (gltf) => this._scene.add(gltf.scene));
  }
}

Common first-time pitfalls

"Element has zero size." Custom elements default to display: inline. Add :host { display: block; } inside a <style> in the shadow root. Or set width/height attributes.
"Renders once, then freezes." Forgot to start the animation loop or stored renderer in a way it gets GC'd. Keep a reference on this.
"User can't interact — wheel doesn't zoom." Need OrbitControls bound to renderer.domElement. By default the wheel propagates to the page (parent scrolls).

Exercises

  1. Build <product-viewer>. Takes src attribute for a glTF URL. Supports OrbitControls. Add a "fit to view" method via auto-bounding-box. Self-contained product viewer for any e-commerce site.
  2. Slot for HTML content. Add a default slot in shadow DOM. Users can pass HTML overlays: <three-scene><p>Hover the cube</p></three-scene>.
  3. Ship to npm. Publish your component. Users install: npm i your-three-scene and import 'your-three-scene'. Element is auto-registered.