@vctrl/viewer
v1.0.0
Published
vctrl/viewer is a React component library for rendering and interacting with 3D models. It's part of the vectreal ecosystem and is designed to work seamlessly with the vctrl/hooks package for model loading and management.
Readme
@vctrl/viewer
A ready-to-use React component for rendering and interacting with 3D models. Built on top of Three.js and React Three Fiber.
This package is still in active development. Breaking changes may occur before the first major release.
Installation
npm install @vctrl/viewer
# or
pnpm add @vctrl/viewerQuick start
import { useLoadModel } from '@vctrl/hooks/use-load-model'
import { VectrealViewer } from '@vctrl/viewer'
import '@vctrl/viewer/css'
function App() {
const { file } = useLoadModel()
return <VectrealViewer model={file?.model} />
}You must import the CSS bundle (
@vctrl/viewer/css) for the viewer to render correctly.
VectrealViewer props
| Prop | Type | Required | Description |
| ------------------------------ | ------------------------------------------------------- | -------- | -------------------------------------------------------------------------------- |
| model | Object3D | No* | The Three.js scene to display. Optional only if you supply scene content via children; with neither, nothing renders. |
| children | React.ReactNode | No | Scene content rendered inside the canvas, alongside or instead of model |
| className | string | No | Additional CSS classes for the viewer container |
| theme | 'light' \| 'dark' \| 'system' | No | Viewer theme, default is system |
| enableViewportRendering | boolean | No | Render only while in viewport, default true |
| enablePostProcessing | boolean | No | Toggle postprocessing effects, default true |
| boundsOptions | BoundsProps | No | Scene bounds and framing behavior |
| cameraOptions | CameraProps | No | Perspective camera configuration |
| controlsOptions | ControlsProps | No | OrbitControls configuration |
| envOptions | EnvironmentProps | No | Drei Environment configuration |
| shadowsOptions | ShadowsProps | No | Shadow behavior configuration |
| normalizationOptions | NormalizationOptions | No | Clamps the model's bounding-box diagonal at runtime, without touching the model data |
| hotspots | HotspotDefinition[] | No | Point-of-interest markers anchored in world space. Each can carry a body and a link, and fly a linked camera |
| hotspotColor | string | No | Overrides the marker fill. The default is neutral so it does not compete with the product. The step numeral stays dark ink, so a dark or saturated fill needs --vctrl-hotspot-ink overridden on the container |
| showHotspotMarkers | boolean | No | Draws the markers, default true. false leaves them resolved but undrawn, so a host can still reach them by id |
| revealHotspotContent | boolean | No | Opens a card on click, default true. false still flies the camera and still reports the activation |
| selectedHotspotId | string \| null | No | Draws one marker as the current one. Editing surfaces only |
| showInternalHotspots | boolean | No | Draws hotspots the author kept backstage. Editing surfaces only |
| showHiddenHotspots | boolean | No | Draws hotspots the author hid. Editing surfaces only |
| onHotspotSelect | (id: string) => void | No | Picks a marker instead of activating it. Passing it makes selection win over both content and camera |
| onHotspotPositionSetterReady | (setter: HotspotPositionSetter \| null) => void | No | Receives a setter that moves a marker without moving the hotspot, for a drag gizmo. Editing surfaces only |
| shadowLightEditable | boolean | No | Renders an in-scene draggable handle for aiming the shadow light. Editing surfaces only |
| staticShadowBake | boolean | No | Bakes the accumulative shadow in one pass on mount instead of fading it in, default false |
| bakedShadow | BakedShadow | No | A persisted shadow bake to render instead of recomputing one |
| popover | React.ReactNode | No | Optional info popover slot. Its content is unmounted until the viewer reaches ready, after the loader's cross-fade |
| loader | React.ReactNode | No | Custom loading UI, default is a built-in spinner. loader={null} renders no loader and skips the loaded cross-fade, taking the viewer from loading straight to ready |
| loadingThumbnail | ViewerLoadingThumbnail | No | Optional blurred loading thumbnail |
| onScreenshot | (dataUrl: string) => void | No | Called when a screenshot is captured |
| onScreenshotCaptureReady | (capture: SceneScreenshotCapture \| null) => void | No | Receives a capture function for on-demand screenshots |
| onCameraSnapshotCaptureReady | (capture: SceneCameraSnapshotCapture \| null) => void | No | Receives a capture function for the current camera pose |
| onInteractionEvent | (event: ViewerInteractionEvent) => void | No | Receives viewer lifecycle and runtime interaction events |
| onCommandExecutorReady | (executor: ViewerCommandExecutor \| null) => void | No | Receives a minimal imperative runtime command executor |
| onShadowBakeReady | (capture: ShadowBakeCapture \| null) => void | No | Receives a function that captures the settled shadow bake as a density PNG |
| onShadowLightChange | (position: [number, number, number]) => void | No | Called with a new shadow light position, in model-size units, while the handle is dragged |
| onRawDiagonalComputed | (diagonal: number) => void | No | Called with the pre-normalization bounding-box diagonal whenever the model changes |
Notes on content source
model is optional because you can also render scene content via children. With neither, nothing renders.
The editor affordances exist for editing surfaces such as the Publisher: shadowLightEditable, staticShadowBake, bakedShadow, onShadowBakeReady, onShadowLightChange, and the hotspot four above - selectedHotspotId, showInternalHotspots, showHiddenHotspots, onHotspotSelect and onHotspotPositionSetterReady. Public and embedded viewers omit them.
Camera options (CameraProps)
cameraOptions accepts:
type CameraProps = {
activeCameraId?: string
cameras?: Array<
PerspectiveCameraProps & {
cameraId: string
name: string
kind?: 'scene' | 'hotspot'
initial?: boolean
target?: [number, number, number]
}
>
sceneTransition?: {
type: 'linear' | 'object_avoidance' | 'none'
duration?: number
easing?: 'linear' | 'ease_in' | 'ease_out' | 'ease_in_out'
}
}Each camera entry extends PerspectiveCameraProps from Drei and adds viewer-specific camera switching metadata. Transitions between cameras are configured once at the scene level via sceneTransition, not per camera.
<VectrealViewer
cameraOptions={{
activeCameraId: 'default',
sceneTransition: {
type: 'linear',
duration: 900,
easing: 'ease_in_out'
},
cameras: [
{
cameraId: 'default',
name: 'Default',
initial: true,
position: [0, 5, 8],
fov: 55,
near: 0.1,
far: 1000
}
]
}}
/>Controls options (ControlsProps)
Based on @react-three/drei OrbitControls.
controlsOptions extends OrbitControls props and adds:
| Option | Type | Description |
| ----------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| controlsTimeout | number | One-shot delay in milliseconds, measured from mount, before OrbitControls is enabled at all. 0 (the default) means enabled immediately |
<VectrealViewer
controlsOptions={{
maxPolarAngle: Math.PI / 2,
autoRotate: true,
controlsTimeout: 2000
}}
/>Camera snapshot callback
onCameraSnapshotCaptureReady(capture) gives you a function that captures the current viewer camera pose as { position, rotation, target, fov }.
Runtime commands and events
VectrealViewer exposes a small runtime interaction surface for surrounding app code.
Commands
onCommandExecutorReady(executor) gives you a ViewerCommandExecutor with execute(command).
Current commands:
| Command | Payload | Effect |
| ---------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| activate_camera | { cameraId: string } | Transitions to one of the configured scene cameras |
| set_controls_enabled | { enabled: boolean } | Temporarily enables or disables orbit interaction |
| set_transition | { transitionType: 'none' \| 'linear' \| 'object_avoidance'; duration?: number; easing?: string } | Overrides the active camera transition |
| set_auto_rotate | { enabled: boolean; speed?: number } | Toggles and configures auto-rotation |
| set_controls_options | { zoom?: boolean; pan?: boolean } | Enables or disables zoom/pan interaction at runtime |
Events
onInteractionEvent(event) receives a ViewerInteractionEvent. The runtime emits three
of them:
| Event | Payload | Meaning |
| --------------------------- | ------------------------------ | ------------------------------------------- |
| viewer_ready | none | Viewer runtime is ready to accept commands |
| initial_framing_completed | { cameraId: string \| null } | Initial framing and stabilization completed |
| camera_changed | { cameraId: string } | Active camera changed |
ViewerInteractionEvent also declares model_loaded and auto_rotate_changed. Both
belong to the union a handler has to narrow, and neither is emitted by the current
runtime.
import { useRef } from 'react'
import { type ViewerCommandExecutor, VectrealViewer } from '@vctrl/viewer'
function ViewerRuntimeExample({ model }: { model: object }) {
const executorRef = useRef<null | ViewerCommandExecutor>(null)
return (
<>
<button
onClick={() =>
executorRef.current?.execute({
type: 'activate_camera',
cameraId: 'overview'
})
}
>
Go to overview
</button>
<button
onClick={() =>
executorRef.current?.execute({
type: 'set_controls_enabled',
enabled: false
})
}
>
Lock controls
</button>
<VectrealViewer
model={model as never}
onCommandExecutorReady={(executor) => {
executorRef.current = executor
}}
onInteractionEvent={(event) => {
console.log('viewer event', event)
}}
/>
</>
)
}Environment options (EnvironmentProps)
Configures the @react-three/drei Environment component. The viewer does not use Drei's Stage; framing is handled by SceneBounds and SceneCamera.
envOptions supports a typed preset system from @vctrl/core:
| Option | Type | Description |
| ----------------------- | -------------------- | ------------------------------------------------------------- |
| preset | EnvironmentKey | Preset key such as studio-key, outdoor-noon, night-city |
| environmentResolution | '1k' \| '4k' | Resolution variant for environment assets |
| background | boolean | Render environment as scene background |
| backgroundBlurriness | number | Blur strength when background is enabled |
| backgroundIntensity | number | Background intensity multiplier |
| environmentIntensity | number | Lighting intensity multiplier |
| files | string \| string[] | Custom environment files |
<VectrealViewer
envOptions={{
preset: 'studio-key',
environmentResolution: '1k',
background: true,
backgroundBlurriness: 0.2,
environmentIntensity: 1,
backgroundIntensity: 1
}}
/>Bounds and shadows
| Prop | Type | Summary |
| ---------------- | -------------- | ---------------------------------------------------- |
| boundsOptions | BoundsProps | Pass-through to Drei Bounds behavior |
| shadowsOptions | ShadowsProps | Baked accumulative shadow, with an optional contact pass |
boundsOptions (BoundsProps)
BoundsProps is forwarded to Drei Bounds. The viewer defaults are:
| Option | Default |
| ------------- | ------- |
| clip | false |
| margin | 1.5 |
| maxDuration | 0 |
clip is false because near/far planes are managed per frame in SceneModel, so
Drei's Bounds is deliberately kept from writing them.
fit is accepted for API compatibility but ignored: SceneBounds always passes
fit={false} to Drei's Bounds because SceneCamera drives fitting imperatively
via bounds.reset().fit().
<VectrealViewer
boundsOptions={{
clip: false,
margin: 1.25,
maxDuration: 300
}}
/>shadowsOptions (ShadowsProps)
Shadows are off by default. enabled defaults to false, so every example below
needs enabled: true to render anything.
Whatever you pass is merged over the defaults below and rendered as Drei
AccumulativeShadows. The soft ground pool is not a separate mode, it is an opt-in extra
pass configured under the nested contact key.
Several numeric options are expressed relative to the model's measured size, not in
world units: scale is a multiple of the model footprint, and light.radius and
light.position are in model-size units. This keeps the bake proportioned for any model.
Viewer defaults:
| Option | Default |
| ------------- | ---------------- |
| enabled | false |
| temporal | true |
| frames | 48 |
| alphaTest | 3.0 |
| cutoffScale | 1 |
| opacity | 0.9 |
| scale | 2.5 |
| resolution | 1024 |
| colorBlend | 2 |
| color | '#000000' |
| ao | false |
| aoIntensity | 1.4 |
alphaTest is not a discard threshold. In Drei's SoftShadowMaterial the shadow alpha
is max(0, 1 - planeBrightness / alphaTest) * opacity, so it sits between the shadowed
and lit brightness of the bake plane. Shadow depth is driven by light.ambient, not by
alphaTest. ao enables screen-space crevice occlusion (N8AO), which runs every frame
and is opt-in for that reason.
Nested light defaults (shadowsOptions.light):
| Option | Default |
| ----------- | ------------- |
| intensity | Math.PI * 2 |
| amount | 8 |
| radius | 0.8 |
| ambient | 0.3 |
| position | [0, 2.5, 0] |
| bias | 0.001 |
Nested contact defaults (shadowsOptions.contact), an optional soft ground pass baked
once under the directional bake:
| Option | Default |
| --------- | ------- |
| enabled | false |
| opacity | 0.6 |
| blur | 3 |
| scale | 1.5 |
| reach | 0.35 |
<VectrealViewer
shadowsOptions={{
enabled: true,
temporal: true,
frames: 48,
opacity: 0.9,
scale: 2.5,
resolution: 1024,
light: {
amount: 8,
radius: 0.8,
ambient: 0.3,
position: [1, 2.5, 1]
},
contact: {
enabled: true,
opacity: 0.6,
blur: 3
}
}}
/>Screenshot callbacks
VectrealViewer supports two screenshot-related callbacks:
onScreenshot(dataUrl)receives a data URL each time a screenshot is captured.onScreenshotCaptureReady(capture)gives you a capture function that can be stored and called from external UI.
The callback types are exported from @vctrl/viewer as SceneScreenshotCapture and SceneScreenshotOptions.
SceneScreenshotOptions:
| Option | Type | Default | Description |
| ---------------- | ------------------------------ | --------------- | ------------------------------------------------------------------------ |
| width | number | 1280 | Output width in pixels |
| height | number | 720 | Output height in pixels |
| mimeType | 'image/jpeg' \| 'image/webp' | 'image/webp' | Output format |
| quality | number | 0.86 | Image quality hint for lossy output |
| mode | 'auto-fit' \| 'viewport' | 'auto-fit' | Capture strategy |
| targetCameraId | string | none | Transition to this camera, capture, then return to the original camera |
Scene components and defaults
@vctrl/viewer also exports the scene components VectrealViewer composes, each with the
default options object it merges over. Import them to build a custom canvas, or to start
from a viewer default and override a field:
| Export | Default options |
| --------------------- | ------------------------ |
| SceneBounds | defaultBoundsOptions |
| SceneCamera | defaultCameraOptions |
| SceneControls | defaultControlsOptions |
| SceneEnvironment | defaultEnvOptions |
| SceneShadows | defaultShadowsOptions |
| SceneHotspots | none |
| SceneModel | none |
| ScenePostProcessing | none |
import { defaultControlsOptions, VectrealViewer } from '@vctrl/viewer'
;<VectrealViewer
controlsOptions={{ ...defaultControlsOptions, autoRotate: true }}
/>The info popover slot is built from InfoPopover, InfoPopoverTrigger,
InfoPopoverContent, InfoPopoverText and InfoPopoverCloseButton, all
exported from the same entry point. The content is yours: these primitives
carry no branding of their own.
Whatever you pass to popover is unmounted, not merely hidden, until the
viewer reaches ready - after the loader's cross-fade, or immediately when you
pass loader={null} and there is no cross-fade to wait for. Slot content
therefore cannot hold state it needs to survive the load, and a scene that
never finishes framing never mounts the slot at all.
The slot renders as a bare sibling of the canvas and supplies no positioning of
its own. Note that the viewer's own container is not positioned either, so
InfoPopover's absolute bottom-0 resolves against whatever positioned
ancestor you give it: put the viewer in a relative wrapper if you use the
slot, or position your own content some other way.
Integration with @vctrl/hooks
The viewer is designed to be used alongside @vctrl/hooks, but it does not read from any hooks context. VectrealViewer renders whatever you give it through model or children and nothing otherwise, so the model always has to be passed explicitly.
ModelProvider and useModelContext are still the convenient way to share one loader across a component tree. Read the model out of the context and hand it to the viewer:
import { ModelProvider, useModelContext } from '@vctrl/hooks/use-load-model'
import { VectrealViewer } from '@vctrl/viewer'
import '@vctrl/viewer/css'
function Scene() {
const { file } = useModelContext(false)
if (!file?.model) return null
return <VectrealViewer model={file.model} />
}
export default function App() {
return (
<ModelProvider>
<Scene />
</ModelProvider>
)
}Development
pnpm nx build vctrl/viewer
pnpm nx lint vctrl/viewer
pnpm nx typecheck vctrl/viewerThe viewer has no unit-test target. Its behavior is covered by the Playwright suite
in packages/viewer-e2e.
The viewer's stories live in the workspace-wide Storybook, alongside the shared design system:
pnpm nx storybook storybookRelated docs
Source
The full source and README live in packages/viewer.
License
AGPL-3.0-only. See LICENSE.md.
