Product Viewer

@sushindustries/react-product-viewer

A 3D product viewer for TanStack applications, documented with itself.

11 min read
View as Markdown

The block that renders it

<!-- ::start:viewer model="/models/logo.glb" height="460" -->
<!-- ::end:viewer -->

Three things keep an embedded 3D canvas from wrecking the page it sits in:

GuardWhy
lazythree and R3F are ~600 kB, fetched only for documents that use the block
ClientOnlythree cannot run on a server — SSR is a crash, not a slowdown
a reserved boxthe fallback is the viewer's height, so prose does not jump

Do not render this eagerly

ProductViewer is exported as a default specifically so React.lazy can take it. Importing it statically pulls the whole 3D bundle into your entry chunk, on every page, for every visitor.

One canvas, and the variants that shape it

logo.glb carries KHR_materials_variants with four appearances in it - Original, White, Black and Nothing - so this is one file being asked to look four ways, rather than four files. Pass the name to variants and the model brings its own materials:

Viewport width
Desktop100%whatever the page has

listVariants(gltf) in @sushindustries/product-viewer@sushindustries/product-viewerRuntime material swapping and geometric zone tinting for a single GLB product. Framework-free.package reads the names out of a model rather than requiring you to know them, and missingVariants tells you which of the ones you asked for the file does not have - which is the check worth running in CI, because a variant name that does not exist fails silently and renders the default.

There is exactly one <Canvas> in this package, in elements/model-viewer/. There were briefly two - the same component being split out to sit beside its own stylesheet, types and stories, while the original stayed where it was - and they drifted precisely as far as you would expect. modelRef, fit, controls, shadows and pivot existed only in one; scroll and its hint only in the other; ModelCard used one and everything else used the other.

Two implementations of one component is not a refactor in progress, it is a bug with a schedule. A fix to the canvas landed in whichever file the person happened to open. ProductViewer is now an alias for ModelViewer, kept because renaming a public API to tidy an internal one is a cost paid by other people.

Everything the canvas can be is a prop, and every one of them is a thing that is right somewhere and wrong somewhere else:

VariantDefaultRight whenWrong when
scrollzoomsomebody opened this deliberatelyit is in the flow of a document - the page cannot be scrolled past
transparentfalsea mark on a page. Also drops the loading scrima product that needs a ground and an honest edge
controlstruea product people turn overthe canvas is inside a button. Controls take the pointerdown and the click never lands
shadowstrueanything with a ground under itbelow ~100px, where it is four pixels of grey and a second render target
fitfalsethe box's shape is not known in advance - an icon, a resizable panela camera somebody placed deliberately
pivotbaseshadows and grid must land where it meets the groundanything rotating - see below
gridGridA responsive grid with no breakpoints in it. One number decides the column count at every width.@sushindustries/ui · layoutfalsea scene with a real-world scalea mark
groundBoundtrueit sits on a flooryou pick it up and look underneath

`pivot` is why a rotating model orbits instead of spinning

base rests the model on y=0, so the whole mass sits above the axis and a Y rotation swings it around the origin like a fairground ride. Anything driving modelRef wants pivot="center".

[!CAUTION] fov is vertical, and that is the whole trap The default camera is placed for a landscape canvas. On a square one the horizontal field of view collapses to match the vertical, so the same model overflows the frame. It reads as "the model is blurry" or "the model is not rendering", and neither is what is happening. That is what fit is for.

Two of these are also CSS, because the DOM has to know:

/* No controls means nothing here to point at, so pointers pass through to
   whatever this is sitting inside. */
.pv-viewer[data-controls='false'] { pointer-events: none; touch-action: auto; }

/* A transparent viewer is drawn on the page, so the loading scrim must not
   paint the very rectangle that mode exists to avoid. */
.pv-viewer[data-transparent='true'] .pv-progress { background: transparent; }
css

Attributes rather than modifier classes, throughout. An attribute travels with the component, cannot be applied without its base, and is visible in the props rather than in a stylesheet somebody has to go and find.

Ready-made compositions

Reach for these before reaching for the canvas. Each is the same ModelViewer with a set of those variants already chosen, and the reasons written down.

ElementImportWhat it is
ModelViewer.../model-viewerthe canvas. Give it a size and it fills it
ModelCard.../model-carda picture until somebody activates it. No WebGL in a grid of forty
ModelMark.../model-marka model at icon size, over a glyph that never gets removed

Each has its own entry point on purpose, so a page that imports the card does not download the mark, and a page that imports neither does not download three.

Props worth knowing

PropDoes
modelThe GLB and its real-world size
variantsGLB material variants to apply, in order
gltfAn already-loaded asset, when something else owns the cache
groundBoundClamp the orbit above the horizon. True for things that sit on a floor, false for things you pick up
zoneTintsPer-zone colour multipliers, for single-mesh models
snapshotRefSet to capture the current frame as a PNG data URL
modelRefThe group holding the model, so something outside can turn it
transparentNo background node and an alpha buffer, so the page shows through
controlsOrbit controls. Off makes the canvas pointer-transparent, so it can live inside a button
shadowsThe contact shadow. Off below roughly 100px, where it is four pixels of grey
fitFrame the camera to the model instead of to a fixed position