MynduiMyndui
0

Anatomy of Spin Viewer

How dragging a 360° product shot down to a rounded division and an array index — no 3D, no Framer Motion, just a discrete frame swap.

01/03An array and an index

frames[index] — 8 of 48

frames[]
ordered image URLs, preloaded once on mount
index
React state — which frame is current
img src
frames[index], swapped — never interpolated
tsx
for (const src of frames) {const img = new Image();img.onload = done;img.onerror = done;img.src = src;}
01

An array and an index

SpinViewer doesn't simulate a 3D object at all. It's an ordered array of photos and a <img src={frames[index]} /> — "rotating" the object is nothing but changing index. Every mechanism in the component exists to make that one src swap feel physical.

On mount, every frame preloads with a plain new Image() before the viewer marks itself ready — that's what makes the first drag instant instead of a flash of broken/loading images:

Once ready, the whole visible surface is one <img> whose src swaps — never a tween, never an opacity cross-fade between two frames. That's why the scene above steps discretely instead of easing: a real frame swap has no in-between state to animate through.

02/03The drag

stepped = round(dx / sensitivity)

Pointer
dx tracked from onPointerDown's clientX
Frame
index += stepped, wrapped mod frame count
tsx
const dx = e.clientX - drag.current.x;const stepped = Math.round(dx / sensitivity) * (reverse ? -1 : 1);setIndex(mod(drag.current.i + stepped, count));
02

The drag

onPointerDown snapshots the pointer's clientX and the current index. onPointerMove does the only math in the whole gesture — how many sensitivity-px ticks the pointer has crossed since then, rounded to a whole step:

With the default sensitivity={6}, a 60px drag steps through 10 frames — drag slower and nothing moves until you cross the next 6px tick, which is exactly why a slow, deliberate drag feels more "mechanical" than a fast flick: the fast flick blurs past ticks the eye never resolves.

autoRotate runs its own tiny requestAnimationFrame loop, independent of the drag math — it accumulates (dt / 1000) * autoRotateSpeed frames per tick and steps index whenever that accumulator crosses 1:

tsx
 
acc += ((t - last) / 1000) * autoRotateSpeed * dir;
if (Math.abs(acc) >= 1) {
  const inc = Math.trunc(acc);
  acc -= inc;
  setIndex((i) => mod(i + inc, count));
}

The loop bails out the moment dragging, reduced, or !ready is true — grabbing the viewer or preferring reduced motion stops it outright, it never fights the user's drag.

03/03Result
03

The result

One array, one index, one preload pass — drag above, or watch it idle.

Accessibility

The root is role="img" with a descriptive aria-label, so screen readers get one coherent object instead of an unlabeled, constantly mutating <img>. touch-action: pan-y keeps vertical page scroll working over the viewer — only horizontal drags are captured. Auto-rotate checks prefers-reduced-motion and simply never starts; dragging is untouched, since it's a deliberate user action rather than motion the page imposed.

Motion Score

Spin ViewerSSCompositor-only
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →