Anatomy of Spin Viewer
How dragging a 360° product shot comes down to a rounded division and an array index. No 3D and no Framer Motion, only a discrete frame swap.
↓Scroll to step through it
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 means
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 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,
with no tween and no opacity cross-fade between two frames. The scene above
steps discretely instead of easing because a real frame swap has
no in-between state to animate through.
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. That is
why a slow, deliberate drag feels more "mechanical" than a fast
flick: the fast flick passes ticks too quickly for the eye to resolve them.
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:
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, so it
never fights the user's drag.
The result
One array, one index, and one preload pass. Drag the viewer above, or leave it to idle.
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
for (const src of frames) {const img = new Image();img.onload = done;img.onerror = done;img.src = src;}Anatomy of Spin Viewer
How dragging a 360° product shot comes down to a rounded division and an array index. No 3D and no Framer Motion, only a discrete frame swap.
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
for (const src of frames) {const img = new Image();img.onload = done;img.onerror = done;img.src = src;}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 means
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 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,
with no tween and no opacity cross-fade between two frames. The scene above
steps discretely instead of easing because a real frame swap has
no in-between state to animate through.
stepped = round(dx / sensitivity)
- Pointer
- dx tracked from onPointerDown's clientX
- Frame
- index += stepped, wrapped mod frame count
const dx = e.clientX - drag.current.x;const stepped = Math.round(dx / sensitivity) * (reverse ? -1 : 1);setIndex(mod(drag.current.i + stepped, count));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. That is
why a slow, deliberate drag feels more "mechanical" than a fast
flick: the fast flick passes ticks too quickly for the eye to resolve them.
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:
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, so it
never fights the user's drag.
The result
One array, one index, and one preload pass. Drag the viewer above, or leave it to 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 never starts. Dragging is untouched,
since it's a deliberate user action rather than motion the page imposed.
Motion Score
opacityFade / cross-fade