threlte logo
@threlte/extras

<HTML>

This component is a port of drei’s <Html> component. It allows you to tie HTML content to any object of your scene. It will be projected to the objects whereabouts automatically.

The container of your <Canvas> component needs to be set to position: relative | absolute | sticky | fixed. This is because the DOM element will be mounted as a sibling to the <canvas> element.

<script lang="ts">
  import Scene from './Scene.svelte'
  import { Canvas } from '@threlte/core'
  import { Checkbox, Pane } from 'svelte-tweakpane-ui'

  let autoRender = $state(true)
</script>

<Pane position="fixed">
  <Checkbox
    label="auto render"
    bind:value={autoRender}
  />
</Pane>

<div>
  <Canvas>
    <Scene {autoRender} />
  </Canvas>
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import { T } from '@threlte/core'
  import { HTML, OrbitControls } from '@threlte/extras'
  import { spring } from 'svelte/motion'
  import { DEG2RAD } from 'three/src/math/MathUtils.js'

  type Props = {
    autoRender?: boolean
  }

  let { autoRender = true }: Props = $props()

  const getRandomColor = () =>
    `#${Math.floor(Math.random() * 16777215)
      .toString(16)
      .padStart(6, '0')}`

  let color = $state(getRandomColor())
  let isHovering = $state(false)
  let isPointerDown = $state(false)

  let htmlPosZ = spring(0)
  $effect(() => {
    htmlPosZ.set(isPointerDown ? -0.15 : isHovering ? -0.075 : 0, {
      hard: isPointerDown
    })
  })
</script>

<T.PerspectiveCamera
  position={[10, 5, 10]}
  makeDefault
  fov={30}
  oncreate={(ref) => ref.lookAt(0, 0.75, 0)}
>
  <OrbitControls
    target.y={0.75}
    maxPolarAngle={85 * DEG2RAD}
    minPolarAngle={20 * DEG2RAD}
    maxAzimuthAngle={45 * DEG2RAD}
    minAzimuthAngle={-45 * DEG2RAD}
    enableZoom={false}
  />
</T.PerspectiveCamera>

<T.DirectionalLight position={[0, 10, 10]} />

<T.AmbientLight intensity={0.3} />

<T.GridHelper />

<T.Mesh position.y={0.5}>
  <T.MeshStandardMaterial {color} />
  <T.SphereGeometry args={[0.5]} />
  <HTML
    position.y={1.25}
    position.z={$htmlPosZ}
    transform
    {autoRender}
  >
    <button
      onpointerenter={() => (isHovering = true)}
      onpointerleave={() => {
        isPointerDown = false
        isHovering = false
      }}
      onpointerdown={() => {
        isPointerDown = true
        color = getRandomColor()
      }}
      onpointerup={() => (isPointerDown = false)}
      onpointercancel={() => {
        isPointerDown = false
        isHovering = false
      }}
      class="rounded-full bg-orange-500 px-3 text-white hover:opacity-90 active:opacity-70"
    >
      I'm a regular HTML button
    </button>
  </HTML>

  <HTML
    position.x={0.75}
    transform
    pointerEvents="none"
    {autoRender}
  >
    <p
      class="w-auto translate-x-1/2 text-xs drop-shadow-lg"
      style="color: {color}"
    >
      color: {color}
    </p>
  </HTML>
</T.Mesh>

Stopping and Starting the Task

<HTML> has an autoRender prop that you can use to turn off and on its render task. If at some point in your application, you no longer need to update the hmtl, you can set autoRender to false. If you need to resume the task, set autoRender back to true.

<HTML> also exports it’s internal render task and the startRendering, stopRendering, and render functions so you can either manually render the html or start and stop the internal task at your will.

Manually-Rendering
<script>
  let html = $state()

  $effect(() => {
    // if (shouldRender) {
    html?.render()
    // }
  })
</script>

<HTML
  autoRender={false}
  bind:this={html}
>
  <h1>Hello World</h1>
</HTML>
Start-And-Stop-Rendering
<script>
  let html = $state()

  // turn this on and off in accordance with your application
  let renderWhileTrue = $state(false)

  $effect(() => {
    if (html !== undefined) {
      if (renderWhileTrue) {
        html.startRendering()
        // always stop rendering if it was started
        return () => {
          html.stopRendering()
        }
      }
    }
  })
</script>

<HTML
  autoRender={false}
  bind:this={html}
>
  <h1>Hello World</h1>
</HTML>

In both cases you should set autoRender to false so that the render task doesn’t automatically begin.

Lastly, you can access these functions from the <HTML>’s children snippet.

<HTML autoRender={false}>
  {#snippet children({ render, startRendering, stopRendering })}
    <button onclick={startRendering}>start rendering</button>
    <button onclick={stopRendering}>stop rendering</button>
    <button onclick={render}>render a single frame</button>
  {/snippet}
</HTML>

Examples

Basic Example

<script lang="ts">
  import { HTML } from '@threlte/extras'
</script>

<HTML>
  <h1>Hello, World!</h1>
</HTML>

Transform

transform applies matrix3d transformations.

<script lang="ts">
  import { HTML } from '@threlte/extras'
</script>

<HTML transform>
  <h1>Hello World</h1>
</HTML>

Occlude

<Html> can be occluded behind geometry using the occlude occlude property.

<script lang="ts">
  import { HTML } from '@threlte/extras'
</script>

<HTML
  transform
  occlude
>
  <h1>Hello World</h1>
</HTML>

Setting occlude to "blending" will allow objects to partially occlude the <HTML> component.

This occlusion mode requires the <canvas> element to have pointer-events set to none. Therefore, any events like those in OrbitControls must be set on the canvas parent. Extras components like <OrbitControls> do this automatically.

<script lang="ts">
  import { Canvas } from '@threlte/core'
  import Scene from './Scene.svelte'
</script>

<div>
  <Canvas>
    <Scene />
  </Canvas>
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import {
    MeshStandardMaterial,
    TetrahedronGeometry,
    CylinderGeometry,
    ConeGeometry,
    SphereGeometry,
    IcosahedronGeometry,
    TorusGeometry,
    OctahedronGeometry,
    BoxGeometry,
    MathUtils
  } from 'three'
  import { T } from '@threlte/core'
  import { Float } from '@threlte/extras'

  const material = new MeshStandardMaterial()
  const geometries = [
    { geometry: new TetrahedronGeometry(2) },
    { geometry: new CylinderGeometry(0.8, 0.8, 2, 32) },
    { geometry: new ConeGeometry(1.1, 1.7, 32) },
    { geometry: new SphereGeometry(1.5, 32, 32) },
    { geometry: new IcosahedronGeometry(2) },
    { geometry: new TorusGeometry(1.1, 0.35, 16, 32) },
    { geometry: new OctahedronGeometry(2) },
    { geometry: new SphereGeometry(1.5, 32, 32) },
    { geometry: new BoxGeometry(2.5, 2.5, 2.5) }
  ] as const

  const n = 40
  const randProps = Array.from(
    { length: n },
    () => geometries[Math.floor(Math.random() * geometries.length)]
  )
</script>

{#each randProps as prop}
  <Float
    floatIntensity={0}
    rotationIntensity={2}
    rotationSpeed={2}
  >
    <T.Mesh
      scale={MathUtils.randFloat(0.25, 0.5)}
      position={[
        MathUtils.randFloat(-8, 8),
        MathUtils.randFloat(-8, 8),
        MathUtils.randFloat(-8, 8)
      ]}
      geometry={prop.geometry}
      {material}
    />
  </Float>
{/each}
// From: https://discourse.threejs.org/t/roundedrectangle-squircle/28645/20
import { BufferGeometry, BufferAttribute } from 'three'

export class RoundedPlaneGeometry extends BufferGeometry {
  parameters: {
    width: number
    height: number
    radius: number
    segments: number
  }
  constructor(width = 1, height = 1, radius = 0.2, segments = 16) {
    super()
    this.parameters = {
      width,
      height,
      radius,
      segments
    }

    // helper consts
    const wi = width / 2 - radius // inner width
    const hi = height / 2 - radius // inner height
    const ul = radius / width // u left
    const ur = (width - radius) / width // u right
    const vl = radius / height // v low
    const vh = (height - radius) / height // v high

    let positions = [wi, hi, 0, -wi, hi, 0, -wi, -hi, 0, wi, -hi, 0]
    let uvs = [ur, vh, ul, vh, ul, vl, ur, vl]

    let n = [
      3 * (segments + 1) + 3,
      3 * (segments + 1) + 4,
      segments + 4,
      segments + 5,
      2 * (segments + 1) + 4,
      2,
      1,
      2 * (segments + 1) + 3,
      3,
      4 * (segments + 1) + 3,
      4,
      0
    ] as const

    const indices: number[] = [
      n[0],
      n[1],
      n[2],
      n[0],
      n[2],
      n[3],
      n[4],
      n[5],
      n[6],
      n[4],
      n[6],
      n[7],
      n[8],
      n[9],
      n[10],
      n[8],
      n[10],
      n[11]
    ]
    let phi, cos, sin, xc, yc, uc, vc, idx

    for (let i = 0; i < 4; i++) {
      xc = i < 1 || i > 2 ? wi : -wi
      yc = i < 2 ? hi : -hi
      uc = i < 1 || i > 2 ? ur : ul
      vc = i < 2 ? vh : vl
      for (let j = 0; j <= segments; j++) {
        phi = (Math.PI / 2) * (i + j / segments)
        cos = Math.cos(phi)
        sin = Math.sin(phi)
        positions.push(xc + radius * cos, yc + radius * sin, 0)
        uvs.push(uc + ul * cos, vc + vl * sin)
        if (j < segments) {
          idx = (segments + 1) * i + j + 4
          indices.push(i, idx, idx + 1)
        }
      }
    }

    this.setIndex(new BufferAttribute(new Uint32Array(indices), 1))
    this.setAttribute('position', new BufferAttribute(new Float32Array(positions), 3))
    this.setAttribute('uv', new BufferAttribute(new Float32Array(uvs), 2))
  }
}
<script lang="ts">
  import { T } from '@threlte/core'
  import { Environment, Float, HTML, useGltf, OrbitControls } from '@threlte/extras'
  import { derived } from 'svelte/store'
  import { type Mesh, MathUtils } from 'three'
  import Geometries from './Geometries.svelte'
  import { RoundedPlaneGeometry } from './RoundedPlaneGeometry'

  const gltf = useGltf<{
    nodes: {
      phone: Mesh
    }
    materials: {}
  }>('/models/phone/phone.glb')

  const phoneGeometry = derived(gltf, (gltf) => {
    if (!gltf) return
    return gltf.nodes.phone.geometry
  })

  const url = window.origin
</script>

<T.PerspectiveCamera
  position={[50, -30, 30]}
  fov={20}
  oncreate={(ref) => {
    ref.lookAt(0, 0, 0)
  }}
  makeDefault
>
  <OrbitControls
    enableDamping
    enableZoom={false}
  />
</T.PerspectiveCamera>

<T.AmbientLight intensity={0.3} />

<Environment url="/textures/equirectangular/hdr/shanghai_riverside_1k.hdr" />

<Float
  scale={0.7}
  floatIntensity={5}
>
  <HTML
    rotation.y={90 * MathUtils.DEG2RAD}
    position.x={1.2}
    transform
    occlude="blending"
    geometry={new RoundedPlaneGeometry(10.5, 21.3, 1.6)}
  >
    <div
      class="phone-wrapper"
      style="border-radius:1rem"
    >
      <iframe
        title=""
        src={url}
        width="100%"
        height="100%"
        frameborder="0"
      ></iframe>
    </div>
  </HTML>

  {#if $phoneGeometry}
    <T.Mesh
      scale={5.65}
      geometry={$phoneGeometry}
    >
      <T.MeshStandardMaterial
        color="#FF3F00"
        metalness={0.9}
        roughness={0.1}
      />
    </T.Mesh>
  {/if}
</Float>

<Geometries />

<style>
  .phone-wrapper {
    height: 848px;
    width: 420px;
    border-radius: 63px;
    overflow: hidden;
    border: 1px solid rgba(0, 0, 0, 0.1);
  }
</style>

Visibility Change Event

Use the property occlude and bind to the event visibilitychange to implement a custom hide/show behaviour.

<script lang="ts">
  import { HTML } from '@threlte/extras'

  const onVisibilityChange = (isVisible: boolean) => {
    console.log(isVisible)
  }
</script>

<HTML
  transform
  occlude
  onvisibilitychange={onVisibilityChange}
>
  <h1>Hello World</h1>
</HTML>

When binding to the event visibilitychange the contents of <HTML> is not automatically hidden when it’s occluded.

Sprite Rendering

Use the property sprite in transform mode to render the contents of <HTML> as a sprite.

<script lang="ts">
  import { HTML } from '@threlte/extras'
</script>

<HTML
  transform
  sprite
>
  <h1>Hello World</h1>
</HTML>

Center

Add a -50%/-50% css transform with center when not in transform mode.

<script lang="ts">
  import { HTML } from '@threlte/extras'
</script>

<HTML center>
  <h1>Hello World</h1>
</HTML>

Portal

Use the property portal to mount the contents of the <HTML> component on another HTMLElement. By default the contents are mounted as a sibling to the rendering <canvas>.

<script lang="ts">
  import { HTML } from '@threlte/extras'
</script>

<HTML portal={document.body}>
  <h1>Hello World</h1>
</HTML>

uikit

An alternative to using HTML for UI is uikit. The vanilla code has be wrapped into threlte-uikit for use in threlte projects. There are situations where this package is necessary, for instance the <HTML/> component cannot be used within XR sessions.

Component Signature

<HTML> extends <T . Group> and supports all its props, slot props, bindings and events.

Props

name
type
required
default
description

as
keyof HTMLElementTagNameMap
no
'div'

autoRender
boolean
no
true
whether the render task should be ran every frame

calculatePosition
( obj: Object3D, camera: Camera, size: { width: number; height: number } ) => [number, number]
no

castShadow
boolean
no
undefined

center
boolean
no
false

distanceFactor
number
no
undefined

eps
number
no
0.001

fullscreen
boolean
no
false

geometry
THREE.BufferGeoemtry
no
undefined

material
THREE.Material
no
undefined

occlude
boolean | THREE.Object3D[] | 'blending'
no
false

pointerEvents
'auto' | 'none' | 'visiblePainted' | 'visibleFill' | 'visibleStroke' | 'visible' | 'painted' | 'fill' | 'stroke' | 'all' | 'inherit'
no
'auto'

portal
HTMLElement
no
undefined

receiveShadow
boolean
no
undefined

sprite
boolean
no
false

transform
boolean
no
false

zIndexRange
[number, number]
no
[16777271, 0]

Exports

name
type
description

startRendering
() => void
Manually start the render task

stopRendering
() => void
Manually stop the render task

render
() => void
renders a single frame of the provided html