ViewportGizmo
A canvas-based 3D orientation gizmo for viewport navigation in editor applications. Displays the current camera orientation with colored axis arms and supports orbit dragging, snap-to-view clicking, and axis clicking. Fully controlled — the parent manages orientation state while the gizmo provides interaction callbacks. Supports configurable axis colors, up-axis conventions, and multiple interaction modes.
Live Preview
Import
Section titled “Import”import { ViewportGizmo } from 'entangle-ui';const [orientation, setOrientation] = useState({ yaw: 45, pitch: -30 });
<ViewportGizmo orientation={orientation} onOrbit={delta => { setOrientation(prev => ({ yaw: prev.yaw + delta.deltaYaw, pitch: prev.pitch + delta.deltaPitch, })); }} onSnapToView={view => { // Snap camera to preset view (front, back, left, right, top, bottom) snapCameraTo(view); }}/>;Orientation
Section titled “Orientation”The gizmo always reflects the parent-provided orientation. It uses Euler angles in degrees with YXZ intrinsic rotation order (matching Three.js defaults).
Orientation
interface GizmoOrientation { yaw: number; // Y-axis rotation (heading) in degrees pitch: number; // X-axis rotation in degrees roll?: number; // Z-axis rotation in degrees (default: 0)}Up Axis Convention
Section titled “Up Axis Convention”Configure which axis points up to match your 3D engine’s coordinate system.
Up Axis Convention
{ /* Y-up (Three.js, Babylon, Maya) */}<ViewportGizmo orientation={orientation} upAxis="y-up" />;
{ /* Z-up (Blender, Unreal, CAD) */}<ViewportGizmo orientation={orientation} upAxis="z-up" />;Axis Colors
Section titled “Axis Colors”Choose from built-in color presets or provide custom axis colors.
Axis Colors
{ /* Blender preset (default): X=red, Y=green, Z=blue */}<ViewportGizmo orientation={orientation} axisColorPreset="blender" />;
{ /* Unreal preset: X=red, Y=green, Z=blue */}<ViewportGizmo orientation={orientation} axisColorPreset="unreal" />;
{ /* Custom colors */}<ViewportGizmo orientation={orientation} axisColorPreset="custom" axisConfig={[ { label: 'X', color: '#FF4444', visible: true }, { label: 'Y', color: '#44FF44', visible: true }, { label: 'Z', color: '#4444FF', visible: true }, ]}/>;Interaction Modes
Section titled “Interaction Modes”Control which interactions are available to the user. In the preview, drag any
gizmo to orbit and click an axis tip to snap — the “Snapped view” readout below
each one makes the difference clear: full and snap-only respond to axis
clicks, while orbit-only and display-only do not.
Interaction Modes
{ /* Full interaction: orbit + snap + click (default) */}<ViewportGizmo orientation={orientation} interactionMode="full" />;
{ /* Snap-to-view only: click axes to snap, no orbit dragging */}<ViewportGizmo orientation={orientation} interactionMode="snap-only" />;
{ /* Orbit only: drag to orbit, no snap clicks */}<ViewportGizmo orientation={orientation} interactionMode="orbit-only" />;
{ /* Display only: no interaction, just a visual indicator */}<ViewportGizmo orientation={orientation} interactionMode="display-only" />;| Mode | Orbit | Snap | Description |
| -------------- | ----- | ---- | ---------------------------------- |
| full | Yes | Yes | Complete interaction (default) |
| snap-only | No | Yes | Click axes to snap to preset views |
| orbit-only | Yes | No | Drag to orbit the camera |
| display-only | No | No | Read-only orientation display |
Visual Options
Section titled “Visual Options”Toggle various visual elements of the gizmo.
Visual Options
<ViewportGizmo orientation={orientation} showLabels={true} // Axis labels (X, Y, Z) at arm tips showNegativeAxes={true} // Dimmed shorter arms for negative axes showOrbitRing={true} // Thin ring around the gizmo showOriginHandle={true} // Clickable center handle background="subtle" // Background style/>| Background | Description |
| ------------- | -------------------------- |
| transparent | No background |
| subtle | Faint background (default) |
| solid | Opaque background |
Orbit Configuration
Section titled “Orbit Configuration”Fine-tune orbit behavior with speed and pitch constraint options.
Orbit Configuration
<ViewportGizmo orientation={orientation} orbitSpeed={1.5} // 1.5x orbit speed constrainPitch={true} // Clamp pitch to [-90, 90] degrees invertYaw={false} // Flip the horizontal orbit direction invertPitch={false} // Flip the vertical orbit direction onOrbit={delta => { /* ... */ }} onOrbitEnd={finalOrientation => { /* ... */ }}/>Orbit sign convention
Section titled “Orbit sign convention”onOrbit reports a delta, not an absolute orientation. By default, dragging
the pointer right (or pressing ArrowRight) emits a positive deltaYaw, and
dragging up (or ArrowUp) emits a positive deltaPitch. Add the delta to
your camera’s current angles to track the gesture:
onOrbit={({ deltaYaw, deltaPitch }) => { setOrientation(prev => ({ yaw: prev.yaw + deltaYaw, pitch: prev.pitch + deltaPitch, }));}}Which way the camera turns is your choice. If drag-to-orbit feels mirrored
against your camera controller, flip it with invertYaw / invertPitch rather
than negating the delta yourself — the inversion is applied before
constrainPitch, so pitch clamping stays correct at the poles.
Event Callbacks
Section titled “Event Callbacks”Event Callbacks
<ViewportGizmo orientation={orientation} onOrbit={delta => { // Called continuously during drag // delta: { deltaYaw: number, deltaPitch: number } }} onOrbitEnd={finalOrientation => { // Called when drag ends // finalOrientation: { yaw, pitch, roll } }} onSnapToView={view => { // Called when user clicks an axis arm // view: 'front' | 'back' | 'left' | 'right' | 'top' | 'bottom' }} onAxisClick={(axis, positive) => { // Called when a specific axis arm is clicked // axis: 'x' | 'y' | 'z', positive: boolean }} onOriginClick={() => { // Called when the center origin handle is clicked }}/>Integrating with Three.js / OrbitControls
Section titled “Integrating with Three.js / OrbitControls”ViewportGizmo renders as a DOM/canvas overlay outside the R3F <Canvas>,
so it never touches the WebGL/WebGPU renderer — it works identically on both
backends. The camera stays the single source of truth: the gizmo only reflects
orientation and reports interactions, while a small sync component pushes the
live camera pose back up to it. onOrbit and onSnapToView write to
OrbitControls, whose update() re-fires 'change', closing the loop with no
drift.
The library exports the math you need so there is nothing to hand-derive:
quaternionToEuler turns camera.quaternion into the gizmo’s Euler convention,
and presetViewToOrientation maps a snap target to angles.
import { useEffect, useRef, useState } from 'react';import { Canvas, useThree } from '@react-three/fiber';import { OrbitControls } from '@react-three/drei';import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib';import { ViewportGizmo, quaternionToEuler, presetViewToOrientation, type GizmoOrientation, type GizmoPresetView, type OrbitDelta,} from 'entangle-ui';
const DEG2RAD = Math.PI / 180;// Keep the polar angle just inside [0, π], where the azimuth degenerates.const POLE_EPS = 0.01;const clampPolar = (p: number) => Math.min(Math.PI - POLE_EPS, Math.max(POLE_EPS, p));
// Display path (camera → gizmo). Lives INSIDE <Canvas> because it needs the// R3F camera; renders nothing.function CameraOrientationSync({ controlsRef, onChange,}: { controlsRef: React.RefObject<OrbitControlsImpl | null>; onChange: (orientation: GizmoOrientation) => void;}) { const camera = useThree(state => state.camera);
useEffect(() => { const controls = controlsRef.current; if (!controls) return; const sync = () => { const q = camera.quaternion; onChange(quaternionToEuler(q.x, q.y, q.z, q.w)); }; sync(); controls.addEventListener('change', sync); return () => controls.removeEventListener('change', sync); }, [camera, controlsRef, onChange]);
return null;}
export function ViewportWithGizmo() { const controlsRef = useRef<OrbitControlsImpl>(null); const [orientation, setOrientation] = useState<GizmoOrientation>({ yaw: 0, pitch: 0, });
// Interaction path (gizmo → camera). Nudge the orbit angles, then update() // re-fires 'change' and the display path pulls the new pose straight back. const handleOrbit = ({ deltaYaw, deltaPitch }: OrbitDelta) => { const controls = controlsRef.current; if (!controls) return; controls.setAzimuthalAngle( controls.getAzimuthalAngle() + deltaYaw * DEG2RAD ); controls.setPolarAngle( clampPolar(controls.getPolarAngle() + deltaPitch * DEG2RAD) ); controls.update(); };
const handleSnapToView = (view: GizmoPresetView) => { const controls = controlsRef.current; if (!controls) return; const { yaw, pitch } = presetViewToOrientation(view, 'y-up'); controls.setAzimuthalAngle(yaw * DEG2RAD); controls.setPolarAngle(clampPolar((pitch + 90) * DEG2RAD)); controls.update(); };
return ( <div style={{ position: 'relative', width: '100%', height: '100%' }}> <Canvas> {/* …your scene… */} <OrbitControls ref={controlsRef} /> <CameraOrientationSync controlsRef={controlsRef} onChange={setOrientation} /> </Canvas>
<div style={{ position: 'absolute', right: 12, bottom: 12 }}> <ViewportGizmo orientation={orientation} onOrbit={handleOrbit} onSnapToView={handleSnapToView} // Flip these if drag-to-orbit turns the camera the wrong way. invertYaw={false} invertPitch={false} /> </div> </div> );}Size and Diameter
Section titled “Size and Diameter”The diameter prop controls the pixel size of the canvas. The size prop affects label font size.
Size and Diameter
<ViewportGizmo orientation={orientation} diameter={80} size="sm" /><ViewportGizmo orientation={orientation} diameter={120} size="md" /><ViewportGizmo orientation={orientation} diameter={160} size="lg" />| Prop | Type | Default | Description |
|---|---|---|---|
orientation * | { yaw: number; pitch: number; roll?: number } | — | Current viewport orientation in degrees. The gizmo always reflects this value (fully controlled). |
upAxis | 'y-up' | 'z-up' | 'y-up' | Which axis points up. Y-up for Three.js/Babylon/Maya, Z-up for Blender/Unreal/CAD. |
axisColorPreset | 'blender' | 'unreal' | 'custom' | 'blender' | Axis color convention. Use "custom" with axisConfig for custom colors. |
axisConfig | [GizmoAxisConfig, GizmoAxisConfig, GizmoAxisConfig] | — | Per-axis configuration [X, Y, Z] with custom label, color, and visibility. Used when axisColorPreset is "custom". |
showLabels | boolean | true | Whether to show axis labels (X, Y, Z) at arm tips. |
showNegativeAxes | boolean | true | Whether to show negative axis arms (dimmed, shorter). |
showOrbitRing | boolean | true | Whether to show a thin orbit ring around the gizmo. |
showOriginHandle | boolean | true | Whether to show a clickable origin handle at the center. |
background | 'transparent' | 'subtle' | 'solid' | 'subtle' | Background style of the gizmo container. |
interactionMode | 'full' | 'snap-only' | 'orbit-only' | 'display-only' | 'full' | Which interactions are enabled. |
orbitSpeed | number | 1 | Orbit speed multiplier for drag interactions. |
constrainPitch | boolean | true | Whether to constrain pitch rotation to [-90, 90] degrees. |
invertYaw | boolean | false | Flip the horizontal orbit direction. Emits the opposite deltaYaw sign so the camera turns the other way for the same gesture. |
invertPitch | boolean | false | Flip the vertical orbit direction. Emits the opposite deltaPitch sign (dragging up turns the camera down). |
onOrbit | (delta: { deltaYaw: number; deltaPitch: number }) => void | — | Called continuously while the user drags to orbit. |
onOrbitEnd | (finalOrientation: GizmoOrientation) => void | — | Called when the user finishes an orbit drag. |
onSnapToView | (view: 'front' | 'back' | 'left' | 'right' | 'top' | 'bottom') => void | — | Called when the user clicks an axis to snap to a preset view. |
onAxisClick | (axis: 'x' | 'y' | 'z', positive: boolean) => void | — | Called when the user clicks a specific axis arm. |
onOriginClick | () => void | — | Called when the user clicks the origin handle. |
diameter | number | 120 | Gizmo diameter in pixels. |
size | 'sm' | 'md' | 'lg' | 'md' | Component size, affecting label font size. |
disabled | boolean | false | Whether the gizmo is disabled. |
className | string | — | Additional CSS class names on the wrapper element. |
style | CSSProperties | — | Inline styles on the wrapper element. |
testId | string | — | Test identifier for automated testing. |
id | string | — | HTML id attribute on the wrapper element. |
Accessibility
Section titled “Accessibility”- The canvas element has
role="application"witharia-label="Viewport orientation gizmo" aria-roledescriptionprovides a live description of the current yaw and pitch values- A hidden
aria-live="polite"region announces orientation changes to screen readers - The canvas is keyboard-focusable (
tabIndex={0}) when not disabled, supporting keyboard-driven orbit via theonKeyDownhandler - When
disabledistrue, the canvas is removed from the tab order (tabIndex={-1})