threlte logo
@threlte/xr

<XROrigin>

<XROrigin /> represents the position of the XR user’s feet in the scene. The XR camera is parented to the origin, and any <Controller> / <Hand> components nested inside attach here instead of the scene root. Transforming the origin (position, rotation, scale) transforms the user — useful for teleportation, dolly rigs, and resizing the user.

Only one <XROrigin> may be mounted within a given <XR>. Mounting multiple origins in the same XR tree throws.

<script>
  import { XR, XROrigin, Controller, Hand } from '@threlte/xr'
</script>

<XR>
  <XROrigin
    position={[0, 0, 5]}
    rotation.y={Math.PI}
  >
    <Controller left />
    <Controller right />
    <Hand left />
    <Hand right />
  </XROrigin>
</XR>

Without <XROrigin>, controllers and hands continue to attach to the scene root.

Dolly rig

Because <XROrigin> contains a regular THREE.Group, it can be placed inside any transformed parent. Moving that parent moves the user:

<T.Group bind:ref={dolly}>
  <XROrigin>
    <Controller left />
    <Controller right />
  </XROrigin>
</T.Group>

Resizing

Scaling the origin scales the user relative to the scene.

<XROrigin scale={miniature ? 0.1 : 1}>
  <Controller left />
  <Controller right />
</XROrigin>

Interaction with useTeleport

When used inside <XROrigin>, useTeleport translates the origin group directly. When used without an origin, it falls back to mutating the underlying XRReferenceSpace.

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

<div class="example">
  <div class="hud">
    Best tested in VR: walk around your playspace, then click a colored pad. The cyan feet marker
    should land in the middle of the selected pad.
  </div>

  <Canvas>
    <Scene />
  </Canvas>

  <VRButton />
</div>

<style>
  .example {
    position: relative;
    height: 100%;
  }

  .hud {
    position: absolute;
    top: 0.75rem;
    left: 0.75rem;
    z-index: 1;
    max-width: 22rem;
    padding: 0.625rem 0.75rem;
    border: 1px solid rgb(255 255 255 / 0.12);
    border-radius: 0.75rem;
    background: rgb(10 12 16 / 0.78);
    color: rgb(229 231 235);
    font-size: 0.875rem;
    line-height: 1.4;
    backdrop-filter: blur(10px);
  }
</style>
<script lang="ts">
  import { T } from '@threlte/core'
  import { Text } from '@threlte/extras'

  interface Props {
    active?: boolean
    color: string
    label: string
    onclick?: () => void
    position: [number, number, number]
  }

  let { active = false, color, label, onclick, position }: Props = $props()
</script>

<T.Group
  {position}
  {onclick}
>
  <T.Mesh
    position.y={0.012}
    rotation.x={-Math.PI / 2}
  >
    <T.CircleGeometry args={[0.38, 48]} />
    <T.MeshStandardMaterial
      color={active ? color : '#111827'}
      emissive={color}
      emissiveIntensity={active ? 1.1 : 0.25}
      metalness={0.1}
      roughness={0.4}
    />
  </T.Mesh>

  <T.Mesh
    position.y={0.13}
    castShadow
  >
    <T.CylinderGeometry args={[0.055, 0.055, 0.26, 24]} />
    <T.MeshStandardMaterial
      {color}
      emissive={color}
      emissiveIntensity={0.2}
      metalness={0.2}
      roughness={0.45}
    />
  </T.Mesh>

  <T.Mesh
    position.y={0.021}
    rotation.x={-Math.PI / 2}
    raycast={() => false}
  >
    <T.RingGeometry args={[0.11, 0.15, 32]} />
    <T.MeshStandardMaterial
      color={active ? '#ecfeff' : '#d1d5db'}
      emissive={active ? '#67e8f9' : '#374151'}
      emissiveIntensity={active ? 0.8 : 0.1}
      side={2}
    />
  </T.Mesh>

  <Text
    color={active ? '#f9fafb' : '#d1d5db'}
    fontSize={0.11}
    anchorX="center"
    anchorY="bottom"
    position={[0, 0.32, 0]}
    raycast={() => false}
  >
    {label}
  </Text>
</T.Group>
<script lang="ts">
  import { T, useTask } from '@threlte/core'
  import { OrbitControls, Text, interactivity } from '@threlte/extras'
  import {
    Controller,
    Hand,
    XR,
    XROrigin,
    pointerControls,
    useHeadset,
    useTeleport
  } from '@threlte/xr'
  import { Group } from 'three'
  import Pad from './Pad.svelte'

  interface PadTarget {
    color: string
    id: string
    label: string
    position: [number, number, number]
  }

  const pads: PadTarget[] = [
    { id: 'rose', label: 'Rose', color: '#fb7185', position: [-1.8, 0, -2.1] },
    { id: 'cyan', label: 'Cyan', color: '#22d3ee', position: [0, 0, -3.2] },
    { id: 'amber', label: 'Amber', color: '#f59e0b', position: [1.9, 0, -1.75] }
  ]

  const teleport = useTeleport()
  const headset = useHeadset()
  const feetMarker = new Group()

  let activePadId = $state('cyan')

  interactivity()
  pointerControls('left')
  pointerControls('right')

  useTask(() => {
    feetMarker.position.set(headset.position.x, 0.02, headset.position.z)
  })

  const goTo = (pad: PadTarget) => {
    activePadId = pad.id
    teleport(pad.position)
  }
</script>

<XR>
  {#snippet fallback()}
    <T.PerspectiveCamera
      makeDefault
      position={[0.2, 2.1, 3.8]}
      oncreate={(ref) => {
        ref.lookAt(0, 0.75, -1.6)
      }}
    >
      <OrbitControls
        target={[0, 0.7, -1.6]}
        enablePan={false}
      />
    </T.PerspectiveCamera>
  {/snippet}

  <T.Group
    position={[0.75, 0, 0.55]}
    rotation.y={Math.PI / 5}
  >
    <T.Mesh
      position.y={0.01}
      rotation.x={-Math.PI / 2}
      raycast={() => false}
    >
      <T.RingGeometry args={[0.22, 0.27, 48]} />
      <T.MeshStandardMaterial
        color="#fde68a"
        emissive="#f59e0b"
        emissiveIntensity={0.25}
        side={2}
      />
    </T.Mesh>

    <Text
      color="#fde68a"
      fontSize={0.085}
      anchorX="center"
      anchorY="bottom"
      position={[0, 0.16, 0]}
      raycast={() => false}
    >
      rotated rig parent
    </Text>

    <XROrigin>
      <Controller left />
      <Controller right />
      <Hand left />
      <Hand right />
    </XROrigin>
  </T.Group>
</XR>

<T is={feetMarker}>
  <T.Mesh
    rotation.x={-Math.PI / 2}
    raycast={() => false}
  >
    <T.RingGeometry args={[0.09, 0.14, 48]} />
    <T.MeshStandardMaterial
      color="#ecfeff"
      emissive="#22d3ee"
      emissiveIntensity={0.9}
      side={2}
    />
  </T.Mesh>

  <Text
    color="#cffafe"
    fontSize={0.095}
    anchorX="center"
    anchorY="bottom"
    position={[0, 0.13, 0]}
    raycast={() => false}
  >
    feet
  </Text>
</T>

<T.Mesh
  receiveShadow
  rotation.x={-Math.PI / 2}
>
  <T.CircleGeometry args={[8, 96]} />
  <T.MeshStandardMaterial color="#0f172a" />
</T.Mesh>

<T.GridHelper
  args={[16, 16, '#1f2937', '#111827']}
  position.y={0.001}
/>

{#each pads as pad}
  <Pad
    active={activePadId === pad.id}
    color={pad.color}
    label={pad.label}
    position={pad.position}
    onclick={() => goTo(pad)}
  />
{/each}

<T.Group position={[0, 1.45, 1.2]}>
  <Text
    color="#e5e7eb"
    fontSize={0.11}
    maxWidth={3.3}
    anchorX="center"
    anchorY="middle"
    textAlign="center"
    raycast={() => false}
  >
    Walk around in room-scale, then click a pad. The cyan feet marker should land exactly on the
    selected target even though the XR origin lives inside a rotated parent rig.
  </Text>
</T.Group>

<T.AmbientLight intensity={0.65} />

<T.DirectionalLight
  castShadow
  intensity={1.8}
  position={[3, 5, 2]}
  shadow.mapSize.width={1024}
  shadow.mapSize.height={1024}
  shadow.camera.top={6}
  shadow.camera.right={6}
  shadow.camera.bottom={-6}
  shadow.camera.left={-6}
/>

This example is a good regression test for room-scale teleportation. <XROrigin> is mounted inside a rotated parent rig, and clicking a pad calls useTeleport with a fixed world-space destination. Walk away from the middle of your playspace before teleporting: the cyan feet marker should still land in the center of the selected pad.

Accessing the origin

Use useXROrigin to read the current XR tree’s origin state from a child component.