npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

sphere-shell

v0.3.7

Published

React/WebXR window manager: movable, resizable, dockable content windows on a spherical shell around the viewer, immersive VR and magic-window browser fallback from one code path.

Readme

sphere-shell

A React window manager for WebXR: content windows (video, text, images, arbitrary UI) are arranged on an invisible sphere around the viewer. Windows can be moved, resized, minimized to a dock, focused, and overlapped, all at (approximately) a constant distance from the viewer. The same code path renders immersively on WebXR headsets and as a "magic window" (mouse/touch-driven 3D view) in ordinary browsers.

sphere-shell is step 1 of the OpencastXR project — a standalone, general-purpose spatial window shell. Step 2 (an Opencast lecture player) is a separate app built on top of it.

Features

  • Spherical window shell with configurable radius, movement bounds, and resize clamps
  • Move, resize, minimize/restore, close/reopen (via a dock whose minimized tiles show a one-shot snapshot taken at minimize time and whose closed tiles are visually distinct), and focus, for every window — dragging a window near a neighbour's edge magnetically snaps it against it, no overlap, with a small configurable gap by default (curved-mode-aware, so windows never visually overlap once bent onto the shell either)
  • A dock slot for your own in-scene controls, a three-dot menu for the shell's own housekeeping actions (extensible with your own rows), and an in-scene Exit VR button — because a DOM overlay is invisible inside an immersive session
  • One code path for immersive VR (Meta Quest 3, controllers and hand tracking) and magic-window fallback everywhere else (desktop, iPad, phone)
  • Unified input: controller ray, hand pinch, mouse, and touch all drive the same R3F pointer events
  • Content components: VideoSurface, ImageContent, MarkdownContent, HtmlSnapshot (a static DOM-snapshot escape hatch), plus arbitrary @react-three/uikit children
  • Declarative arrange/recenter, and layout serialization (getLayout/setLayout) for saving and restoring a user's window arrangement
  • VR locomotion that moves the player, not the shell: thumbstick dolly plus comfort-capped smooth rotation, both disable-able

Install

sphere-shell declares its 3D/XR stack as peer dependencies so your app controls the exact versions:

npm install sphere-shell @react-three/fiber@^9.0.0 @react-three/uikit@^1.0.74 @react-three/xr@^6.4.0 three@>=0.160.0 react@^19.0.0 react-dom@^19.0.0

(zustand, marked, html2canvas and @react-three/uikit-lucide are ordinary dependencies of sphere-shell and are installed automatically — you don't need to add them yourself.)

@react-three/uikit-lucide supplies the window-control and dock icons. It is a plain dependency rather than a peer dependency because you never import it: it is an internal rendering detail, and there is nothing for you to configure. The one hazard of that choice is that @react-three/uikit-lucide declares @react-three/uikit as an ordinary dependency of its own (range ^1.0.74), so a project pinned to an older 1.0.x uikit would end up with two uikit copies and uikit's module-level state would split. The @react-three/uikit peer range above is therefore ^1.0.74 and not ^1.0.0: any uikit that satisfies sphere-shell also satisfies the icon package, and package managers deduplicate to one copy.

Quick start

import { useEffect, useMemo } from 'react'
import { Canvas } from '@react-three/fiber'
import { XR, createXRStore } from '@react-three/xr'
import {
  WindowShell, Window, VideoSurface, MarkdownContent, xrPointerOptions,
} from 'sphere-shell'

// Spreading `xrPointerOptions` is required, not decorative: without it
// @react-three/xr draws the controller ray and cursor underneath your windows,
// and draws the ray only 1 m long so it stops short of them. Pass the same
// radius you give <WindowShell>. See "XR pointer setup" under Conventions.
const xrStore = createXRStore({ ...xrPointerOptions({ radius: 2 }) })

// sphere-shell's <VideoSurface> never creates or controls the video element —
// your app owns playback (see Known limitations below). This hook builds one
// and, per Chrome's autoplay/power-saving policy, keeps it attached to the DOM
// (off to the side, nearly invisible) so playback doesn't silently stall.
function useVideo(url: string): HTMLVideoElement {
  const video = useMemo(() => {
    const v = document.createElement('video')
    v.src = url
    v.loop = true
    v.muted = true
    v.playsInline = true
    return v
  }, [url])

  useEffect(() => {
    video.style.position = 'fixed'
    video.style.left = '0'
    video.style.top = '0'
    video.style.width = '2px'
    video.style.height = '2px'
    video.style.opacity = '0.01'
    video.style.pointerEvents = 'none'
    document.body.appendChild(video)
    return () => {
      document.body.removeChild(video)
    }
  }, [video])

  return video
}

function App() {
  const video = useVideo('/lecture.mp4')

  return (
    <>
      <button onClick={() => void video.play()}>Play</button>
      <Canvas camera={{ position: [0, 0, 0.01], fov: 70 }}>
        <ambientLight intensity={1} />
        <XR store={xrStore}>
          <WindowShell radius={2}>
            <Window
              id="video-main"
              title="Lecture"
              size={{ width: 40 }}
              aspect={16 / 9}
              position={{ azimuth: -24, elevation: 8 }}
            >
              <VideoSurface src={video} />
            </Window>
            <Window
              id="chapters"
              title="Chapters"
              size={{ width: 22, height: 34 }}
              position={{ azimuth: -55, elevation: -8 }}
            >
              <MarkdownContent value={'# Chapters\n\n- Intro\n- Architecture'} />
            </Window>
          </WindowShell>
        </XR>
      </Canvas>
    </>
  )
}

Call xrStore.enterVR() (e.g. from a button, gated on navigator.xr?.isSessionSupported('immersive-vr')) to enter VR; the same tree renders as a magic window until then. See apps/demo/src/App.tsx in this monorepo for a complete, working example (two videos, markdown, an image, a uikit widget window, and an HtmlSnapshot).

API reference

Components

| Export | Props | Notes | |---|---|---| | WindowShell | radius?, bounds?, snapDegrees?, minWidth?, maxWidth?, edgeSnapDegrees?, edgeSnapGapDegrees?, windowAnimations?, windowFlightSeconds?, dragDeadZoneDegrees? (all of ShellConfig, partial), initialLayout?: ShellLayout, dock?: boolean (default true), dockControls?: ReactNode, dockMenuItems?: AppDockMenuItem[], recenterKey?: string \| null (default 'r'), rotationSpeed?: number \| null (default 60), recenterButton?: 'a-button' \| 'b-button' \| null (default 'a-button'), curved?: boolean (default false, experimental), children? | Root component: creates the store, anchor group, dock, and navigation (MagicWindowControls + XRControls). Defaults come from DEFAULT_CONFIG (see Conventions below) for any field you don't pass. The ShellConfig fields and initialLayout are applied once, at mount — see "Mount-time-only configuration" below. curved is the exception: it is synced when it changes, because it exists to be toggled live — see "Curved windows (experimental)". dockMenuItems is threaded straight to Dock's menuItems — see "The dock". | | ShellProvider | { store: WindowStore; children: ReactNode } | The context provider WindowShell uses internally — it takes an already-created store (from createWindowStore) rather than config, and does not render an anchor group, dock, or navigation itself. Use it directly instead of <WindowShell> only for advanced composition: e.g. sharing one store across more than one render tree, or building an entirely custom shell (your own dock/navigation) around the same window state. Most apps should use <WindowShell>. | | Window | id: string (required), title?, size?: { width?, height? } (degrees), aspect?: number, position?: { azimuth, elevation } (degrees; auto-placed if omitted), movable? (default true), resizable? (default true), minimizable? (default true), dockTile? (default true), onClose?, onMinimize?, onFocus?, children? | Renders a title bar (drag handle, minimize and close buttons drawn as lucide Minus/X icons — uikit's default font has neither glyph, so text / rendered as empty boxes on a headset) plus a resize grip in the corner, tangential to the sphere, facing the shell's anchor. title, size and position are kept in sync with the store when they change; id, aspect and the three capability flags are mount-time only — see "Mount-time-only configuration" below. Minimizing, restoring, or closing animates a fly-to/from-dock tween — to/from the dock's anchor regardless of dockTile — unless DEFAULT_CONFIG.windowAnimations is false — see "Window fly-to/from-dock animation" under Conventions. | | Dock | children?: ReactNode, menuItems?: AppDockMenuItem[] | Rendered automatically by WindowShell when dock is not false; a panel at −30° elevation in two stacked strips — one tile per minimized or closed window on top (only when there are any), and below it the control strip: your app-controls slot (children), a three-dot menu holding "Arrange", "Recenter", the experimental Curved/Flat toggle and any menuItems you append, and — only during an XR session — a red X that ends the session (hover it for an "Exit VR" label). Your slot child defines the dock's width. A minimized window's tile shows a static snapshot rendered once, at the moment it was minimized — not a live view (deliberate: see "Performance guard rails" in the design spec). See "The dock" under Conventions. Exported for advanced/custom layouts. | | VideoSurface | src: HTMLVideoElement | Wraps an externally owned and controlled video element as a THREE.VideoTexture. Never calls .play()/.pause() itself; aspect ratio is read live from videoWidth/videoHeight (with a 16:9 fallback until metadata loads) and updates on resize/loadedmetadata. | | ImageContent | src: string, fit?: 'cover' \| 'contain' (default 'contain') | 'contain' letterboxes to fit the window without cropping; 'cover' fills the window and crops. | | MarkdownContent | value: string | Renders headings (h1–h3), paragraphs, list items, a single top-level image per paragraph, and fenced code blocks, in a vertically scrollable column. Parsed via markdownTokensToSpec (also exported, with its BlockSpec union type) using marked's lexer. | | HtmlSnapshot | element: HTMLElement \| null, trigger?: unknown | Escape hatch for static, non-interactive HTML in a window: rasterizes element via html2canvas once per element/trigger change (not per frame) into a texture. | | HoverLabel | label: string, controlHeight?: number (default 30), suppressed?: boolean, children?: ReactNode | A hover tooltip for an in-scene control: the children as-is, plus a small label panel that appears just above them while a pointer or controller ray rests on them. uikit 1.0.74 has no tooltip primitive, and an icon-only button in a headset needs one — it is what lets the dock's Exit VR button be a bare red X. Hover is tracked with pointer enter/leave (over/out re-fire on every change of intersected object and would flicker the label), and the panel is absolutely positioned, pointerEvents="none", and lifted by PANEL_OVERLAY_Z_INDEX. suppressed keeps it hidden while hovered - for a control that already says what it does, such as the three-dot button while its own menu is open in the same space. label must be plain ASCII (see "Text glyphs" in Known limitations). | | MagicWindowControls | recenterKey?: string \| null (default 'r') | Mounted automatically by WindowShell. Flat-screen navigation: drag background to look around, wheel/pinch to dolly, R (or Recenter in the Dock's ... menu) to recenter. Self-deactivates during an XR session. See "Keyboard shortcut" below for the shortcut's filtering rules and how to remap or disable it. | | XRControls | rotationSpeed?: number \| null (default 60), recenterButton?: 'a-button' \| 'b-button' \| null (default 'a-button') | Mounted automatically by WindowShell. VR navigation: right-thumbstick dolly (stick Y) and smooth rotation (stick X), hold the configured right-controller face button ~1s to recenter, and one automatic recenter on entering a session. Reads nothing else — no left controller, no other button. Self-deactivates outside a session. While the recenter button is held, a small ring fixed in front of the camera sweeps closed over the hold and disappears on release/completion — see longPressProgress (components/navigation/xrNavigation.ts) and RecenterHoldRing. See "Smooth stick rotation" and "The recenter button" under Conventions. | | HeadLocked | distance?: number (default 1.2), config?: Partial<HeadLockedConfig>, children? | A lazy-follow HUD container: uikit children that gently chase the viewer's gaze instead of being rigidly pinned to it, or fixed in world space like a Window. Not a window — no title bar, no dock tile, no store registration; mount it as a sibling of <WindowShell>. Read-only UI only — see "HeadLocked HUD containers" under Conventions for why interactive controls belong in a Window or the Dock instead. |

Hooks

| Export | Signature | Notes | |---|---|---| | useWindowManager() | () => { windows, focus(id), minimize(id), restore(id), close(id), setTitle(id, title?), move(id, pos, opts?), resize(id, size), arrange(opts?), getLayout(), setLayout(layout), recenter(), setCurved(boolean) } | Imperative control surface for all windows. move's opts is { snap?: boolean } (snaps to snapDegrees when set); arrange's opts is ArrangeOptions ({ fov?, gap? }, defaults 120°/3°). setCurved is experimental — see "Curved windows (experimental)". Returns a fresh object every render — destructure the actions you need rather than depending on the object's identity. | | useWindowState(id: string) | (id) => WindowEntry \| undefined | Reactive state for a single window (position, size, minimized/closed, zOrder, etc.). | | useCurved() | () => boolean | Experimental. Whether curved window mode is currently on. See "Curved windows (experimental)". | | useCurvedAvailable() | () => boolean | Experimental. Whether curved mode can be delivered at all on the installed three/@react-three/uikit. true unless the shell has detected that its shader injection no longer applies, after which setCurved(true) is a no-op and windows stay flat. See "What happens if a uikit or three upgrade breaks it". | | useShellConfig() | () => ShellConfig | Reactive current shell config (radius, bounds, snapDegrees, minWidth, maxWidth, edgeSnapDegrees, edgeSnapGapDegrees, windowAnimations, windowFlightSeconds, dragDeadZoneDegrees). | | useShellContext() | () => { store, anchorRef, requestRecenter, registerRecenterHandler } | Lower-level context; most apps want useWindowManager/useWindowState instead. | | useShellStore() | () => WindowStore | The raw zustand store, for advanced use (e.g. reading state outside React). | | useCaptureClick({ onClick, disabled? }) | (opts) => { onPointerDown, onPointerUp, onPointerCancel, onClick } | Capture-based press/release clicks — spread the result onto a control in place of a plain onClick, so a tremor that drifts the ray between press and release no longer silently drops the click. See "Catching press-and-release, not just click" under Conventions. | | useDockBendFrame() | () => DockBendFrame ({ group, bendRadiusM, bendRadiusPx, pixelSize, curved }) | Experimental. For a component rendered in the dock's app slot (DockProps.children / WindowShellProps.dockControls) that needs to correct its own ray-hit math under curved mode — e.g. a timeline scrub. curved is the dock's RENDERED state (not the curved prop you asked for), derived together with the other four fields in one useMemo so they can never disagree. group is a METRES-space frame (Object3D.worldToLocal idiom); bendRadiusM matches that frame directly, bendRadiusPx is the SAME radius in uikit layout-pixel units, and pixelSize converts between the two — mixing a metres-space local x with the pixel radius (or vice versa) silently loses the curvature correction with no error, which is exactly why both are exposed. All four are null exactly when curved is false. See "Curved windows (experimental)" for the worked, unit-consistent recipe. |

Core / store (React-free, also exported)

| Export | Kind | Notes | |---|---|---| | createWindowStore(configOverrides?, initialCurved?), DEFAULT_CONFIG, ShellConfig, WindowStore, RegisterWindowOptions | store | The zustand store WindowShell creates internally. Nested config objects (bounds) are cloned per store, so mutating one store's config never leaks into another's or into DEFAULT_CONFIG. | | arrangeWindows(windows, opts?), ArrangeOptions | function/type | Packs open windows into centered rows within a field of view (opts.fov, default 120°) with a gap (opts.gap, default 3°); larger windows toward the row's center. The store's arrange(opts?) forwards the same options. | | snapPosition(draggedId, dragged, windows, thresholdDeg, bounds, opts?), DraggedWindow, SnapOptions | function/types | The magnetic edge-snap decision behind dragging — see "Window edge snapping" under Conventions. dragged is { azimuth, elevation, width, height } (a DraggedWindow); windows is searched for the nearest in-threshold neighbour on each axis independently, excluding draggedId and any minimized/closed window. opts (SnapOptions, default {}) is { curved?: boolean, gapDeg?: number }: curved (default false) widens each window's AZIMUTH half-extent from width / 2 to bentHalfExtentDegrees(width) — pass the shell's actual RENDERED curved state, curved && curvedAvailable, never the raw curved flag alone, or curved-mode windows will snap into visible overlap; gapDeg (default 0) adds that many degrees between a snapped pair's edges on both axes. A candidate whose touch target would land outside bounds is skipped rather than clamped. Returns the snapped { azimuth, elevation }, or null if nothing was close enough (or in bounds). useDragOnSphere is the only built-in caller; exported for a custom drag implementation. | | edgeSnapThresholdDeg(width, height), EDGE_SNAP_MIN_THRESHOLD_DEG (3), EDGE_SNAP_MAX_THRESHOLD_DEG (8), EDGE_SNAP_PROPORTION (0.09) | function/constants | What edgeSnapDegrees: 'auto' (the default) resolves to for a given window: EDGE_SNAP_PROPORTION of min(width, height), clamped to [EDGE_SNAP_MIN_THRESHOLD_DEG, EDGE_SNAP_MAX_THRESHOLD_DEG]. useDragOnSphere calls it once per drag against the DRAGGED window's own size; exported so a custom drag implementation (or an app that wants to preview the threshold a given window will get) can call it directly. | | ALIGNMENT_TIE_BAND_DEG (0.25) | constant | How close (in degrees) snapPosition's top-align and bottom-align distances have to be, against the SAME side-by-side neighbour, before top wins regardless of which is numerically nearer — see "Edge ALIGNMENT" under "Window edge snapping" and snapPosition's doc comment. | | ShellLayout (type), markdownTokensToSpec/BlockSpec | types/fn | ShellLayout is the shape returned by getLayout()/accepted by setLayout() (JSON-serializable, version: 1). | | sphericalToCartesian, cartesianToSpherical, panelDimensions, radiusForDepth, clampPosition, clampSize, intersectRaySphere | functions | The spherical-math primitives described under Conventions below. | | windowRenderOrder(depthRank, openCount), WINDOW_RENDER_ORDER_MIN, WINDOW_RENDER_ORDER_MAX, DOCK_RENDER_ORDER, HEADLOCKED_RENDER_ORDER, XR_POINTER_RENDER_ORDER, APP_RENDER_ORDER_MIN, WINDOW_POINTER_EVENTS_ORDER, DOCK_POINTER_EVENTS_ORDER, HEADLOCKED_POINTER_EVENTS, DECORATIVE_POINTER_EVENTS, APP_POINTER_EVENTS_ORDER_MIN, PANEL_OVERLAY_Z_INDEX, WINDOW_CONTROL_Z_INDEX, XR_RAY_MAX_LENGTH_FACTOR, XR_CLICK_THRESHOLD_MS, xrRayMaxLength(radius), xrPointerOptions({ radius }) | consts/functions | The draw-order and hit-order band allocation — see "Draw-order and hit-order bands" under Conventions. xrPointerOptions({ radius }) is a ready-made createXRStore options fragment; spread it into your store, or the controller ray and cursor will be drawn behind your windows, the ray will stop 1 m short of them, and any press over 300 ms will not click at all. DECORATIVE_POINTER_EVENTS goes on the icons and labels inside your own buttons — see "Make each button ONE hit target". | | stepHeadLocked(state, targetYawDeg, targetPitchDeg, dtS, config), headLockedTargetFromForward(forward, previousYawDeg), advanceHeadLockedState(previous, forward, dtS, config), headLockedRenderOrder(), DEFAULT_HEADLOCKED, HEADLOCKED_POLE_EPSILON, HeadLockedState, HeadLockedConfig | functions/consts/types | The pure lazy-follow math behind <HeadLocked>, including its pole guard (headLockedTargetFromForward) and its NaN-safety gate (advanceHeadLockedState, the function the component actually calls each frame) — see "HeadLocked HUD containers" under Conventions. Exported so a custom head-locked component can reuse the same behaviour instead of reimplementing it. | | placeHeadLocked(group, headWorldPos, yawDeg, pitchDeg, distanceM) | function | Positions and orients a live three.js Object3D for a head-locked HUD, correct under an arbitrary parent transform (not only at the scene root) — the piece of <HeadLocked>'s per-frame work that needs a real scene-graph object rather than plain data. | | XR_ROTATION_DEGREES_PER_SECOND, XR_ROTATION_DEADZONE, XR_ROTATION_MAX_FRAME_DELTA, XR_DOLLY_INNER_MARGIN, XR_RECENTER_HOLD_SECONDS, stickYawDegrees(xAxis, delta, opts?), yawAroundPivot(pivot, position, yawRadians), StickRotationOptions | consts/functions/type | The VR navigation parameters and the two pure functions behind smooth stick rotation — see "Smooth stick rotation" under Conventions. Exported so a custom navigation component can reuse the same comfort behaviour instead of reimplementing it. | | panelTransform(position, size, radius, depthRank?, openCount?), PanelTransform | function/type | Everything that decides where a panel is and what covers what: { position, rotation, rotationOrder: 'YXZ', radius, dims, renderOrder }, all anchor-local. Window and Dock are thin wrappers over it. Apply rotation as new THREE.Euler(...rotation, rotationOrder) — three's default Euler order is 'XYZ' and would compose the two angles differently — and renderOrder on the panel's root uikit Container, where it is inherited by every descendant. | | SphericalPosition, AngularSize, ShellBounds, Vec3, WindowEntry | types | Core data shapes. | | dockEntries(windows), DockEntry, DockEntryKind | function/types | Which windows get a dock tile and why ('minimized' / 'closed'), in registration order. The built-in Dock is a thin renderer over this; reuse it for a custom dock. | | dockMenuItems({ curved, curvedAvailable }), DockMenuItem, DockMenuItemId | function/types | The rows of the dock's three-dot menu — the shell's own built-ins ('arrange', 'recenter', 'curved'), each with the label it shows in the current state and whether it can be acted on. A toggle row names the state it switches to; an unavailable Curved row is present but enabled: false ("Curved n/a"). Reuse it if you build your own dock. | | dockMenuRows({ curved, curvedAvailable }, appItems?), AppDockMenuItem, DockMenuRow | function/types | dockMenuItems's rows, source: 'shell'-tagged, followed by appItems ({ id, label, onSelect }) source: 'app'-tagged. A sibling of dockMenuItems, not a change to it. Throws on ANY id collision — an app row against a built-in one, or two app rows against each other — see "The dock". What the built-in Dock's menuItems prop is a thin renderer over. | | useXRSession(), XRSessionHandle | hook/type | { active, mode, end } — whether an immersive session is running, which kind it is, and a function that ends it. Reactive (re-renders on session start/stop and on a mode change) and safe with no <XR> ancestor. What the dock's Exit VR button is built on; use it for your own in-scene exit control. mode is the observed kind, not the requested one: @react-three/xr derives it from XRSession.environmentBlendMode, so 'immersive-ar' means "this session composites over the real world" (passthrough on a Quest) and 'immersive-vr' means "over opaque black". An app that offers a passthrough-versus-opaque background needs exactly that, because WebXR has no mid-session mode switch — reaching the other background means ending the session and entering the other mode. null when no session is running. | | RESIZE_GRIP_PX (22), RESIZE_GRIP_HIT_PX (44), gripAngularSize(windowWidthDeg, gripPx, pxPerDeg?) | constants/function | The resize grip's visible and touch-sensitive sizes, and the degrees a given pixel run subtends from the viewer. The angular size is independent of radius (the r cancels — see the function's doc comment), so one pixel size is correct at every radius. | | PX_PER_DEG (16), TITLE_BAR_PX (32), WindowFrame, WindowFrameProps, useDragOnSphere(id, { enabled }), useResizeOnSphere(id, { enabled }) | constants/component/hooks | The pieces a custom frame needs: PX_PER_DEG converts a window's angular size into the uikit pixel size it lays out in, TITLE_BAR_PX is the title bar's height in those pixels, and the two hooks return the uikit pointer-handler props that implement drag-on-sphere and resize-on-sphere. WindowFrame is the built-in frame, exported so you can copy or wrap it — note that a custom frame must put panelTransform's renderOrder and depthWrite={false} on its root Container (or focus-on-overlap breaks, and a Content child will depth-occlude other windows), and zIndex={WINDOW_CONTROL_Z_INDEX} on any control that has to be clickable on top of the window's own content. WindowFrameProps.interactive?: boolean (default true) sets the frame's pointerEventsWindow.tsx passes false while a fly-to/from-dock animation is in progress. Its resize grip is two boxes: a RESIZE_GRIP_HIT_PX (44) square carrying the pointer handlers, with an unpainted background, and a RESIZE_GRIP_PX (22) visible square inside it — a controller ray at 2 m is far shakier than a mouse, so the touch-sensitive area is deliberately larger than the chrome. The cost is that window content in the bottom-right 44×44 px is not clickable. | | easeOutCubic(t), flightProgress(elapsedS, durationS?), interpolateFlightTransform(t, from, to), dockFlightScale(windowSize, tileSize), dockSideEndpoint(dockPosition, windowSize, tileSize), WINDOW_FLIGHT_DURATION_S (0.45), FlightTransform, FlightVec3 | functions/constant/types | The pure tween behind the fly-to/from-dock animation (core/windowFlight.ts): easeOutCubic is the cubic ease-out shape, flightProgress turns elapsed seconds into its [0, 1] input (default duration WINDOW_FLIGHT_DURATION_S, but Window.tsx passes ShellConfig.windowFlightSeconds), interpolateFlightTransform blends a { position, scale } pair at that progress (position and scale only — orientation is not animated, see "Window fly-to/from-dock animation" under Conventions), dockFlightScale sizes the dock-side endpoint's scale from the ratio of the tile's footprint to the window's own, and dockSideEndpoint combines a dock position with that scale into the flight's dock-side FlightTransform — every window's dock-side endpoint since 0.3.4, dockTile: false included. | | DOCK_ANCHOR_POSITION, DOCK_TILE_SIZE, DOCK_ELEVATION (-30) | constants | The dock panel's own anchor-local placement/size (core/dock.ts) — what Dock.tsx positions itself with, and what the fly-to/from-dock animation's dock-side endpoint is computed from via panelTransform(DOCK_ANCHOR_POSITION, DOCK_TILE_SIZE, radius). | | captureWindowPreview(gl, scene, anchor, entry, radius) | function | Renders one dock-preview snapshot into a fresh WebGLRenderTarget and returns it. The caller owns the render target and must keep it alive for as long as rt.texture is sampled (the store's setPreview/restore/unregister handle that for the built-in dock). |

Conventions

  • Units are degrees, everywhere in the public API (positions, sizes, bounds, snap increment). Radians are an internal implementation detail.

  • Azimuth 0° is straight ahead (−Z in three.js world space); positive azimuth is to the right (+X).

  • Elevation 0° is the horizon; positive elevation is up (+Y).

  • Window size is angular (e.g. { width: 40 } degrees), independent of the shell radius — the same angular size looks the same size from the viewer regardless of radius. Give either both width/height or width + aspect.

  • Panel size in meters is derived as 2 · radius · tan(angularWidth / 2) (in each axis) — see panelDimensions.

  • Default radius: 2 m (DEFAULT_CONFIG.radius).

  • Default movement bounds: azimuth −110°…+110°, elevation −40°…+60° (DEFAULT_CONFIG.bounds) — windows cannot be dragged outside this range.

  • Default resize clamp: width 10°…100° (DEFAULT_CONFIG.minWidth/maxWidth); height follows aspect when one is set.

  • Snap: DEFAULT_CONFIG.snapDegrees is 2.5° by default (used by move(id, pos, { snap: true })); pass snapDegrees: null to disable.

  • Window edge snapping: while a window is being DRAGGED (not on programmatic move()), coming within DEFAULT_CONFIG.edgeSnapDegrees of a neighbouring window's edge pulls it against that edge — never overlapping, and by default DEFAULT_CONFIG.edgeSnapGapDegrees (1.0°) apart rather than exactly flush — instead of leaving whatever the raw pointer position implies. Default 'auto': the threshold is computed PER DRAG from the dragged window's own smaller extent (edgeSnapThresholdDeg(width, height), ~9% of min(width, height), clamped to [3°, 8°]) rather than one flat number for every window size — see that function's doc comment for the geometry (a flat 1.5°, shipped in 0.3.2, turned out to be smaller than a hand-guided controller ray's own jitter for most of this shell's window-size range, and „did not noticeably work on hardware" on a real headset). Pass an explicit number to pin a flat threshold instead (the 0.3.2 behaviour), or null to disable edge snapping entirely. That "no overlap" promise is scoped to the ONE neighbour a given axis snapped to, not to the shell as a whole — a window snapped against B can still overlap some unrelated C, which is normal (see "Overlap and depth" below), not something snapping tries to resolve. A computed touch target that would itself land outside the shell's movement bounds (a neighbour near a shell edge can imply one) is rejected rather than clamped back — a clamped "snap" would quietly reintroduce the very overlap/gap mismatch the feature exists to avoid, so that axis just falls back to the plain clamped pointer position instead. This is separate from snapDegrees above: that one rounds to an absolute angular grid on release regardless of neighbours; this one aligns to a neighbour's edge regardless of the grid, live during the drag, and takes priority over the grid round on release when both would otherwise apply. Horizontal and vertical neighbours are found independently — a horizontal (left/right) candidate requires the two windows' elevation ranges to already overlap, and vertically the same with azimuth — so dragging into a neighbour's corner snaps both axes at once. See snapPosition/edgeSnapThresholdDeg (core/windowSnap.ts) for the exact rule and useDragOnSphere for the wiring.

    Under curved mode, the "touching" edge is the BENT one, not the nominal one — and there's a gap by default. Curved mode's cylindrical bend preserves arc length, so a window subtends MORE azimuth once bent than its nominal width says (see "Curved windows (experimental)" below) — two windows whose nominal edges are exactly flush therefore physically overlap once curved. useDragOnSphere passes the shell's actual rendered curved state (curved && curvedAvailable) into snapPosition's opts, so the touch target (and the perpendicular same-row/column overlap check) use bentHalfExtentDegrees(width) instead of width / 2 whenever curved mode is actually on screen — elevation is never bent, so vertical snapping is unaffected either way. On top of that, DEFAULT_CONFIG.edgeSnapGapDegrees (default 1.0) adds that many degrees between a snapped pair's edges on BOTH axes, flat or curved — a snap has never been perfectly flush by default since 0.3.4, in response to hardware feedback that flush snapping felt too tight; pass 0 for the old exactly-flush behaviour.

    Edge ALIGNMENT (0.3.6) — a second target on the elevation axis, alongside stacking. A neighbour sitting BESIDE the dragged window (their rows overlap in elevation, but their columns do NOT overlap in azimuth — i.e. genuinely side-by-side, not stacked above/below it) can pull the dragged window's top or bottom edge flush with that neighbour's corresponding edge, without changing which azimuth side of it the dragged window sits on. This composes with, rather than replaces, the stack-adjacency snap described above: a neighbour that is ALSO a stacking target (columns overlap too — the corner case) only ever contributes stack-adjacency, never alignment, so the two never fight over the same neighbour. Nearest of top-align/bottom-align wins; on a near-tie — within ALIGNMENT_TIE_BAND_DEG (0.25°) of each other — top wins regardless, per hardware feedback's stated preference for the upper edge (falling back to the bottom edge when top isn't a valid candidate at all, e.g. against a taller neighbour). Reuses the same edgeSnapDegrees threshold as everything else on this axis — no new config field — but, unlike stack-adjacency, alignment never applies edgeSnapGapDegrees: flush is the entire point of aligning, not a distance to preserve. See snapPosition's doc comment (core/windowSnap.ts, "Edge ALIGNMENT" section) for the exact decision table and formulas.

  • Drag/resize dead zone: DEFAULT_CONFIG.dragDeadZoneDegrees (default 0.4°, 0 disables) holds a window's title-bar drag and its resize grip to a dead zone before the FIRST move()/resize() call of a gesture — hand tremor while aiming at either, or while pressing a title-bar button, no longer visibly nudges the window or resizes it. Below the threshold the window shows literally zero movement (not a move-then-snap-back); once the pointer crosses it, the drag continues from an offset anchored at the pointerdown position rather than jumping to the raw pointer hit, so crossing the threshold never teleports the window by the (generally much larger) distance between where it was grabbed and the window's own position. 0.4° is derived from measured hand-tremor amplitude (0.5–1 cm at arm's length, atan(d / r) at the default 2 m radius) with a margin above it — see ShellConfig.dragDeadZoneDegrees's doc comment (store/windowStore.ts) for the arithmetic. 0 reproduces the pre-0.3.5 behaviour bit-for-bit. Title-bar minimize/close buttons were never at risk from this either way — see "Catching press-and-release, not just click" below for the complementary fix that actually applies to them.

  • Window fly-to/from-dock animation: minimizing, restoring, or closing ANY window — dockTile: false included, since 0.3.4 — animates it flying to/from the dock's anchor over DEFAULT_CONFIG.windowFlightSeconds (default WINDOW_FLIGHT_DURATION_S, 0.45s; position and scale, cubic ease-out) instead of an instant show/hide, so you can see where it went or came from. dockTile: false no longer gets a fade-in-place special case — see Window.tsx's "Where a dockTile: false window flies" for why that 0.3.3 behaviour meant the flight went unnoticed on real hardware for a shell where most windows have their own dock button instead of a tile. dockTile still controls whether a persistent tile shows up in the dock strip; it no longer affects where the flight goes. Non-interactive while flying. DEFAULT_CONFIG.windowAnimations (default true) turns this off entirely, not merely instantaneous. See core/windowFlight.ts and Window.tsx.

  • Overlap and depth: overlapping windows are a feature, not a bug. Focusing a window brings it in front of the ones it overlaps. The mechanism is uikit's renderOrder, which the transparent-pass sort compares before any depth: the focused window gets the highest value and each window behind it one lower (windowRenderOrder, applied by panelTransform). The 15 mm-per-rank radial offset (radiusForDepth, DEPTH_STEP) is only z-fighting insurance for near-coplanar panels — it cannot order windows on its own, because uikit panels are drawn transparent with the depth mask off, and the sort then falls back to the panel origin's projected depth (r · cos θ), where the off-axis angle swamps a 15 mm radial difference.

  • Nothing in the shell writes depth. Panels, glyphs and images already default to depthWrite: false; WindowFrame additionally sets depthWrite={false} on its root so uikit's Content (which forces depthWrite: true on the meshes below it, and is what VideoSurface uses) does not either. A depth-writing content plane occludes panels that are drawn later, including those of windows radially in front of it — along a ray far off the front window's own normal its tangent plane can be several centimetres further away than the back window's, so its fragments fail the depth test and the back window shows through. If you write a custom frame, keep depthWrite={false} on its root.

  • The dock always wins its own band. The dock is a fixed strip at −30° elevation and the default movement bounds reach down to −40°, so a window centered near the bottom of its range can extend into the dock band. Where they overlap, the dock stays on top and keeps receiving the clicks — it draws at DOCK_RENDER_ORDER (above every window) and hit-tests at DOCK_POINTER_EVENTS_ORDER (one step above every window), so the two agree by construction rather than by geometric accident. In effect the dock is a reserved strip — you cannot usefully place a window over it, only behind it. That is the spec's intent. To keep windows out of the band entirely, pass a bounds.minElevation above about −18°.

Draw-order and hit-order bands

Every surface here is transparent and writes no depth, so "what covers what" is decided by two explicit, independent orderings — and a surface that is drawn behind something but still swallows its clicks is the worst possible outcome. So both are allocated in bands, with room to spare:

| renderOrder | Who | |---|---| | 0 … 999_999 | window content — one value per window, WINDOW_RENDER_ORDER_MINWINDOW_RENDER_ORDER_MAX | | 1_000_000 | the dock (DOCK_RENDER_ORDER) | | 1_500_000 | <HeadLocked> HUD containers (HEADLOCKED_RENDER_ORDER, via headLockedRenderOrder()) | | 2_000_000 | the XR controller ray and cursor (XR_POINTER_RENDER_ORDER) | | 3_000_000 and above | free for your own in-scene 3D UI (APP_RENDER_ORDER_MIN) |

| pointerEventsOrder | Who | |---|---| | 0 | windows and everything inside them (WINDOW_POINTER_EVENTS_ORDER) | | 1 | the dock (DOCK_POINTER_EVENTS_ORDER) | | 2 and above | free for your own in-scene UI (APP_POINTER_EVENTS_ORDER_MIN) |

<HeadLocked> has no row in that second table: it does not join the pointerEventsOrder band scheme at all, because it opts OUT of hit-testing entirely (pointerEvents={HEADLOCKED_POINTER_EVENTS}'none', an inherited uikit property — on its root). It has to — uikit gives every component defaultPointerEvents: 'auto' regardless of listeners, a <HeadLocked> root's pointerEventsOrder defaults to the same 0 as a window's, and it typically sits much nearer the viewer (distance, default 1.2 m) than the windows or dock (radius, typically 2 m) it is drawn over — winning every hit by @pmndrs/pointer-events's distance-ascending tiebreak and silently swallowing clicks meant for whatever is behind it, if it were left at the uikit default. See "HeadLocked HUD containers" below.

XR pointer setup

You must configure the XR pointer yourself, because only the application creates the XR store. Two @react-three/xr defaults are wrong for a spherical shell, and both are fixed by spreading one exported fragment:

import { createXRStore } from '@react-three/xr'
import { xrPointerOptions } from 'sphere-shell'

// Same radius you pass to <WindowShell radius>.
export const xrStore = createXRStore({ ...xrPointerOptions({ radius: 2 }) })
  • renderOrder. The ray is drawn at 2 and the cursor dot at 1. Left at those defaults the pointer is painted over by every window whose renderOrder is higher — with six windows open (0…5) that is four of them — and, because the ray's material inherits MeshBasicMaterial's depthWrite: true, the ray also depth-occludes those windows and punches a moving hole through their content.
  • rayModel.maxLength. The ray is drawn min(maxLength ?? 1, hitDistance) long, so by default it is 1 m — it visibly stops in mid-air, halfway to a 2 m shell, however far away the thing it is pointing at actually is. xrPointerOptions sets xrRayMaxLength(radius) (4 · radius), which is a ceiling, not a length: the ray is still clipped to whatever it hit, and is hidden entirely when it hits nothing, so over-provisioning has no visual cost. The hand ray keeps @react-three/xr's own 20 cm fingertip stub, which is a deliberate hand-tracking convention rather than the same bug.
  • clickThresholdMs. @pmndrs/pointer-events emits a click only if the press lasted under 300 ms (getIsClicked: if (buttonUpTime - objectButtonPressTime > clickThresholdMs) return false). A VR press is aim, settle, squeeze, release, and routinely takes longer — at which point the button has highlighted, taken the pointerdown, taken the pointerup, and silently not fired. This was reported from a headset as "buttons don't always trigger, or only very slowly, even when they're already highlighted". xrPointerOptions raises it to XR_CLICK_THRESHOLD_MS (1500 ms) on every pointer — controller, hand, transient and gaze alike.

If you supply your own controller/hand implementation instead of @react-three/xr's defaults, put XR_POINTER_RENDER_ORDER, xrRayMaxLength(radius) and XR_CLICK_THRESHOLD_MS on your own pointers.

Make each button ONE hit target

The other half of the same report, and one only your own components can fix.

A click also requires the release to land on the exact same Object3D as the press — there is no movement tolerance of any kind. But a uikit "button" is not one object: every component is its own Mesh with its own raycast, a lucide icon is an Svg that builds one mesh per subpath, and uikit's hit-distance bias means the icon, not the button panel behind it, is usually what the ray hits. So a 22 px button with a 14 px icon has (at least) two hit objects, and a millimetre of hand tremor between press and release loses the click.

Hover, meanwhile, is emitted on the hit object and every ancestor — which is why the button stays lit the whole time and the whole thing reads as "the button is broken" rather than as "I missed".

Put DECORATIVE_POINTER_EVENTS on everything inside a control that is there to be looked at rather than aimed at:

<Container onClick={...} hover={{ backgroundColor: '#3a3a4a' }}>
  <X width={14} height={14} pointerEvents={DECORATIVE_POINTER_EVENTS} />
</Container>

pointerEvents is inherited in uikit, so one value covers the whole subtree under it, including an icon's subpath meshes. The shell's own dock, tiles and window controls already do this. Note that e.stopPropagation() is not an alternative — it stops ancestors from receiving the event, not the target from being the one the click is attributed to.

Hit priority inside one window is not a pointerEventsOrder matter. uikit derives both its draw order and its hit distance from the same orderInfo (makeClippedCast subtracts majorIndex · 0.01 + minorIndex · 0.0001 + elementType · 0.00001 + patchIndex · 0.0000001 metres from every intersection), so raising a control's uikit zIndex lifts it in both at once — locally, without letting it steal clicks from other windows the way a raised pointerEventsOrder would. That is what WINDOW_CONTROL_Z_INDEX (1) is: the value WindowFrame puts on its resize grip so the grip beats its own window's content. Keep it at 1 — the bias is 10 mm per unit and must stay below DEPTH_STEP (15 mm), or a hidden window's control could be grabbed through the window in front of it.

PANEL_OVERLAY_Z_INDEX (also 1) is the same mechanism for a transient overlay over its own panel — the dock's three-dot menu and <HoverLabel>'s tooltip, and available for a popup inside your own dockControls slot. Both live in the dock's uikit tree, so they already have the dock's band; what they need is priority inside it.

Catching press-and-release, not just click

The complementary fix to the section above — that one is about hit fragmentation across several meshes within one button; this one is about press-and-release drift, which loses a click even on an already-single-hit-target button.

@pmndrs/pointer-events (uikit's event system) only synthesizes a click when pointerdown and pointerup land on the exact same Object3D — no movement tolerance of any kind. A Quest controller ray drifting by even a millimetre between press and release, e.g. from ordinary hand tremor, drops the click silently: „Mit den Zeigern der Quest zittere ich immer ein wenig, dann kann aus einem Button Drücken ein verschieben werden."

useCaptureClick() fixes it the same way useDragOnSphere/useResizeOnSphere already fix the analogous problem for a drag: call setPointerCapture on pointerdown, so the SAME element keeps receiving this pointer's events regardless of where the ray drifts, and commit the click on the captured pointerup instead of relying on uikit's own same-object synthesis. Release-anywhere — once captured, ANY pointerup for that pointer commits, deliberately "platform button" semantics rather than "click" semantics.

const click = useCaptureClick({ onClick: () => store.getState().restore(id) })

<Container {...click} hover={{ backgroundColor: '#3a3a46' }}>
  <Text pointerEvents={DECORATIVE_POINTER_EVENTS}>Restore</Text>
</Container>

Spread the result in place of a plain onClick — it returns onPointerDown/onPointerUp/onPointerCancel and an onClick. That last one matters: with capture set, uikit's own same-object click synthesis fires too (capture makes the same-object check trivially true), so without swallowing it that stray click would still bubble to an ancestor's own handler — the dock's background dismiss-on-click-outside, for instance. The returned onClick does nothing but stopPropagation(); it never commits anything itself.

disabled?: boolean (default false) makes both halves inert: no capture on pointerdown, no commit on pointerup. Hover visuals are completely untouched — hover/onPointerEnter/onPointerLeave stay exactly as you'd write them without this hook; only the click-commit mechanism changes.

Every shell-owned control already uses it: dock tile restore, the dock's built-in and app-supplied menu rows, the three-dot shell menu button, Exit VR, and the title bar's minimize/close buttons. It is exported specifically so a host application's own uikit buttons can have the same fix — sphere-shell has no way to patch a click handler it doesn't own.

Not everything that looks like a click is one: the recenter hold gesture (XRControls's recenterButton, with RecenterHoldRing's progress indicator) is driven by gamepad button state over several frames, not a uikit pointer event, and is deliberately NOT built on useCaptureClick — a hold needs to see its own in-progress state every frame, which a one-shot click helper has no concept of. The dock menu's plain "Recenter" row is a useCaptureClick control like any other menu row; the floating hold ring is a different control entirely.

The dock

A panel at −30° elevation, in two stacked strips:

┌───────────────────────────────────────────────┐
│            [tile] [tile] [tile]               │  tiles, centred, and only
│                                               │  when there are any
│  [ your dockControls ......... ] │ [...] [X]  │  the control strip
└───────────────────────────────────────────────┘

It was one row until 0.3.0, with the tiles at the left-hand end of it. That put them in permanent competition with your controls for the strip's width — and your controls lost more of it with every window the user put away. Stacking them means the control strip is yours: the only other things in it are a divider and two square buttons.

Both ways of hiding a window put a tile in the dock, and the two look different:

| | tile | |---|---| | minimized | neutral grey, grey border, the one-shot snapshot taken at minimize time | | closed | red-tinted background, red border, a large red X where the snapshot would be |

<Window dockTile={false}> opts a window out of the tiles strip entirely, for a window your app already gives a control of its own — a button in your dockControls, a link in another window, a keyboard shortcut. A tile beside a dedicated control is a second target for the same job in the one strip that has to stay scannable at arm's length. The flag only hides the tile: the window still minimizes and closes normally, so your control has to be able to restore it from closed (restore(id) clears both flags) and has to be reachable whenever the window is. The library cannot check that for you. dockEntries applies the filter, so a custom dock gets it for free.

Clicking either restores the window (restore clears minimized and closed), so closing a window is recoverable rather than final. The distinction is carried on three independent channels — hue, border colour, thumbnail content — because at about 2 m through a headset lens a tint alone does not survive. A window that is both minimized and closed reads as closed.

The shell's own buttons are two squares at the right-hand end: a three-dot menu and — only during a session — Exit VR.

| button | what it holds | |---|---| | ... | Arrange, Recenter, and the experimental Curved/Flat toggle, as a small panel that opens above the strip | | red X | ends the immersive session; hover it for an "Exit VR" label |

Those three actions used to be three labelled buttons sitting permanently in the strip. They are housekeeping — reached occasionally, while an app's own controls are reached constantly — and at arm's length through a lens the strip's WIDTH is the scarce resource, so they now cost one extra click and give that width back to the app slot. dockMenuItems({ curved, curvedAvailable }) is the exported pure function that decides the rows, so what the menu offers is testable without rendering it; a toggle row names the state it will switch to, and an unavailable Curved row stays present but inert ("Curved n/a") rather than vanishing.

The menu panel and the hover label are absolutely positioned children inside the dock's own uikit tree: they inherit the dock's band and are ordered within it by PANEL_OVERLAY_Z_INDEX, so opening the menu never shifts the strip's layout and never needs a band of its own. The label is pointerEvents="none", or it would steal the pointer from the very button that summoned it.

You can add your own rows to that menu, <WindowShell dockMenuItems={...}> (or <Dock menuItems={...}> if you compose the dock yourself):

<WindowShell
  dockMenuItems={[
    { id: 'background', label: 'Hintergrund: Durchsichtig', onSelect: toggleBackground },
  ]}
>

Your rows render after the shell's own three, with the same panel, hover and dismiss behaviour — the menu still closes on select and on a click on the strip's background, with no code on your side. dockMenuRows({ curved, curvedAvailable }, appItems) is the exported pure function that composes the two lists, a sibling of dockMenuItems rather than a change to it, so nothing about the built-ins-only shape moved. There is deliberately no icon slot for an app row — the built-ins each have a fixed icon looked up by their closed 'arrange' | 'recenter' | 'curved' id, and an app's own id is open-ended, so matching that would mean inventing an icon table rather than reusing one. Any row id collision throws — with a built-in row's id, or with another app row's — rather than silently shadowing, reordering, or duplicating a row. The app-vs-app case matters as much as the built-in one: rows are keyed by id when rendered, so an undetected duplicate would silently duplicate a React key instead of raising anything before a headset. This is a pure function, so the error reproduces on every render and every test, not just once in front of a user.

This exists for the same reason the app slot does: housekeeping that is reached occasionally does not deserve a permanent button competing with your controls for the strip's width. It is exactly what a video player's own "Hintergrund: Schwarz/Durchsichtig" toggle needs — a control the user reaches for rarely enough that a menu row is the right amount of ceremony, and important enough that it should not need a window of its own.

Your own controls go in the app slot, <WindowShell dockControls={...}> (or <Dock>{...}</Dock> if you compose the dock yourself). They render to the left of the shell's buttons, separated by a divider, and inherit the dock's renderOrder and pointerEventsOrder, so plain uikit Containers with onClick work with no render-order work on your side. Children must be @react-three/uikit components.

The slot child defines its own size. The strip is a flexDirection="row" container with alignItems="center" and no fixed dimensions, so a column child simply makes the dock taller (two rows of app controls is a shipped case) and a wider child makes it wider.

Your slot child defines the dock's width. Give its root a width and the dock is that wide, plus ~80 px for the shell's own controls. Then flexGrow={1} on the one element that should span it:

<Container flexDirection="row" alignItems="center" gap={8} width={1100}>
  <Container width={60} height={60} … />        {/* spans both rows */}
  {/* Takes the rest. No alignItems: a flex column stretches its children, which
      is what makes row 1 as wide as this column rather than as wide as row 2. */}
  <Container flexDirection="column" gap={6} flexGrow={1}>
    <Container flexDirection="row" alignItems="center" gap={8}>
      <Text width={46}>{position}</Text>
      <Container flexGrow={1} minWidth={180} height={6} … />   {/* the timeline */}
      <Text width={46}>{duration}</Text>
    </Container>
    <Container flexDirection="row" alignItems="center" gap={4}>{/* everything else */}</Container>
  </Container>
</Container>

Design pixels map to a fixed angular size here — the dock derives its pixelSize from one tile's angular width, so the same number is the same number of degrees at any shell radius. 1100 px is about 65° of azimuth.

Leaving the slot content-sized (no width) is still fine when the slot should follow its own content; it just means an element with flexGrow only absorbs what your other rows leave over. Either way, remember uikit's flex children do not shrink: a width below your own content pushes it past the strip's edge, so bound whatever text you put in there.

When the tiles are the wider strip, the dock widens to fit them and the control strip stays centred beneath. There is no wrapping, scrolling or overflow menu for a dock with very many tiles — an accepted v1 limitation, unchanged by the two-strip layout (which only stops the tiles from eating your width in the common case).

This slot is not a convenience — it is the only correct place for app controls that a VR user must be able to reach. A DOM overlay does not exist inside an immersive session, so an HTML button is simply not there once the user enters VR. At the same time the library deliberately knows nothing about, for example, video playback (see "The app owns video playback, always"), so it cannot ship the buttons itself. The demo's Play/Pause live in this slot for exactly that reason.

Exit VR appears in the dock only while an immersive session is running, for the same reason: without it there is no way out of a session from inside the scene. It calls XRSession.end()createXRStore has enterVR but no matching leave action; its own teardown is driven by watching session become null. The underlying useXRSession() hook is exported if you want your own exit control.

Smooth stick rotation

In VR the right thumbstick drives both locomotion axes: Y dollies toward or away from the view direction, X rotates. Rotation is smooth, not snap, and it rotates the player (the <XROrigin> group, pivoted on the head) — the sphere and everything on it stays world-stable, exactly like the dolly. A window you put over your left shoulder is still there in world space after any amount of stick input; you turned, the room did not.

Artificial rotation is a known motion-sickness risk and was deliberately absent from earlier versions. It exists because it was asked for after hardware testing, and it ships with every comfort measure that does not defeat the feature:

| Parameter | Value | Why | |---|---|---| | Rate cap | XR_ROTATION_DEGREES_PER_SECOND = 60°/s | 180° in 3 s; 0.5–0.83° per frame at 72–120 Hz, well below where optical flow starts to strobe | | Response | proportional, no acceleration | the rate depends only on the current stick position, so holding the stick never speeds up, and the user can turn as slowly as they like | | Deadzone | XR_ROTATION_DEADZONE = 0.2, rescaled | a resting thumb cannot make the world drift; the rate ramps from 0 at the deadzone edge instead of stepping to 20% | | Hitch guard | XR_ROTATION_MAX_FRAME_DELTA = 0.1 s | a GC pause or a shader compile can hand useFrame a 500 ms delta; unclamped that is a 30° jump in one frame, i.e. a teleport |

<WindowShell rotationSpeed={30}> halves it, rotationSpeed={120} doubles it, and rotationSpeed={null} switches artificial rotation off entirely, leaving physical turning and recentering — which is what you want if your users are sensitive, or if your app has its own locomotion. The dolly, recenter and auto-recenter are unaffected either way.

The two pure functions behind it are exported (stickYawDegrees, yawAroundPivot) so a custom navigation component can reuse the same behaviour rather than reimplement it.

The recenter button, and sharing the controllers with your app

In VR the shell recenters when the right controller's A button is held for about a second (XR_RECENTER_HOLD_SECONDS). A and B are the only face buttons that controller has, so an application that wants one of them for itself has to be able to take it — otherwise one press would fire both its action and the library's recenter. recenterButton is that switch:

<WindowShell recenterButton="b-button">   {/* A is now yours */}
<WindowShell recenterButton={null}>        {/* no button binding at all */}

null leaves the dock's Recenter control and the once-per-session auto-recenter, which are unaffected by this prop either way.

The hold travels with the binding. Whichever button you name, it still has to be held for a second. The hold is not a property of the A button — it is what stops a brushed thumb from throwing the whole shell to a new position and yaw, the one action in this library with no undo and the most disorienting thing it can do to someone wearing a headset. If you want a bare press, pass null and call useWindowManager().recenter() from your own control.

The hold shows its own progress. While the bound button is held, a small ring — fixed directly in front of the camera, not <HeadLocked>'s lazy follow (a hold this short has to read as "right where you're looking" from its first visible frame) — sweeps closed over the second, so it is visible how much longer to keep pressing. It disappears on release, and again the instant the hold fires (rather than staying "full" for the rest of an over-long hold). This comes with XRControls automatically and is not independently configurable — the progress fraction it reads (longPressProgress) is an internal reformatting of the same hold state stepLongPress already tracks, not a public API.

Everything else on the controllers is yours. The library reads the right thumbstick and the one bound face button, and nothing on the left controller at all. To read the rest, mount your own component inside your <XR> tree and do what XRControls does — UNSAFE_useXRStore()?.getState().inputSourceStates inside a useFrame, filtered by inputSource.handedness. Use the store's getState() rather than useXR/useXRInputSourceState: those throw without an <XR> ancestor, which matters if your component can also render outside one.

HeadLocked HUD containers

<HeadLocked> is a lazy-follow HUD container: uikit content that gently chases the viewer's gaze instead of being either rigidly gaze-locked or fixed in world space like a Window. It is a stable feature (not experimental), mounted as a sibling of <WindowShell>:

<XR store={xrStore}>
  <WindowShell radius={2}>{/* ...windows... */}</WindowShell>
  <HeadLocked>
    <Container padding={12} backgroundColor="#1c1c22" borderRadius={8}>
      <Text fontSize={14} color="#e8e8ee">{captionText}</Text>
    </Container>
  </HeadLocked>
</XR>
  • Lazy follow, not hard-attached. Position is smoothed exponentially toward the viewer's current head yaw/pitch with a time constant (config.tauS, default 0.3 s), not pinned to it frame-for-frame — a head-locked HUD that snaps instantly reads as nervous/jittery, and one that never lags reads as part of the viewer's own body in an uncomfortable way. 0.3 s tracks a deliberate look within a fraction of a second while damping out small head jitter and quick glances away and back.
  • Fixed distance, limited pitch travel. Content sits distance meters (default 1.2) from the head along the smoothed direction, offset config.offsetPitchDeg (default −15°) below where the viewer is actually looking, and pitch tracking is clamped to config.pitchMinDeg/ pitchMaxDeg (default ±40°) so the HUD stays upright and legible instead of chasing the viewer's gaze down to the floor or up past vertical.
  • Identical behaviour in the magic window. The same yaw/pitch is read from whichever camera R3F currently has active — no session branching — so in the flat/mouse-driven view, where the camera's orientation moves far less than a headset's, the HUD settles near the bottom of the screen, which is the intended "effectively screen-fixed" result, not a special case.
  • The follow math is a pure, tested core/ function. stepHeadLocked — exponential smoothing (1 - exp(-dt/tau), exact and frame-rate independent for a constant target — see its doc comment), yaw wrapped at ±180° so a target on the other side of that seam is approached the short way, pitch target clamped before smoothing so the output can never leave the configured range. config.offsetPitchDeg is applied by the component, once, after smoothing — never fed back into the smoothed state — so stepHeadLocked's output is always a pure function of "where is the viewer looking", independent of the offset.
  • Render order: its own band, HEADLOCKED_RENDER_ORDER (via headLockedRenderOrder()) — above every window and the dock, below the XR controller ray and cursor — see "Draw-order and hit-order bands" above.
  • Hit-transparent by construction. The root Container sets pointerEvents="none" (inherited, so the whole subtree gets it) and has no opt-out prop — a HUD nearer the viewer than the windows/dock it overlaps would otherwise win every hit contest against them and silently swallow clicks, which is exactly the failure mode this needs "read-only UI only" (below) to actually hold in practice, not just in intent. See "Draw-order and hit-or