---
title: Model Mark
description: A model at icon size - turning, uninteractive, and layered over a real icon that never gets removed.
source: https://adamjurek.com/components/model-mark
---

## Home


```tsx
import { ModelMark } from "@sushindustries/react-product-viewer/model-mark";

<ModelMark
	model={{ url: logoUrl, realLength: 1 }}
	glyph={<Icon name="sushi" size={30} />}
	label="Sushindustries"
	seconds={18}
/>;
```

## Why this is an element and not four props

`ModelViewer` is right for a hero and wrong for a 48px square in four separate
ways. Each of them looks like a *different* bug, and none of them is a fault in
the viewer - every one is correct for a product on a card, which is what it was
built for.

| What | Why it is wrong here | What it looks like |
| --- | --- | --- |
| Orbit controls | The canvas takes the pointerdown, so a button around it never receives the click | An icon that spins and refuses to open |
| Contact shadow | A second render target, re-baked every frame while the model turns | Nothing. It just costs |
| Fixed camera | `fov` is *vertical*, so a square canvas has a far narrower horizontal field of view than the landscape one the camera was placed for | "The model is blurry", or "it is not rendering" |
| Progress scrim | An overlay and a 4px backdrop blur | A grey square that appears and vanishes |

Four props somebody has to remember every time is a rule. An element is a
decision that has already been made.

> [!CAUTION] `fov` is vertical, and that is the whole trap
> A camera tuned on a wide canvas looks completely correct until the same
> component is put in a square one. The model does not move; the frame closes in
> on it.


## Get Started


## Install

```shell
pnpm add @sushindustries/react-product-viewer three @react-three/fiber @react-three/drei three-stdlib
```

There is no registry entry for this - it ships from its own package rather
than `packages/ui`, so there is no TanStack or shadcn command to run
alongside it.

## Use it

```tsx
import { Icon } from "@sushindustries/ui";
import { ModelMark } from "@sushindustries/react-product-viewer/model-mark";

export function Example() {
	return (
		<ModelMark
			model={{ url: "/models/logo.glb", realLength: 1 }}
			glyph={<Icon name="sushi" size={30} />}
			label="Sushindustries"
			seconds={18}
		/>
	);
}
```

The import comes from `/model-mark`, not from the package root - the root
statically imports the full viewer, and importing from there would put
three's ~600 kB back into every page that names a mark.

## What you should see

The glyph, immediately - a flat icon, exactly as if `ModelMark` were not
there at all. A moment later, once the GLB has downloaded and WebGL has
initialised, a small 3D canvas fades in over it and starts turning slowly.
The glyph never disappears; the canvas paints over it.

## If nothing happens

If only the glyph ever shows and the canvas never arrives, check
`prefers-reduced-motion` first - under that preference no canvas mounts at
all, by design, and the glyph alone is the correct result rather than a
bug. Failing that, check the model URL: a 404 or a WebGL context refusal
also leaves the glyph as the only thing on screen, silently, because the
canvas simply never becomes `live`.


## Guides


## The glyph is underneath, and it stays there

`glyph` is not a placeholder that gets swapped out on load. It is drawn in the
same grid cell and the canvas paints over it.

```tsx
<ModelMark
	model={LOGO_MODEL}
	glyph={<Icon name="sushi" size={30} />}
	label="Sushindustries"
/>
```

WebGL is the one part of a page that can fail for reasons the page does not
control: a driver, a context the browser refused, a machine with no GPU worth
the name. Left as a fallback that is *replaced*, every one of those is a blank
square. Left underneath, every one of them is an icon that does not spin.

It is also what shows under reduced motion, because no canvas is mounted then
at all - see below.

## `place-self: stretch` is load-bearing

This is worth stating on its own, because getting it wrong is a **silent, total
failure** rather than a visual one.

```css
.pv-mark > * {
	grid-area: 1 / 1;
	place-self: stretch; /* not `place-items: center` on the parent */
}
```

A grid item that is *centred* is sized to its content. The viewer inside asks
for `width: 100%` of that item. A percentage of a shrink-to-fit box that is
waiting on its own content resolves to **zero**.

The canvas is then 0x0. WebGL initialises happily, nothing is logged, no error
is thrown, and the element renders as an empty square that looks exactly like a
model which failed to load.

## Reduced motion mounts nothing

Not a slower spin, and not a still canvas.

A stationary 3D model at icon size is a worse version of the glyph already
underneath it, and it costs a WebGL context to be worse. So `useMarkSpin`
returns `live: false` and the element renders the glyph alone.

`spinAnyway` exists and should stay off.

## Motions are variants, and variants are pure functions

```tsx
<ModelMark motion="sway" seconds={18} model={model} />
```

| Motion | What it does | For |
| --- | --- | --- |
| `spin` | one axis, constant speed | the default. A thing on a shelf |
| `sway` | turns to face, overshoots, returns | a mark with a **front**. A spin spends half of every revolution edge-on and unreadable |
| `tumble` | two axes at rates that do not resynchronise | a mark with no front - a solid, a knot, a die |
| `still` | held at a three-quarter view | a resting state. Enough angle to read as a model, no movement |

Each is a pure function of elapsed seconds, exported as `MARK_MOTIONS`:

```ts
import { MARK_MOTIONS, applyMotion } from "@sushindustries/react-product-viewer/model-mark";

MARK_MOTIONS.spin(2, 8); // { x: 0, y: 1.57..., z: 0 }
```

Pure and time-based, and both halves earn their place. **Pure** means a motion
is testable, composable and reusable without a canvas anywhere near it - a
fifth motion is a function, not a fork of this element. **Time-based** means a
dropped frame loses nothing.

Two numbers in there are chosen rather than found, and both would look
arbitrary without saying so:

- `sway` turns a **quarter turn** each way. Enough to read as three-dimensional,
  little enough that nothing goes past its own silhouette.
- `tumble` runs its second axis at **1.6x**, not 1.5. At a simple fraction the
  two axes resynchronise every other cycle and the whole thing visibly loops.

The variant is `data-motion` on the element, never a second class name. 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 find.

> [!NOTE] `still` is exempt from reduced motion
> Reduced motion is a request about movement, not about canvases. Treating
> `still` as motion would disable the one variant that already honours the
> preference.

## The pivot is why it spins rather than orbits

`ProductModel` rests the model's base on `y=0` by default, so contact shadows
and the grid land where it actually meets the ground. That is right for a
product and wrong for anything rotating: the whole mass then sits **above** the
axis, and a Y rotation swings the object around the origin like a fairground
ride instead of turning it on the spot.

```tsx
<ProductViewer pivot="center" /> // the origin at the middle of the bounding box
```

`ModelMark` sets it. There is no ground under an icon for the base to rest on,
so there is nothing traded away.

| `pivot` | Origin | For |
| --- | --- | --- |
| `base` | centred in X and Z, resting on y=0 | a product. Shadows and grid land correctly |
| `center` | the middle of the bounding box | anything that rotates |

## It turns on elapsed time

```ts
group.rotation.y = ((now - started) / (seconds * 1000)) * Math.PI * 2;
```

Not `rotation.y += 0.01`. A per-frame increment loses rotation on every dropped
frame, and a tab that was in the background comes back a quarter turn behind
where it should be. Nobody notices until they switch away and back.

Written straight onto the group through `modelRef`, never through state: a
`rotation` prop would re-render the whole viewer sixty times a second to change
one float React has no reason to know about.

## It has its own entry, and that is deliberate

```ts
import { ModelMark } from "@sushindustries/react-product-viewer/model-mark";
```

Not from the package root. `ModelMark` lazily imports the viewer from inside
itself, so a page that only names a mark ships no three until one becomes live.
The root imports the viewer *statically*, so importing from there would put
~600 kB straight back into the graph - and the bundler says so rather than
quietly undoing it:

```text
[INEFFECTIVE_DYNAMIC_IMPORT] src/product-viewer.tsx is dynamically imported by
src/elements/model-mark/model-mark.tsx but also statically imported by
src/index.ts
```

## Sizing

One custom property, because the size is almost always a stylesheet's decision:

```css
.site-mark {
	--pv-mark-size: clamp(2.75rem, 11cqi, 3.25rem);
}
```

A number prop could express none of `clamp`, a container query, or a media
query. `style` is the escape hatch for the one case where the caller really did
compute it.

## Props

| Prop | Type | What it does |
| --- | --- | --- |
| `model` | `ModelConfig` | The GLB and its real-world size |
| `glyph` | `ReactNode` | Drawn underneath, and never removed |
| `seconds` | `number` | Seconds per revolution. Default 14 |
| `spinAnyway` | `boolean` | Turn even under reduced motion. Leave it off |
| `label` | `string` | `aria-label`. The canvas contributes nothing to the accessibility tree |
| `className` | `string` | Added after `pv-mark` |
| `style` | `CSSProperties` | In practice, `--pv-mark-size` |


## API


## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `model` | `ModelConfig` | - | The GLB and its real-world size. |
| `glyph?` | `ReactNode` | - | Drawn underneath the canvas, and never removed - what shows before the model loads, if it fails, and under reduced motion. |
| `seconds?` | `number` | `14` | Seconds per full cycle. Higher is slower. |
| `motion?` | `MarkMotion` | `"spin"` | `"spin" \| "sway" \| "tumble" \| "still"`. See Motions below. |
| `spinAnyway?` | `boolean` | `false` | Turn even under reduced motion. Should stay off - a stationary 3D model at icon size is a worse, more expensive glyph. |
| `className?` | `string` | - | Added after the built-in `pv-mark` class. |
| `style?` | `CSSProperties` | - | In practice, `--pv-mark-size`. |
| `label?` | `string` | - | `aria-label` on the wrapping `role="img"` element - required in practice, since the canvas contributes nothing to the accessibility tree. |

## Motions

```ts
type MarkMotionFn = (seconds: number, period: number) => { x: number; y: number; z: number };

const MARK_MOTIONS: Readonly<Record<MarkMotion, MarkMotionFn>>;

function applyMotion(rotation: Euler, motion: MarkMotion, seconds: number, period: number): void;
```

Each entry in `MARK_MOTIONS` is a pure function of elapsed seconds and the
`seconds` prop's period - no canvas required to call one, and a fifth motion
is a function added to this map rather than a fork of the element.
`applyMotion` writes the result straight onto a three `Euler` in place,
which is how `ModelMark` avoids a `rotation` prop re-rendering the viewer
sixty times a second.

## useMarkSpin

```ts
useMarkSpin(seconds: number, spinAnyway: boolean, motion: MarkMotion): MarkSpin
```

Returns `{ modelRef, live }`. `live` is `false` until the canvas should
mount - never under reduced motion unless `spinAnyway` is set - and `true`
otherwise. `modelRef` is what the animation frame loop writes rotation onto.

## Notes

`model` takes the same `ModelConfig` shape as `ProductViewer` - a URL and a
`realLength` - because `ModelMark` mounts the same viewer underneath, fitted
and stripped down for icon size rather than rebuilt.

There is no prop for `pivot`, `controls`, `shadows`, `fit` or `groundBound`.
`ModelMark` sets all five itself: `pivot="center"` so the model spins on the
spot rather than orbiting the origin, and the rest off or fitted, because
none of them are meaningful choices at icon size - see the guides tab for
why each one would be wrong here.


## Examples


This site's own desktop is the example. One icon on it - the one with the
`sushi` glyph - is a live `ModelMark` rather than a flat SVG:

<!-- ::start:shelf -->
<!-- ::end:shelf -->

## In a page

This site's mark wraps `ModelMark` in exactly one file, and it decides only
two things: which model, and which glyph goes underneath it. Everything
about *how* a model behaves at icon size - the camera fit, the disabled
controls, the reduced-motion path - stays inside the element:

```tsx
import { ModelMark } from "@sushindustries/react-product-viewer/model-mark";
import { Icon } from "@sushindustries/ui";
import { LOGO_MODEL } from "./logo";

export function SiteMark({ seconds = 18 }: { seconds?: number }) {
	return (
		<ModelMark
			model={LOGO_MODEL}
			seconds={seconds}
			motion="sway"
			label="Sushi Industries"
			glyph={<Icon name="sushi" size={30} />}
			className="site-mark"
		/>
	);
}
```

`motion="sway"` is a fact about this logo, not a stylistic pick: the mark
has a front, and the default `spin` would show it edge-on for half of every
revolution.

## What this example is not

The shelf above renders exactly one live mark among a grid of flat icons -
`ModelMark` is not meant to replace every glyph on a page. Every other icon
on that shelf is a plain SVG, on purpose: a live model is a hero treatment
for the one entry that deserves it, not a default.
