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

@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

NPM Downloads Storybook

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/viewer

Quick 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/viewer

The 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 storybook

Related docs


Source

The full source and README live in packages/viewer.

License

AGPL-3.0-only. See LICENSE.md.