Reference spaces

Every position you write in a WebXR app is relative to something. That something is the reference space, and it is the one piece of the coordinate system your code never states out loud.

The same coordinate, five different places

The purple box is at (0, 0, -1) in every one of these, and that line of code never changes. What moves is the origin: the ring and the three axes. Drag the height slider while local is selected to see why that space cannot be trusted to put anything on the floor.

The demo needs WebGL. The five spaces and what each one anchors to are described below.

Starting the demo…

The bug this causes

You place a table in your scene at y = 0, load it on a headset, and the table is floating at chest height. Or you build on a headset, it looks right, a colleague tries it and everything sits six inches too low. Nothing in the scene code is wrong. The reference space is different from the one you assumed.

This happens because a WebXR position is meaningless on its own. When you ask for a pose, you ask for it relative to a reference space, and that space decides what the origin means: the floor under your feet, the height your head happened to be at when the session started, or your head itself, moving as you move.

The API makes this easy to skip over. You request a space once during setup, store it in a variable, and pass it to getViewerPose on every frame thereafter. From then on it is invisible. Getting it right at the start is cheaper than working out later why a scene that was authored in metres is offset by an amount nobody can name.

The five types

These are the values you pass to requestReferenceSpace. A device may refuse any of them, so every request can reject.

viewer
The origin is the viewer's head, and it moves with them. Content placed here is stuck to the face, which makes it right for a loading indicator or a debug readout and wrong for anything that should stay in the room. Every device supports it, and it is the one space that cannot fail.
local
The origin is roughly where the viewer was when the session started, with y = 0 at the height of their head, not the floor. Good for seated experiences and for content that should appear in front of whoever launched it. Bad for anything that needs to rest on the ground, because the offset varies with the person.
local-floor
Same as local, but y = 0 is the floor, determined by the runtime rather than by where a head happened to be. This is the right default for standing content. The floor estimate may be a configured value from device setup rather than a fresh measurement, so treat it as accurate to a few centimetres.
bounded-floor
A floor-relative space that also gives you boundsGeometry: a polygon describing the area the user can safely walk in. Use it when your content should fit the room the user actually has. The polygon is not necessarily a rectangle and can change during the session.
unbounded
For content that spans a large area, where the user may walk far enough that tracking drift and floating-point precision start to matter. The runtime is allowed to move the origin to keep tracking stable, which means world positions you cached are no longer where you left them.

Requesting one, with a fallback

There is no capability query to ask in advance which spaces a device offers. You find out by requesting one and handling the rejection, which is why the fallback chain above is the standard shape.

The floorOffset line is the part people leave out. Falling back from local-floor to local without compensating is exactly how content ends up floating: you have kept the coordinates and lost the meaning of y = 0.

// Ask for the most specific space you can use, then fall back.
async function getSpace(session) {
  for (const type of ['local-floor', 'local', 'viewer']) {
    try {
      return { space: await session.requestReferenceSpace(type), type };
    } catch {
      // This device does not offer that type. Try the next one.
    }
  }
  throw new Error('no usable reference space');
}

const { space, type } = await getSpace(session);

// If you fell back to 'local', y = 0 is head height, not the floor.
// Shift the content down by an assumed height so it still lands on the ground.
const floorOffset = type === 'local' ? -1.6 : 0;
world.position.y = floorOffset;

session.requestAnimationFrame(function onFrame(time, frame) {
  session.requestAnimationFrame(onFrame);
  const pose = frame.getViewerPose(space);
  if (!pose) return; // Tracking can be lost for a frame. Skip, do not crash.
  render(pose);
});

requestReferenceSpace rejects rather than returning null, so each attempt needs its own catch. Requesting the list in order of specificity means a device that supports the good one never sees the fallback.

What each space anchors to

The column that matters most is the third one, because it is the one that is invisible in your code.

TypeOriginWhat y = 0 isTypical use
viewerThe head, movingEye level, alwaysHead-locked UI, debug overlays
localStart positionHead height at session startSeated experiences
local-floorStart positionThe floorStanding content, the usual default
bounded-floorStart positionThe floorRoom-scale content that respects the play area
unboundedRuntime-chosen, may moveThe floorLarge-area AR and outdoor experiences

Checked against the WebXR Device API specification in 2026-09. Availability varies by device: viewer and local are universal, bounded-floor and unbounded are not.

The origin can move under you

A reference space is not a permanent promise. The runtime can decide the origin needs to move, most commonly when the user triggers a recentre from the system menu, and it announces this by firing a reset event on the space.

What breaks is anything you computed once and stored. A teleport target saved as a world position, a piece of furniture placed at the start of the session, a spatial audio source: after a reset, all of them are somewhere else relative to the user, because the coordinate system moved and your stored numbers did not.

Handling it properly means treating the event as an instruction to recompute rather than an event to log. Anchors exist precisely so you do not have to do this by hand, which is why placing real-world content with an XRAnchor is more robust than storing coordinates yourself.

Reacting to a reset

A reset is not an error and does not end the session. Silence here does not produce a crash, which is what makes it hard to find: the session keeps running, and the content is simply in the wrong place from that point on.

space.addEventListener('reset', (event) => {
  // Anything you cached in this space's coordinates is now stale.
  clearPlacedObjects();
  recomputeTeleportTargets();

  // bounded-floor: the play area polygon may also have changed.
  if (space.boundsGeometry) {
    updatePlayArea(space.boundsGeometry);
  }
});

The event also fires on the first frame in some runtimes, so the handler must be safe to run before anything has been placed.

Reading the boundary, if there is one

bounded-floor gives you boundsGeometry: an array of DOMPointReadOnly values forming a polygon in the space's own coordinates, listed anticlockwise, with y at zero. It describes where the user can walk, not where your content must fit, and the two are different requirements.

The polygon is not a rectangle. Treating it as a bounding box gets you a play area that includes corners the user cannot reach, and a game that puts an objective in one of them. If you need a rectangle, compute the largest one that fits inside the polygon rather than the one that contains it.

It can also be empty or absent even on a device that granted the space, which usually means the user has not set up a boundary. Design for the case where you know nothing about the room, and treat the polygon as an improvement when it arrives.

Moving the user without moving the world

getOffsetReferenceSpace returns a new space derived from the one you have, with a fixed transform applied. It is the sanctioned way to teleport, to apply snap turning, and to seat a user at a particular spot without rewriting every position in the scene.

Doing it this way rather than translating your scene root matters for two reasons. Tracked content stays consistent, because the runtime knows about the offset and applies it to poses and hit tests alike. And the original space is unchanged, so you can always derive a fresh offset from it instead of accumulating floating-point error through repeated additions.

The derived space is a real reference space and forwards the reset event, so a handler attached to the original does not fire for it. Attach to whichever space you are actually passing to getViewerPose.

// Derive a shifted space rather than repositioning your scene.
const offset = new XRRigidTransform(
  { x: 0, y: 0, z: -5 },          // five metres forward
  { x: 0, y: 0, z: 0, w: 1 },     // no rotation
);
const moved = space.getOffsetReferenceSpace(offset);

// From here on, pass 'moved' to getViewerPose instead of 'space'.
// The room stays where it is; the user's coordinates have shifted.

The transform is applied in the original space, and the sign is the opposite of what people expect: an offset of z = -5 moves the user forward, because you are moving the origin backwards relative to them.

Mistakes that cost the most time

Assuming local-floor is available
It usually is, and then you test on a device where it is not, and the promise rejects during setup. Because this happens before the first frame, the failure looks like "the session would not start" rather than "one request failed".
Falling back without compensating
Dropping from local-floor to local and keeping the same scene coordinates puts everything roughly 1.6 metres too high. The scene is correct, the space is correct, and the result is wrong.
Hardcoding an average height
A fixed 1.6 metre offset is a reasonable fallback and a bad assumption. Users range well outside it, and an offset that is wrong by 20 centimetres is exactly the kind of error that reads as "this feels off" rather than as a bug report.
Ignoring the reset event
Everything works until someone recentres, and then the content is somewhere else. Because the session continues normally, this is almost never reproduced by the developer and almost always reported by a user.
Mixing spaces in one frame
Poses obtained in different reference spaces are in different coordinate systems. Comparing a controller pose in local-floor against a cached position in viewer produces numbers that look plausible and are meaningless.

Further reading