Feature spec
IntermediateCategory
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
Everything it takes to ship a Product 360° Viewer — parts, prompts, and real costs.
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.
Image sequence loader
DataFetches 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
UITracks 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
UIUnified 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
UICSS 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
UIA 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
ServiceImages 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:
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);1213alter table public.products enable row level security;1415create policy "Published products are publicly readable"16 on public.products for select17 using (is_published = true);1819create policy "Admins can manage all products"20 on public.products for all21 using (auth.role() = 'authenticated' and exists (22 select 1 from public.profiles23 where id = auth.uid() and role = 'admin'24 ))25 with check (auth.role() = 'authenticated' and exists (26 select 1 from public.profiles27 where id = auth.uid() and role = 'admin'28 ));2930create 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);3940alter table public.frame_sets enable row level security;4142create policy "Frame sets follow product visibility"43 on public.frame_sets for select44 using (45 exists (46 select 1 from public.products47 where id = product_id and is_published = true48 )49 );5051create index frame_sets_product_idx52 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.
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
- 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
- 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
- 3WebGL-accelerated zoom via a canvas element for blur-free magnification beyond 2x — standard CSS scale degrades at 3x+
- 4CDN pipeline: automated image sequence processing (turntable → 36 frames → WebP conversion → upload to R2) using a Cloudflare Worker
- 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:
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase Storage | Hosts the frame sequence WebP images for each product | 1 GB storage included in free tier | Pro $25/mo includes 100 GB (approx) |
| Cloudflare R2 | Alternative CDN for frame images at higher catalog scale — lower egress cost than Supabase | 10 GB/mo storage + bandwidth free | $0.015/GB storage, $0/egress to internet (approx) |
| Cloudinary | Image hosting with on-the-fly WebP conversion from source JPEG frames | 25 credits/mo free | Plus $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
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
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
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.
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.
Need this feature production-ready?
RapidDev builds a product 360° viewer into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.