Skip to content

<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 { MathUtils } from 'three'
import { T } from '@threlte/core'
import { HTML, OrbitControls } from '@threlte/extras'
import { Spring } from 'svelte/motion'
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 = new 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 * MathUtils.DEG2RAD}
minPolarAngle={20 * MathUtils.DEG2RAD}
maxAzimuthAngle={45 * MathUtils.DEG2RAD}
minAzimuthAngle={-45 * MathUtils.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.current}
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>
<script lang="ts">
import { HTML } from '@threlte/extras'
</script>
<HTML>
<h1>Hello, World!</h1>
</HTML>

transform applies matrix3d transformations.

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

<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 { 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?.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>

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.

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>

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>

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>

<HTML> has an autoRender prop that you can use to pause its render task. If at some point you no longer need to update <HTML>, you can set autoRender to false. If you need to resume updates, set autoRender back to true.

<HTML> also exports its internal render task and the startRendering, stopRendering, and render functions so you can either manually render or start and stop the internal task based on your needs.

manually-rendering.svelte
<script lang="ts">
import { HTML } from '@threlte/extras'
let html = $state<HTML>()
$effect(() => {
html?.render()
})
</script>
<HTML
autoRender={false}
bind:this={html}
>
<h1>Hello World</h1>
</HTML>
toggle-rendering.svelte
<script lang="ts">
import { HTML } from '@threlte/extras'
let html = $state<HTML>()
// turn this on and off in accordance with your application
let renderWhileTrue = $state(false)
$effect(() => {
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>

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.

<HTML> extends < T . Group > and supports all its props, snippets, 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