Tutorial · Intermediate · ~50 min
WebXR · Three.js · VR · Controller Input

WebXR Device API — First Steps

WebXR is the browser standard for immersive VR and AR. It handles head tracking, stereo rendering, controller poses, and haptics — all from JavaScript. This tutorial walks through entering an inline preview session, upgrading to immersive-vr, handling controller input, and rendering a simple interactive VR scene with Three.js.

1Check XR support and show the button

const btn = document.getElementById('vr-btn'); if (!navigator.xr) { btn.textContent = 'WebXR not supported'; btn.disabled = true; } else { // Check if immersive-vr is available (requires HTTPS + headset) navigator.xr.isSessionSupported('immersive-vr').then(supported => { if (supported) { btn.textContent = 'Enter VR'; btn.addEventListener('click', startXR); } else { btn.textContent = 'VR not available on this device'; btn.disabled = true; } }); } // Always show inline 3D preview (works on any device) navigator.xr?.isSessionSupported('inline').then(ok => { if (ok) startInlineSession(); });
WebXR requires HTTPS (or localhost). Use serve / vite locally — plain file:// URLs won't work.

2Start an immersive-vr session

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js'; const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.xr.enabled = true; document.body.appendChild(renderer.domElement); let xrSession = null; async function startXR() { xrSession = await navigator.xr.requestSession('immersive-vr', { requiredFeatures: ['local-floor'], // floor-relative reference space optionalFeatures: ['hand-tracking'], // hand joints if available }); // Tell Three.js about the session — it handles stereo rendering internally await renderer.xr.setSession(xrSession); // Session ends when user removes headset xrSession.addEventListener('end', () => { xrSession = null; btn.textContent = 'Enter VR'; }); btn.textContent = 'Exit VR'; }

3The XR render loop

In XR mode, Three.js takes over requestAnimationFrame. Use renderer.setAnimationLoop — it works both in-page and in VR:

const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(70, innerWidth/innerHeight, 0.01, 100); scene.add(camera); // Add some objects to the scene const geo = new THREE.BoxGeometry(0.2, 0.2, 0.2); const mat = new THREE.MeshStandardMaterial({ color: 0x22c55e }); for (let i = 0; i < 10; i++) { const box = new THREE.Mesh(geo, mat); box.position.set( (Math.random() - 0.5) * 4, Math.random() * 1.5, -1 - Math.random() * 3 ); scene.add(box); } // Add light scene.add(new THREE.AmbientLight(0xffffff, 0.5)); const dirLight = new THREE.DirectionalLight(0xffffff, 1); dirLight.position.set(2, 4, 2); scene.add(dirLight); // The loop — called by the browser at 90 Hz in VR renderer.setAnimationLoop((time, frame) => { renderer.render(scene, camera); });
The frame argument is an XRFrame — use it to query controller poses, hand joints, and AR hit tests in the same loop.

4Read controller poses

// Three.js XRControllerModelFactory handles controller meshes automatically import { XRControllerModelFactory } from 'https://cdn.jsdelivr.net/npm/three@0.160/examples/jsm/webxr/XRControllerModelFactory.js'; const cmf = new XRControllerModelFactory(); const controllers = [0, 1].map(i => { // Grip space: physical controller model const grip = renderer.xr.getControllerGrip(i); grip.add(cmf.createControllerModel(grip)); scene.add(grip); // Ray space: pointing ray const ctrl = renderer.xr.getController(i); ctrl.addEventListener('selectstart', onSelectStart); ctrl.addEventListener('selectend', onSelectEnd); scene.add(ctrl); // Visualise ray const ray = new THREE.Line( new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -1), ]), new THREE.LineBasicMaterial({ color: 0x22c55e }) ); ctrl.add(ray); return ctrl; }); function onSelectStart(e) { // Controller pressed trigger const ctrl = e.target; console.log('Select start from', ctrl === controllers[0] ? 'left' : 'right'); }

5Gamepad buttons and haptics

renderer.setAnimationLoop((time, frame) => { if (frame) { const session = renderer.xr.getSession(); for (const source of session.inputSources) { const gp = source.gamepad; if (!gp) continue; // Standard button layout (OpenXR gamepad profile): // buttons[0] = trigger, [1] = grip, [3] = thumbstick press, [4/5] = A/B const trigger = gp.buttons[0]?.value ?? 0; // 0–1 analogue const grip = gp.buttons[1]?.pressed ?? false; // Analogue thumbstick const stickX = gp.axes[2] ?? 0; // -1 left, +1 right const stickY = gp.axes[3] ?? 0; // -1 up, +1 down // Haptic pulse (if available) if (trigger > 0.9 && source.gamepad.hapticActuators?.length) { source.gamepad.hapticActuators[0].pulse(trigger * 0.5, 50); } } } renderer.render(scene, camera); });
Haptics require a brief pulse < 250 ms — longer pulses are silently ignored by most runtimes. The intensity range is 0–1.