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 { Canvas } from '@threlte/core'
  import Scene from './Scene.svelte'
</script>

<div>
  <Canvas>
    <Scene />
  </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'

  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
  >
    <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"
  >
    <p
      class="w-auto translate-x-1/2 text-xs drop-shadow-lg"
      style="color: {color}"
    >
      color: {color}
    </p>
  </HTML>
</T.Mesh>

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
    }

    console.log(width, height)

    // 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
  path="/hdr/"
  files="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>

Component Signature

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

Props

name
type
required
default

as
keyof HTMLElementTagNameMap
no
'div'

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

recieveShadow
boolean
no
undefined

sprite
boolean
no
false

transform
boolean
no
false

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