Save products you love by clicking the heart icon.
We use privacy-friendly analytics (Plausible + Umami) to understand how visitors use this site. No analytics are loaded without your consent. Privacy Policy
End-to-end guide to building a production WebXR application with Three.js and TypeScript — project setup, session lifecycle, controller input, teleportation, VR UI, and deployment.
A practical deep-dive into the WebXR Hand Tracking API — joint positions, pinch detection, custom gesture recognition, and interaction patterns for immersive VR applications.
One of the key challenges in XR is making virtual objects stay where you put them. Walk away, come back — is the object still there? This is where WebXR Anchors come in. Combined with plane detection, they form the foundation for spatial understanding in immersive web applications.
Without anchors, objects are positioned relative to a reference space (typically local-floor or bounded-floor). Move through the space, and the objects move with you — they're not truly placed in the world.
An XRAnchor represents a fixed point in the user's environment. The runtime tracks this point across frames, adjusting its transform as the understanding of the environment improves. This means:
Anchors can be created from several sources:
The most common pattern — the user points at a surface, and an anchor is created at the intersection:
const session = await navigator.xr.requestSession('immersive-ar', {
requiredFeatures: ['hit-test', 'anchors']
});
// Create hit test source
const hitTestSource = await session.requestHitTestSource({
space: viewerReferenceSpace
});
// In frame loop
function onXRFrame(time: number, frame: XRFrame) {
const hitResults = frame.getHitTestResults(hitTestSource);
if (hitResults.length > 0) {
const pose = hitResults[0].getPose(referenceSpace);
if (pose) {
const anchor = await frame.createAnchor(pose.transform, referenceSpace);
// attachObjectToAnchor(anchor);
}
}
}
If plane detection is active, you can create anchors directly from plane geometry:
// Get all detected planes
const detectedPlanes = frame.detectedPlanes;
for (const plane of detectedPlanes) {
const centerPose = frame.getPose(plane.planeSpace, referenceSpace);
if (centerPose && plane.semanticLabel === 'floor') {
const anchor = await frame.createAnchor(centerPose.transform, referenceSpace);
break;
}
}
You can also create anchors at arbitrary positions in space:
const arbitraryTransform = new XRRigidTransform({
x: 1.5, y: 0.5, z: -1.0 // 1.5m right, 0.5m up, 1m in front
});
const anchor = await frame.createAnchor(arbitraryTransform, referenceSpace);
Understanding anchor states is critical for robust applications:
Creation → Tracking → (Loss) → (Recovery) → Deletion
// Check anchor tracking state each frame
function updateAnchor(anchor: XRAnchor, frame: XRFrame) {
const anchorPose = frame.getPose(anchor.anchorSpace, referenceSpace);
if (!anchorPose) {
// Anchor is temporarily lost — occluded or out of tracking area
// Option 1: Hide attached object
anchorObject.visible = false;
// Option 2: Use last known position as fallback
anchorObject.position.copy(lastKnownPosition);
return;
}
// Anchor is tracking — update object transform
anchorObject.position.copy(anchorPose.transform.position);
anchorObject.quaternion.copy(anchorPose.transform.orientation);
lastKnownPosition.copy(anchorPose.transform.position);
// Anchor is fully tracking
anchorObject.visible = true;
}
Anchor loss happens when:
Some anchors recover automatically when tracking is re-established. Others remain permanently lost — this is runtime-dependent.
Plane detection identifies real-world surfaces (floors, walls, tables, ceilings) and exposes them as XRPlane objects.
const session = await navigator.xr.requestSession('immersive-ar', {
requiredFeatures: ['plane-detection']
});
Plane detection is incremental — planes are added, removed, and refined over time:
session.addEventListener('plane-detection', (event: XRPlaneEvent) => {
// New planes detected
for (const plane of event.added) {
visualizePlane(plane);
}
// Existing planes changed (polygon refined, classification updated)
for (const plane of event.changed) {
updatePlaneVisualization(plane);
}
// Planes removed (no longer visible or merged)
for (const plane of event.removed) {
removePlaneVisualization(plane);
}
});
Each XRPlane exposes:
interface XRPlane {
planeSpace: XRSpace; // Origin at plane center
polygon: DOMPointReadOnly[]; // Boundary vertices
lastChangedTime: number; // Timestamp of last modification
semanticLabel?: string; // 'floor', 'wall', 'ceiling', 'table', etc.
orientation: string; // 'horizontal' | 'vertical'
}
Create a mesh from the polygon vertices:
function createPlaneMesh(plane: XRPlane): THREE.Mesh {
const shape = new THREE.Shape();
const points = plane.polygon;
shape.moveTo(points[0].x, points[0].z);
for (let i = 1; i < points.length; i++) {
shape.lineTo(points[i].x, points[i].z);
}
shape.closePath();
const geometry = new THREE.ShapeGeometry(shape);
const material = new THREE.MeshBasicMaterial({
color: plane.semanticLabel === 'floor' ? 0x4488ff : 0xff8844,
transparent: true,
opacity: 0.3,
side: THREE.DoubleSide
});
const mesh = new THREE.Mesh(geometry, material);
// Rotate from XZ to XY (Three.js convention)
mesh.rotation.x = -Math.PI / 2;
return mesh;
}
Note: The polygon vertices are in the plane's local coordinate system (XZ plane), not world space. Attach the mesh to a
THREE.Object3Dparented to theplaneSpacetransform.
The semanticLabel property provides human-readable classification:
| Label | Meaning | Typical Use |
|---|---|---|
floor | Ground surface | Teleport targets, object placement |
ceiling | Overhead surface | Lighting, ambient occlusion probes |
wall | Vertical surface | Portal windows, art displays, occlusion |
table | Horizontal surface at waist height | Miniature models, board games |
door | Doorway opening | Room transitions (experimental) |
window | Window opening | Portal to outdoor scenes (experimental) |
Not all runtimes provide semantic labels. Always check if the label exists before relying on it.
Anchors can survive application restarts using their UUID:
// When anchor is created
const anchor = await frame.createAnchor(transform, referenceSpace);
const anchorId = anchor.uuid;
localStorage.setItem('mySavedAnchor', anchorId);
// On subsequent session — restore anchors
const savedAnchorId = localStorage.getItem('mySavedAnchor');
if (savedAnchorId) {
// Session.requestAnchorInformation() returns anchor info by UUID
const anchorInformation = await session.requestAnchorInformation(savedAnchorId);
if (anchorInformation) {
// Re-create scene objects at this anchor
}
}
Important limitations:
session.enabledFeaturesThe most common UX pattern for object placement:
class PlacementController {
private previewObject: THREE.Mesh;
private placedAnchors: XRAnchor[] = [];
private hitTestSource: XRHitTestSource | null = null;
async initialize(session: XRSession, refSpace: XRReferenceSpace) {
this.hitTestSource = await session.requestHitTestSource({
space: refSpace
});
}
update(frame: XRFrame) {
const results = frame.getHitTestResults(this.hitTestSource!);
if (results.length > 0) {
const pose = results[0].getPose(referenceSpace);
if (pose) {
// Move preview to hit surface
this.previewObject.position.copy(pose.transform.position);
this.previewObject.visible = true;
}
} else {
this.previewObject.visible = false;
}
}
async placeObject(frame: XRFrame) {
const results = frame.getHitTestResults(this.hitTestSource!);
if (results.length > 0) {
const pose = results[0].getPose(referenceSpace);
if (pose) {
const anchor = await frame.createAnchor(pose.transform, referenceSpace);
this.placedAnchors.push(anchor);
// Instantiate final object at anchor position
}
}
}
}
| Factor | Impact | Mitigation |
|---|---|---|
| Anchor count | Each anchor adds tracking overhead | Limit to 32 simultaneous anchors |
| Plane polygon complexity | Complex planes = more vertices | Simplify polygon to convex hull |
| Plane detection frequency | CPU cost per frame | Use plane-detection event, don't poll |
| Persistent anchor restore | May trigger full environment scan | Restore anchors lazily after session starts |