threlte logo
@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

OptionDefaultDescription
enabledtrueWhether the plugin is active for this hand.
joint'index-finger-tip'Which hand joint to track. Any HandJoints name.
hoverRadius0.03 (3 cm)Distance at which an object starts receiving hover events.
downRadius0.01 (1 cm)Distance below which a hover transitions to pointerdown.
fixedStep1 / 40Interval 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 / pointerleave fire when the joint crosses the hoverRadius threshold for an object.
  • pointerdown / pointerup fire when the joint crosses the downRadius threshold.
  • click fires immediately after every pointerup — there’s no distance or time threshold beyond crossing back above the down radius.
  • pointermove fires every tick while hovering.
  • pointermissed fires 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:

  1. Broad phase: reject if the joint is further than hoverRadius + boundingSphere.radius from the object’s world-space bounding sphere center.
  2. 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)