Session visibility

The user can still see your frames while a system menu sits on top of them. visibilityState tells you that, and your code has to treat it as its own case.

Three states, and the two ways people get the middle one wrong

Switch the state and watch two things: whether the blade keeps turning (are you still rendering?) and whether the sphere keeps following input (are you still reading it?). Turn on the common mistakes to see what each wrong branch looks like.

The demo needs WebGL. The states and the correct handling are written out below.

Starting the demo…

Why two states are not enough

A page that is not in front of the user should stop working. That instinct is right on the open web, where the states are visible and hidden and the browser stops calling your animation callback. Carrying it into XR produces a specific bug: the user opens the system menu, your application freezes, and when they dismiss the menu they are looking at a scene that jumped forward in time.

The reason is that a headset does not simply stop showing your content when something else appears. The runtime composites the menu over your frames, so you are still on screen and still expected to produce frames. What you are not expected to do is treat the user's hands as input, because those hands are operating the menu.

WebXR gives this its own state. XRSession.visibilityState is one of three strings, and the middle one has no equivalent in ordinary web page lifecycle.

The three states

These are the values of XRSession.visibilityState. The runtime sets them; you cannot.

visible
Your content is being shown and the session is receiving input. The ordinary case, and the only one where reading input sources is appropriate.
visible-blurred
Your content is still being shown, but input is being delivered somewhere else — typically a system menu or an OS-level dialog composited over your frames. Keep rendering. Do not act on input. Poses may still be reported, and acting on them is the bug.
hidden
Your content is not being shown. The runtime is not obliged to call your frame callback at all, so do not rely on it to drive timers or to notice that time has passed.

Reading it and reacting to it

The event fires on the session, and the current value is always readable from the session object. Reading it inside the frame callback is the more robust of the two, because it cannot be out of date.

session.addEventListener('visibilitychange', (event) => {
  switch (event.session.visibilityState) {
    case 'visible':
      resumeInput();
      break;
    case 'visible-blurred':
      // Still on screen. Keep drawing, stop listening.
      suspendInput();
      break;
    case 'hidden':
      // Frames may stop arriving. Save anything you need saved.
      suspendInput();
      pauseSimulation();
      break;
  }
});

function onXRFrame(time, frame) {
  session.requestAnimationFrame(onXRFrame);

  // Render on every frame you are given, including blurred ones.
  renderScene(frame);

  if (session.visibilityState !== 'visible') return;
  handleInput(frame);
}

Checking the state inside the frame callback as well as in the event handler costs nothing and removes a class of ordering bug, because the event and the frame do not arrive in a guaranteed order.

The two mistakes, and what each looks like

Treating visible-blurred as if it were hidden is the more common one. The application stops its render loop, the compositor keeps showing the last frame it received, and the user sees a still image behind the menu. Dismiss the menu and the scene resumes from where it stopped, which reads as a hitch or a teleport depending on how long the menu was open.

Treating it as if it were visible is the more embarrassing one. The user reaches for a menu button, and because your code is still consuming the same poses, their avatar reaches with them. In a multiplayer session everyone else watches them wave at nothing. In a single-player one they dismiss the menu and find they have dropped whatever they were holding.

Both wrong branches are in the demo above. Turn on the mistakes toggle and switch to visible-blurred: the blade stops turning, or the sphere keeps tracking, depending on which failure you are looking at.

What to actually do in each state

Keep rendering in visible and visible-blurred. Rendering is cheap relative to the alternative, which is showing the user a frozen frame at the moment they are most likely to be paying attention to your application's responsiveness.

Gate input on visible alone. That means controller buttons, hand gestures, and anything derived from a pose: a teleport arc, a grab, a UI hover. It does not mean gating the poses themselves out of your scene graph, since a hand model that keeps updating its position while its interactions are suppressed looks correct and feels correct.

Treat hidden as a pause, and save first. This is the state where your frame callback may simply never be called again. Anything that depends on time advancing — a countdown, a physics step, a network heartbeat — needs a source other than requestAnimationFrame, or needs to reconcile after the fact.

Mistakes that cost the most time

Wiring it to document.visibilityState
The page and the session are different things with different lifecycles, and the page-level API has no equivalent of visible-blurred. A session can be visible while the page reports hidden.
Assuming poses stop arriving when blurred
The runtime may keep reporting input source poses during visible-blurred. Code that only checks whether a pose exists, rather than checking the state, will act on them.
Using the frame callback as a clock
It stops in hidden and is not guaranteed to be regular elsewhere. Anything that must keep time needs a real timestamp and a reconciliation step.
Testing only by removing the headset
Taking a headset off usually produces hidden, which is the easy case. Reaching visible-blurred means opening the system menu, and that is the state worth testing deliberately.

Further reading