# How to Add a Product 360° Viewer to Your App — Copy-Paste Prompts Inside

- Tool: App Features
- Last updated: July 2026

## TL;DR

A 360° product viewer loads a sequence of 24–72 pre-shot WebP images and swaps between them as the user drags — no 3D engine required. With Lovable or V0 you can ship a working spin viewer in 4–8 hours for $0–$25/month (image hosting only). The heavy lifting is image production (photography), not code. Frame flicker and touch-scroll conflicts are the two bugs that kill first builds.

## What a Product 360° Viewer Actually Is

A 360° viewer is a drag-to-spin illusion: the browser holds 24–72 JPEG or WebP images captured at equal rotation steps around a product, and swaps the visible image as the user drags. There is no 3D rendering — just fast image swapping with inertia. E-commerce studies consistently show that 360° images reduce return rates and increase conversion versus static photos because buyers can inspect every angle before purchase. The code is simpler than it looks; the real effort is the photo shoot (a turntable, consistent lighting, 36+ shots per product). The product decisions that shape the build are: how many frames (24 = noticeable steps, 72 = buttery), whether to support zoom, and whether models come from an image sequence or actual 3D models (GLB files via @google/model-viewer).

## Anatomy of the Feature

Six components. Lovable and V0 generate the React event handler and image swap logic correctly on the first prompt. The preload cache and CORS setup are where builds silently fail.

- **Image sequence loader** (data): Fetches a sequence of 24–72 JPEG or WebP images (e.g. frame_01.webp through frame_36.webp) from a CDN base URL. Images are loaded via the Image() constructor with onload callbacks and stored in a ref array. A loaded/total counter drives the preload progress indicator. Interaction is disabled until all frames are cached.
- **Spin controller** (ui): Tracks the current frame index as React state. onMouseDown/onMouseMove/onMouseUp and onTouchStart/onTouchMove/onTouchEnd handlers map pointer delta-X to frame index delta. Frame index wraps modulo the total frame count so spin is continuous in both directions. A drag velocity variable (delta-X per ms) enables inertia — the spin coasts and decelerates after the pointer lifts.
- **Touch and mouse event layer** (ui): Unified event handling for desktop mouse drag and mobile touch drag. For touch, touchmove must call event.preventDefault() with { passive: false } on the event listener to prevent page scroll conflict — this is the single most common mobile UX bug in 360° viewers.
- **Zoom layer** (ui): CSS transform: scale() applied to the image element. Scroll-wheel delta triggers scale changes between 1x and 2x with CSS transition: transform 0.1s ease. On mobile, pinch gesture via Pointer Events API (tracking distance between two pointer IDs) toggles between 1x and 2x. A reset zoom button returns to 1x.
- **Preload progress indicator** (ui): A circular or linear progress bar driven by a loaded/total image count in state. Rendered in place of the viewer until all frames are cached. Disappears once 100% of frames are loaded and the first frame is displayed. The viewer wrapper shows pointer-events: none until preload completes to prevent interaction on partially loaded sequences.
- **CDN image delivery** (service): Images stored in Supabase Storage public bucket or Cloudflare R2, named with zero-padded indices (product_01.webp through product_36.webp). The viewer accepts a baseUrl and frameCount prop and generates image URLs as `${baseUrl}frame_${String(i+1).padStart(2, '0')}.webp`.

## Data model

Two tables: products stores the viewer configuration and frame metadata tables track individual frame assets. Run this in the Supabase SQL editor:

```sql
create table public.products (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  frame_count int not null default 36,
  frame_base_url text not null,
  frame_format text not null default 'webp',
  zoom_enabled boolean not null default true,
  autoplay boolean not null default true,
  is_published boolean not null default false,
  created_at timestamptz not null default now()
);

alter table public.products enable row level security;

create policy "Published products are publicly readable"
  on public.products for select
  using (is_published = true);

create policy "Admins can manage all products"
  on public.products for all
  using (auth.role() = 'authenticated' and exists (
    select 1 from public.profiles
    where id = auth.uid() and role = 'admin'
  ))
  with check (auth.role() = 'authenticated' and exists (
    select 1 from public.profiles
    where id = auth.uid() and role = 'admin'
  ));

create table public.frame_sets (
  id uuid primary key default gen_random_uuid(),
  product_id uuid references public.products(id) on delete cascade not null,
  frame_index int not null,
  asset_url text not null,
  width int,
  height int,
  constraint frame_sets_unique unique (product_id, frame_index)
);

alter table public.frame_sets enable row level security;

create policy "Frame sets follow product visibility"
  on public.frame_sets for select
  using (
    exists (
      select 1 from public.products
      where id = product_id and is_published = true
    )
  );

create index frame_sets_product_idx
  on public.frame_sets (product_id, frame_index);
```

The frame_sets table is optional for simple catalogs where frames follow a predictable naming pattern — the viewer can construct URLs from baseUrl + frame_index without a database lookup. Use frame_sets when frames are individually uploaded, renamed, or come from different sources.

## Build paths

### Lovable — fit 4/10, 4–6 hours

Lovable handles the React event handler logic, image preload pattern, and Supabase Storage integration cleanly in 1–2 prompts when given the exact frame URL pattern. Inertia physics and passive event listener registration require a follow-up manual fix.

1. Create a new Lovable project with Lovable Cloud enabled for Supabase auth and Storage
2. Upload your 36 WebP frames to Supabase Storage via the Lovable Cloud Storage panel to get the base URL
3. Paste the prompt below, providing the base URL and frame count
4. After generation, open the published URL on your phone and test touch drag — if page scroll interferes, add { passive: false } to the touchmove addEventListener in the generated code
5. Test frame loading on a slow connection by throttling in Chrome DevTools — confirm the preload indicator works before launch

Starter prompt:

```
Build a 360° product viewer component. Load 36 WebP images named frame_01.webp through frame_36.webp from the base URL '{your-base-url}'. Show a circular preload progress indicator (loaded count / 36) that disappears once all images are cached in an Image() ref array. Render a single <img> tag that updates its src to the currently cached image on every frame index change. Drag-to-spin: onMouseDown records start X; onMouseMove computes delta X and converts to frame delta at a rate of 5 pixels per frame; frame index wraps modulo 36 for continuous spin. Auto-spin on mount using requestAnimationFrame at 2 frames per tick; cancel the animation loop on first mousedown or touchstart. Scroll-wheel zoom: toggle CSS transform scale between 1x and 2x on wheel event. Touch support: onTouchMove maps touchX delta to frame delta. Show a '360°' badge icon overlay until first user interaction. If any frame fails to load, fall back to a static hero image. Clean up the requestAnimationFrame loop in the useEffect cleanup function to prevent memory leaks.
```

Limitations:

- Lovable may not register touchmove with { passive: false } — page scroll and spin conflict on mobile until this is manually added via addEventListener in a useEffect
- Inertia (spin coasting after pointer release) is often omitted from Lovable output — add drag velocity tracking manually if needed
- CORS must be configured on Supabase Storage bucket before deploying; Lovable's preview runs on a different origin than the published URL

### V0 — fit 4/10, 4–6 hours

V0 produces polished viewer shell code and handles the image preload pattern cleanly for Next.js. The main manual fix: V0 often wraps frame images in next/image which interferes with raw frame-index rendering; swap to a plain <img> tag.

1. Prompt V0 with the viewer spec below, explicitly requesting plain <img> tags (not Next.js Image component)
2. Add your Supabase environment variables in the Vars panel (NEXT_PUBLIC_SUPABASE_URL and anon key)
3. Run the product data model SQL in the Supabase SQL editor
4. Add the viewer component to the product detail page in your Next.js app
5. Publish and test on iOS Safari and Android Chrome specifically — verify pinch zoom and drag spin work independently

Starter prompt:

```
Create a Next.js ProductViewer client component. Props: baseUrl (string), frameCount (number, default 36), frameFormat (string, default 'webp'). Behavior: preload all frames into an Image() array with onload callbacks; show a progress bar (loaded/total) until complete. Render a single plain <img> element (NOT next/image) with src set to the current frame URL. Drag-to-spin: useRef tracks isDragging and startX; onMouseMove converts delta X to frame delta at 1 frame per 6px; index wraps modulo frameCount. Auto-spin: requestAnimationFrame loop at 2 frames per tick, stops on first pointer interaction. Scroll-wheel zoom: toggle between 1x and 2x with CSS transform and a 0.15s ease transition. Touch: touchmove delta X maps to frame delta. Badge: show '360°' badge until first interaction. Cleanup: cancelAnimationFrame in useEffect return. Error: if a frame Image() onerror fires, stop the preload and show a static fallback image. Export as a named component for use on any product page.
```

Limitations:

- V0 occasionally generates next/image wrappers instead of plain <img> — inspect the output and replace next/image with <img> in the viewer component
- V0 does not auto-provision Supabase Storage — upload frame sequences manually and paste the base URL as a prop or env variable
- next/image domain whitelist in next.config.js must include the Supabase Storage domain or image loads will fail with a 400 error

### Custom — fit 5/10, 1–2 weeks

Full control over inertia algorithm, lazy loading strategy per product, WebGL-based zoom for blur-free magnification, and integration with product PIM systems. Necessary for catalogs exceeding 100 products.

1. React component with a custom inertia engine: track drag velocity (px/ms) and apply exponential decay in requestAnimationFrame after pointer release for realistic spin momentum
2. Lazy load frame sequences per product using IntersectionObserver — only start preloading when the viewer scrolls into viewport, critical for product listing pages with multiple viewers
3. WebGL-accelerated zoom via a canvas element for blur-free magnification beyond 2x — standard CSS scale degrades at 3x+
4. CDN pipeline: automated image sequence processing (turntable → 36 frames → WebP conversion → upload to R2) using a Cloudflare Worker
5. Integration with a DAM or PIM system (Bynder, Akeneo) via REST API to pull frame URLs from product metadata

Limitations:

- 1–2 week timeline plus the image production pipeline (photography) which is often the longer effort
- WebGL zoom adds bundle complexity; only warranted for high-end jewelry or watch product pages

## Gotchas

- **Frame flicker on rapid spin** — Setting <img src={currentFrameUrl}> directly from state causes the browser to decode a new image on each state update. During fast dragging, the browser cannot decode frames fast enough and shows a white-flash or blank frame between each src update. This makes the viewer look broken and is the most reported quality issue in AI-generated 360° viewers. Fix: Preload ALL frames into a JavaScript Image() array before allowing interaction: const frames = Array.from({ length: 36 }, (_, i) => { const img = new Image(); img.src = buildUrl(i); return img; }). Track loaded count with img.onload callbacks. Only start allowing spin once every frame is loaded (loaded === total). Update the displayed <img>.src from frames[currentIndex].src to read from the already-decoded browser cache.
- **Touch drag hijacks page scroll on mobile** — Attaching a touchmove event handler to the viewer container causes the browser to block page scrolling for the entire session once the user's finger enters the viewer area. On product pages with content below the fold, users cannot scroll past the viewer on mobile — a critical usability failure. Fix: Call event.preventDefault() inside the touchmove handler and register the listener with { passive: false } via addEventListener in a useEffect: viewerRef.current.addEventListener('touchmove', handleTouch, { passive: false }). React's synthetic onTouchMove cannot receive { passive: false } — you must use addEventListener directly. Add a visual 'hold and drag to spin' instruction to set user expectations.
- **next/image breaks frame sequence control** — V0 often wraps frame images in the Next.js Image component (next/image) which applies automatic layout fills, width/height constraints, and blur-up placeholders. These transforms interfere with the viewer's need to update image src at sub-frame timing — the next/image optimization pipeline introduces latency and sometimes caches a single frame, breaking the spin entirely. Fix: Replace next/image with a plain <img> tag for the frame display element. Add unoptimized={true} to any remaining next/image instances in the product gallery. Update next.config.js to whitelist the Supabase Storage domain only for non-viewer images that do benefit from optimization.
- **CORS error when drawing frames to canvas for zoom** — If zoom compositing or canvas-based magnification draws cross-origin frame images onto a canvas, calling ctx.drawImage() followed by toDataURL() throws a SecurityError: 'Tainted canvases may not be exported'. This only manifests when canvas zoom is implemented — standard CSS scale zoom is unaffected. Fix: Set CORS headers on the Supabase Storage bucket (go to Storage → Policies in Supabase Dashboard and configure CORS to allow your app's origin). Also add crossOrigin='anonymous' to the Image() objects in the preload array. With both in place, canvas operations on the frame images work without SecurityError.
- **Auto-spin requestAnimationFrame leaks on unmount** — The requestAnimationFrame loop for auto-spin continues running after the viewer component unmounts — for example when a modal closes or the user navigates away. The animation ID has no reference to cancel it, causing increasing CPU usage and eventual memory pressure if the user opens and closes the viewer multiple times. Fix: Store the animation ID in a useRef and return a cleanup function from useEffect: const animId = useRef(null); inside the loop, animId.current = requestAnimationFrame(spin); return () => cancelAnimationFrame(animId.current) in the useEffect cleanup. Test by mounting and unmounting the viewer 10 times in DevTools — CPU usage should return to baseline after each unmount.

## Best practices

- Preload all frames before allowing interaction — never swap src on partially loaded images or flicker is guaranteed
- Register touchmove with { passive: false } via addEventListener in useEffect — React's synthetic event cannot disable passive mode, causing scroll-spin conflict
- Always cancel the requestAnimationFrame loop in the useEffect cleanup — memory leaks accumulate fast on product listing pages with multiple viewers
- Use WebP format for frame sequences — the ~35% file size reduction versus JPEG materially improves preload time on mobile
- Show a '360°' instructional badge until first interaction — most users don't discover drag-to-spin without a visual cue
- Wrap the viewer frame count and base URL in a product record in Supabase — hardcoding these values in the component makes catalog management painful
- Test on iOS Safari specifically for pinch-zoom and drag-spin independence — the events overlap differently than on Android Chrome

## Frequently asked questions

### How many images do I need for a smooth 360° spin?

24 frames (one every 15 degrees) is the minimum for a usable experience — users notice the stepping at this count. 36 frames (10-degree steps) feels smooth for most products. 72 frames (5-degree steps) is buttery and worth it for high-end products like watches or jewelry where every angle is a selling point. Each additional frame adds ~30 KB to the preload (at WebP compression) — a 72-frame sequence is roughly 2.2 MB, which loads in 1–2 seconds on average mobile connections.

### Should I use image sequences or a 3D model for 360° product viewing?

Image sequences for almost all e-commerce use cases. Photographs capture real-world material, lighting, and texture in a way 3D models can't match without expensive rendering. 3D models (GLB via @google/model-viewer) are appropriate when: the product doesn't yet physically exist (pre-orders), you need AR placement (try-in-room), or you're building a configurator where users change colors or parts. Image sequences are faster to build, cheaper to host, and look better on real products.

### How do I compress 360° product images for fast loading?

Convert all frames to WebP before upload — this typically reduces file size by 30–35% versus JPEG at equivalent quality. Target 800x800 px for standard product viewers (sufficient for 2x zoom). Use ImageMagick's batch convert command from your desktop or a Cloudinary transformation pipeline to process all frames consistently. A well-compressed 36-frame WebP sequence should total 800 KB–1.5 MB, loading in under 2 seconds on LTE.

### Can I add zoom to a 360° viewer?

Yes, and CSS transform: scale() is the right tool for most implementations. Toggle between 1x and 2x on scroll-wheel and pinch gestures. For zoom beyond 2x (3x–5x for jewelry or watch details), CSS scale degrades to blurry pixels — at that point you need either higher-resolution source frames (1600x1600) or a canvas-based magnifier that crops and enlarges a region of the current frame.

### How do I make the 360° viewer work on iPhone?

The main iPhone-specific issues: (1) touch drag and page scroll conflict — register touchmove with { passive: false } and call preventDefault; (2) iOS Safari respects touch-action CSS — add touch-action: none to the viewer container element; (3) pinch zoom conflicts with the browser's native page zoom — call event.preventDefault() on the touchstart handler when two touches are detected. Test on a real iPhone, not just Chrome DevTools touch simulation.

### What's the cheapest way to host 36 product images?

Supabase Storage free tier (1 GB) covers a small catalog — 36 frames at ~30 KB each is ~1 MB per product, so the free tier supports roughly 1,000 products. Beyond that, Cloudflare R2 at $0.015/GB storage and $0 egress to the internet is significantly cheaper than Supabase's bandwidth pricing at scale. For a quick start, Supabase is fine; migrate to R2 when storage or bandwidth costs appear on your bill.

### Can I add hotspot labels to specific angles?

Yes — track the current frame index in state and conditionally render annotation overlays when the index matches a target angle. Store hotspot data as { frameIndex: 12, label: 'USB-C port', x: 65, y: 48 } where x/y are percentage positions on the image. During spin, check if currentIndex === hotspot.frameIndex and render a positioned label at the configured coordinates. Fade the label with CSS opacity for a polished feel.

### How do I generate the 36 product photos for a 360° spin?

You need a turntable (motorized or manual), a consistent camera position, and controlled lighting. Set the turntable to 10-degree increments and shoot one photo per step for 36 total. Remove backgrounds with a tool like Remove.bg or Photoshop's Remove Background. Resize all frames to 800x800 px with the product centered. Convert to WebP. The photography and post-processing typically takes 30–60 minutes per product — budget accordingly. This is usually the longer effort versus writing the viewer code.

---

Source: https://www.rapidevelopers.com/app-features/product-360-degree-viewer
© RapidDev — https://www.rapidevelopers.com/app-features/product-360-degree-viewer
