@threlte/xr
touchControls
The touchControls plugin adds proximity-based pointer events driven by a hand joint — “poke to press” interactions, as opposed to the ray-based pointerControls. When the tracked joint (by default the index fingertip) enters an object’s bounding volume, hover events fire; when it gets closer than a down-radius threshold, pointerdown/pointerup events fire. This plugin only works with <Hands>.
<script lang="ts">
import { T, Canvas } from '@threlte/core'
import { XR, VRButton } from '@threlte/xr'
import Scene from './Scene.svelte'
</script>
<div>
<Canvas>
<Scene />
<XR>
{#snippet fallback()}
<T.PerspectiveCamera
makeDefault
position={[0, 1.5, 0.5]}
oncreate={(ref) => {
ref.lookAt(0, 1.3, 0)
}}
/>
{/snippet}
</XR>
<T.AmbientLight />
<T.DirectionalLight
intensity={1.5}
position={[1, 1, 1]}
/>
</Canvas>
<VRButton />
</div>
<style>
div {
height: 100%;
}
</style>
<script lang="ts">
import { T } from '@threlte/core'
import { Spring } from 'svelte/motion'
import { Mesh } from 'three'
let hovering = $state({ left: false, right: false })
let pressed = $state({ left: false, right: false })
const isHovered = $derived(hovering.left || hovering.right)
const isPressed = $derived(pressed.left || pressed.right)
let { color, ...rest } = $props()
const pressDepth = 0.03
const press = new Spring(0)
const mesh = new Mesh()
$effect(() => {
mesh.position.z = (rest.position.z ?? 0) - press.current * pressDepth
})
</script>
<T
is={mesh}
onpointerenter={((event: { handedness: 'left' }) => {
hovering[event.handedness] = true
}) as any}
onpointerleave={((event: { handedness: 'left' }) => {
hovering[event.handedness] = false
}) as any}
onpointerdown={((event: { handedness: 'left' }) => {
pressed[event.handedness] = true
press.set(1)
}) as any}
onpointerup={((event: { handedness: 'left' }) => {
pressed[event.handedness] = false
if (!isPressed) press.set(0)
}) as any}
{...rest}
>
<T.BoxGeometry args={[0.08, 0.08, 0.04]} />
<T.MeshStandardMaterial
{color}
emissive={color}
emissiveIntensity={isHovered ? 0.4 : 0.1}
/>
</T>
<script lang="ts">
import { T } from '@threlte/core'
import { touchControls, useXR, Controller, Hand } from '@threlte/xr'
import TouchDebug from './TouchDebug.svelte'
import Button from './Button.svelte'
const { isPresenting } = useXR()
touchControls('left')
touchControls('right')
let debug = $state(false)
</script>
<Controller left />
<Controller right />
<Hand left />
<Hand right />
<Button
position={[-0.18, 1.3, -0.25]}
color="#e11d48"
/>
<Button
position={[-0.06, 1.3, -0.25]}
color="#16a34a"
/>
<Button
position={[0.06, 1.3, -0.25]}
color="#2563eb"
/>
<Button
position={[0.18, 1.3, -0.25]}
color="#6b7280"
onclick={() => (debug = !debug)}
/>
<T.Mesh
position.y={1.3}
position.z={-0.3}
scale={$isPresenting ? 1 : 0.001}
>
<T.PlaneGeometry args={[0.6, 0.2]} />
<T.MeshStandardMaterial
color="#1f2937"
transparent
opacity={0.6}
/>
</T.Mesh>
{#if debug}
<TouchDebug />
{/if}
<!--
Debug visualization for the touchControls example. Draws a wireframe hover
sphere and a smaller down sphere around each hand's tracked joint, so you
can see exactly when each threshold is crossed while tuning size.
-->
<script lang="ts">
import { Mesh, MeshBasicMaterial, SphereGeometry, Vector3 } from 'three'
import { T, useTask } from '@threlte/core'
import { useHand, type HandJoints } from '@threlte/xr'
interface Props {
joint?: HandJoints
hoverRadius?: number
downRadius?: number
}
const { joint = 'index-finger-tip', hoverRadius = 0.03, downRadius = 0.01 }: Props = $props()
const leftHand = useHand('left')
const rightHand = useHand('right')
const sphereGeometry = new SphereGeometry(1, 16, 12)
const hoverMaterial = new MeshBasicMaterial({
color: '#facc15',
wireframe: true,
transparent: true,
opacity: 0.3
})
const downMaterial = new MeshBasicMaterial({
color: '#ef4444',
wireframe: true,
transparent: true,
opacity: 0.5
})
const createSphere = (material: MeshBasicMaterial) => {
const mesh = new Mesh(sphereGeometry, material)
mesh.matrixAutoUpdate = false
mesh.visible = false
return mesh
}
const leftHover = createSphere(hoverMaterial)
const leftDown = createSphere(downMaterial)
const rightHover = createSphere(hoverMaterial)
const rightDown = createSphere(downMaterial)
const origin = new Vector3()
const update = (hand: ReturnType<typeof useHand>, hoverMesh: Mesh, downMesh: Mesh) => {
const space = hand.current?.hand.joints[joint]
if (space === undefined || space.jointRadius === undefined) {
hoverMesh.visible = false
downMesh.visible = false
return
}
space.updateWorldMatrix(true, false)
origin.setFromMatrixPosition(space.matrixWorld)
hoverMesh.position.copy(origin)
hoverMesh.scale.setScalar(hoverRadius)
hoverMesh.updateMatrix()
hoverMesh.visible = true
downMesh.position.copy(origin)
downMesh.scale.setScalar(downRadius)
downMesh.updateMatrix()
downMesh.visible = true
}
useTask(() => {
update(leftHand, leftHover, leftDown)
update(rightHand, rightHover, rightDown)
})
</script>
<T is={leftHover} />
<T is={leftDown} />
<T is={rightHover} />
<T is={rightDown} />
<script>
import { touchControls } from '@threlte/xr'
touchControls('left')
touchControls('right')
</script>
Any mesh within this component and all child components will receive touch-style pointer events when the hand’s index fingertip is within the hover radius.
<T.Mesh
onpointerenter={() => console.log('finger near')}
onpointerleave={() => console.log('finger away')}
onpointerdown={() => console.log('touching')}
onclick={() => console.log('pressed and released')}
>
<T.BoxGeometry args={[0.1, 0.1, 0.1]} />
<T.MeshStandardMaterial />
</T.Mesh>
Options
| Option | Default | Description |
|---|---|---|
enabled | true | Whether the plugin is active for this hand. |
joint | 'index-finger-tip' | Which hand joint to track. Any HandJoints name. |
hoverRadius | 0.03 (3 cm) | Distance at which an object starts receiving hover events. |
downRadius | 0.01 (1 cm) | Distance below which a hover transitions to pointerdown. |
fixedStep | 1 / 40 | Interval at which joint positions are polled and intersections are recomputed. |
<script>
import { touchControls } from '@threlte/xr'
const { enabled } = touchControls('right', {
joint: 'thumb-tip',
hoverRadius: 0.05,
downRadius: 0.015
})
</script>
Available events
<T.Mesh
onpointerenter={(e) => console.log('enter')}
onpointerleave={(e) => console.log('leave')}
onpointerdown={(e) => console.log('down')}
onpointerup={(e) => console.log('up')}
onpointermove={(e) => console.log('move')}
onclick={(e) => console.log('click')}
onpointermissed={(e) => console.log('missed')}
/>
pointerenter/pointerleavefire when the joint crosses thehoverRadiusthreshold for an object.pointerdown/pointerupfire when the joint crosses thedownRadiusthreshold.clickfires immediately after everypointerup— there’s no distance or time threshold beyond crossing back above the down radius.pointermovefires every tick while hovering.pointermissedfires on click for any interactive object not hit at down time.
Each hand fires events independently — just like pointerControls. See the pattern in pointerControls.
Intersection model
Internally, touchControls is calculating during each tick, for every interactive mesh:
- Broad phase: reject if the joint is further than
hoverRadius + boundingSphere.radiusfrom the object’s world-space bounding sphere center. - Narrow phase: transform the joint into the mesh’s local space, clamp to the local AABB (
boundingBox.clampPoint), project back to world, and measure distance. This respects rotation and non-uniform scale exactly.
The reported event.point is the closest point on the mesh’s oriented bounding box. For very concave meshes consider wrapping interactive children in simpler colliders.
Pointer capture
pointerdown captures the pressed object. pointerup and click always fire on that captured target, even if the finger moves off the object before release.
Composing with other plugins
touchControls can coexist with pointerControls and teleportControls. A single mesh with onpointerenter will be interactive to all active plugins — each fires its own events independently.
<script>
import { pointerControls, touchControls } from '@threlte/xr'
pointerControls('left')
pointerControls('right')
touchControls('left')
touchControls('right')
</script>
Accessing the pointer
Like pointerControls, the returned object exposes:
const { enabled, hovered } = touchControls('left')
// enabled — currentWritable(boolean)
// hovered — Map<string, IntersectionEvent> (internal hover tracking)