Skip to main content
RapidDev - Software Development Agency
App Featuresmedia-content19 min read

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

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.

4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members

Feature spec

Intermediate

Category

media-content

Build with AI

4–8 hours with Lovable or V0

Custom build

1–2 weeks custom dev

Running cost

$0–$25/mo (image hosting); scales with catalog and traffic

Works on

Web

Everything it takes to ship a Product 360° Viewer — parts, prompts, and real costs.

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).

What users consider table stakes in 2026

  • Drag-to-spin on both mouse and touch with continuous frame wrapping — no hard stop at 0 or 360 degrees
  • Auto-spin on component mount at a slow rate that stops on first user interaction
  • Pinch-to-zoom on mobile and scroll-wheel zoom on desktop, capped at 1x–3x
  • Preload progress indicator showing the image loading state — interaction blocked until all frames are cached
  • Mobile-responsive layout with proper touch gesture handling that doesn't hijack page scroll
  • A 360° badge icon that disappears after first user interaction, teaching the interaction pattern

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.

Layers:UIDataService

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.

Note: Never load frames lazily on demand during spin — the fetch latency causes visible flicker. Preload every frame upfront. WebP reduces sequence size by approximately 35% versus equivalent JPEG quality.

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.

Note: The Pointer Events API (onPointerDown/onPointerMove/onPointerUp with e.currentTarget.setPointerCapture) provides a cleaner unified mouse+touch implementation than separate mouse and touch handlers, and is supported in all modern browsers.

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.

Note: { passive: false } must be set on the addEventListener call, not as a React event prop — React always attaches passive listeners. Use a useEffect with ref.current.addEventListener('touchmove', handler, { passive: false }) and remove it in cleanup.

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.

Note: Hammer.js GestureRecognizer is an alternative for pinch detection but adds 7 KB to the bundle. Native Pointer Events handle the use case in modern browsers without a library.

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.

Note: If any frame fails to load, fall back to the static product hero image and show a subtle error state — do not leave the progress bar spinning indefinitely.

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`.

Note: Configure CORS on the Supabase Storage bucket from the start — if sticker images are ever drawn to a canvas (for zoom compositing), cross-origin images throw SecurityError. Supabase public buckets serve CORS headers automatically; private buckets do not.

The data model

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

schema.sql
1create table public.products (
2 id uuid primary key default gen_random_uuid(),
3 name text not null,
4 frame_count int not null default 36,
5 frame_base_url text not null,
6 frame_format text not null default 'webp',
7 zoom_enabled boolean not null default true,
8 autoplay boolean not null default true,
9 is_published boolean not null default false,
10 created_at timestamptz not null default now()
11);
12
13alter table public.products enable row level security;
14
15create policy "Published products are publicly readable"
16 on public.products for select
17 using (is_published = true);
18
19create policy "Admins can manage all products"
20 on public.products for all
21 using (auth.role() = 'authenticated' and exists (
22 select 1 from public.profiles
23 where id = auth.uid() and role = 'admin'
24 ))
25 with check (auth.role() = 'authenticated' and exists (
26 select 1 from public.profiles
27 where id = auth.uid() and role = 'admin'
28 ));
29
30create table public.frame_sets (
31 id uuid primary key default gen_random_uuid(),
32 product_id uuid references public.products(id) on delete cascade not null,
33 frame_index int not null,
34 asset_url text not null,
35 width int,
36 height int,
37 constraint frame_sets_unique unique (product_id, frame_index)
38);
39
40alter table public.frame_sets enable row level security;
41
42create policy "Frame sets follow product visibility"
43 on public.frame_sets for select
44 using (
45 exists (
46 select 1 from public.products
47 where id = product_id and is_published = true
48 )
49 );
50
51create index frame_sets_product_idx
52 on public.frame_sets (product_id, frame_index);

Heads up: 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 it — pick your path

Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.

Hand-built by developersFit for this feature:

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.

Step by step

  1. 1React 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. 2Lazy 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. 3WebGL-accelerated zoom via a canvas element for blur-free magnification beyond 2x — standard CSS scale degrades at 3x+
  4. 4CDN pipeline: automated image sequence processing (turntable → 36 frames → WebP conversion → upload to R2) using a Cloudflare Worker
  5. 5Integration with a DAM or PIM system (Bynder, Akeneo) via REST API to pull frame URLs from product metadata

Where this path bites

  • 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

Third-party services you'll need

The viewer itself is pure browser code — costs are image hosting and CDN bandwidth only:

ServiceWhat it doesFree tierPaid from
Supabase StorageHosts the frame sequence WebP images for each product1 GB storage included in free tierPro $25/mo includes 100 GB (approx)
Cloudflare R2Alternative CDN for frame images at higher catalog scale — lower egress cost than Supabase10 GB/mo storage + bandwidth free$0.015/GB storage, $0/egress to internet (approx)
CloudinaryImage hosting with on-the-fly WebP conversion from source JPEG frames25 credits/mo freePlus $99/mo (approx)

Swipe the table sideways to see pricing.

What it costs to run

Drag through the tiers to see how your monthly bill scales with users — no surprises later.

Estimated monthly running cost

$0/mo

Frame image hosting only. Supabase free tier (1 GB) covers small catalogs (10–20 products × 36 frames × ~30 KB = ~21 MB total). Bandwidth at 100 sessions stays under free limits.

Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.

What breaks when AI tools build this

The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.

Frame flicker on rapid spin

Symptom: 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

Symptom: 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

Symptom: 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

Symptom: 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

Symptom: 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

1

Preload all frames before allowing interaction — never swap src on partially loaded images or flicker is guaranteed

2

Register touchmove with { passive: false } via addEventListener in useEffect — React's synthetic event cannot disable passive mode, causing scroll-spin conflict

3

Always cancel the requestAnimationFrame loop in the useEffect cleanup — memory leaks accumulate fast on product listing pages with multiple viewers

4

Use WebP format for frame sequences — the ~35% file size reduction versus JPEG materially improves preload time on mobile

5

Show a '360°' instructional badge until first interaction — most users don't discover drag-to-spin without a visual cue

6

Wrap the viewer frame count and base URL in a product record in Supabase — hardcoding these values in the component makes catalog management painful

7

Test on iOS Safari specifically for pinch-zoom and drag-spin independence — the events overlap differently than on Android Chrome

When You Need Custom Development

Lovable and V0 cover product spin viewers for typical e-commerce use cases. Certain catalog or quality requirements need custom engineering:

  • Product catalog exceeds 500 SKUs and needs an automated image sequence pipeline — photography to 36 WebP frames to Supabase Storage requires a Cloudflare Worker or similar automation
  • 3D GLB model support is required (actual 3D models, not image sequences) via @google/model-viewer with AR Quick Look integration for iPhone — this is a different technical stack from frame-swapping
  • Integration with a DAM or PIM system (Bynder, Cloudinary, Akeneo) for automated frame ingestion from product asset management workflows
  • Hotspot annotations on specific rotation angles (e.g. 'click at 45° to see the charging port') require custom state management linking frame index to annotation visibility

RapidDev builds these features for production

Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.

Get a fixed-price quote

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.

RapidDev

Need this feature production-ready?

RapidDev builds a product 360° viewer into real apps — auth, database, payments — at $13K–$25K.

Book a free consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.