@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.