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.

Frequently Asked Questions

What will I learn in this tutorial?

Enter VR and AR from the browser using the WebXR Device API — set up an XR session, handle controller input, and render a stereo 3D scene.

What topics are covered in this tutorial?

This tutorial covers: Check XR support and show the button, Start an immersive-vr session, The XR render loop, Read controller poses, Gamepad buttons and haptics.

What tools and technologies does this tutorial use?

This tutorial uses WebXR, Three.js, VR, Controller Input.

How long does this tutorial take?

This tutorial takes approximately 50 minutes to complete.

What prerequisites do I need before starting?

This is a Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.