How to build a draggable 360 product viewer in React
A 360 product viewer in React maps a horizontal drag offset to a rotateY transform using Framer Motion's useMotionValue and useTransform, smoothed by a useSpring with stiffness 200 and damping 30. The spring snaps back to center on drag release, and a separate scroll-linked rotateY adds ambient motion when no drag is active.
- Stack: React 18 + Framer Motion 11, ~270 lines, zero icon dependencies.
- Core API: useMotionValue, useSpring, useTransform, useScroll, all from framer-motion.
- Drag range clamped to ±200px; rotateY maps to [-25°, 25°]; scale dips to 0.95 at extremes.
- Accessible: image alt text is required; the drag interaction is progressive enhancement.
- On touch devices the drag gesture works natively; scroll parallax fires independently.
Product Showcase 360 is a centered React section that lets visitors drag the product image left or right to feel a perspective rotation, simulating a real-world object turn. It combines Framer Motion drag constraints, spring physics and scroll parallax into a single, self-contained component that works with any image source or a styled placeholder when none is provided.
Anatomy
The component is structured in four stacked blocks inside a single centered container. At the top, a fade-in header holds the badge (glowing accent dot + label), the h2 title and an italic subtitle in serif. Below, the interactive viewer is a Framer Motion div that accepts drag on the x-axis, wrapping a square aspect-ratio image container with rounded corners and a subtle border. A drag-hint caption fades in with a 500ms delay. Under the viewer, a short description paragraph sits at max-width 520px. At the bottom, specs render in a responsive CSS grid with auto-fit columns, each card fading in with a staggered delay.
How it works
The rotation is driven by three motion values wired in sequence. dragX captures the raw drag offset as it changes (written via onDrag's info.offset.x); smoothX applies a spring (stiffness 200, damping 30) to it so the rotation feels weighted rather than instant; rotation is then derived from smoothX via useTransform, mapping the [-200, 200] drag range to [-25deg, 25deg] on the rotateY axis. A scale motion value from the same smoothX dips to 0.95 at the extremes to hint at depth. When the user releases, onDragEnd resets dragX to 0, and the spring naturally decelerates the rotation back to center. A secondary scrollRotation, computed from useScroll on the section ref, plays a gentle ±15deg rocking as the user scrolls past, it only shows on the placeholder when no imageSrc is provided.
How to build it in React
Wire the drag-to-rotateY transform chain
Create a useMotionValue for the raw drag position, pipe it through useSpring for inertia, then derive the rotation angle with useTransform. This three-step chain is the whole mechanical core of the component.
const dragX = useMotionValue(0); const smoothX = useSpring(dragX, { stiffness: 200, damping: 30 }); const rotation = useTransform(smoothX, [-200, 200], [-25, 25]); const scaleVal = useTransform(smoothX, [-200, 0, 200], [0.95, 1, 0.95]);Apply drag constraints and reset on release
Wrap the image in a motion.div with drag='x', dragConstraints locking the range to ±200px and dragElastic at 0.1 so the boundary bounces lightly. In onDrag, write info.offset.x into dragX; in onDragEnd, reset dragX to 0 so the spring returns the product to face-on.
<motion.div drag="x" dragConstraints={{ left: -200, right: 200 }} dragElastic={0.1} onDrag={(_, info) => dragX.set(info.offset.x)} onDragEnd={() => dragX.set(0)} style={{ rotateY: rotation, scale: scaleVal, perspective: "800px" }} >Add a scroll-linked ambient rotation
Attach a ref to the section and pass it to useScroll with offset ['start end', 'end start']. Map the resulting scrollYProgress to a ±15deg rotateY on the placeholder or a secondary layer. This runs independently of drag and gives the section life as the user scrolls past without interacting.
const { scrollYProgress } = useScroll({ target: sectionRef, offset: ["start end", "end start"], }); const scrollRotation = useTransform(scrollYProgress, [0, 1], [-15, 15]);Build the specs grid with staggered entry
Render specs in a CSS grid with gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))'. Each spec card is a motion.div with whileInView fade-up and a delay of i * 0.08s. Keep the grid maxWidth at 600px so it reads well even with four columns.
{specs.map((spec, i) => ( <motion.div key={i} initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ delay: i * 0.08, duration: 0.4 }} > <span>{spec.value}</span> <span>{spec.label}</span> </motion.div> ))}
When to use it
Use it as the opening section on a product landing page where a single hero product deserves tactile attention, hardware, headphones, watches, physical SaaS devices, premium packaging. The drag interaction signals 'explore me' immediately without any instruction. Skip it when you have a carousel of multiple products (the single-image assumption breaks down), or when the page is conversion-critical with a tight funnel where distraction is costly. On ultra-low-end devices the spring physics can feel sluggish; test on real hardware before shipping.
Used by
- Apple, Scroll-driven 3D rotation of the AirPods Max on product pages, establishing the benchmark for drag-to-rotate product storytelling.
- Nothing, Perspective product views with pointer-reactive tilt on Phone (2) landing pages, matching the tactile hardware brand identity.
- Teenage Engineering, Interactive 360-style product images for synthesizers, letting visitors inspect hardware details before buying.
- Sonos, Scroll-parallax product images with subtle perspective shifts to convey premium build quality across speaker lines.
FAQ
Can I use multiple product images to simulate real 360-frame sequences?
This component uses a single image with a CSS rotateY perspective illusion, not frame-by-frame playback. For true frame sequences (e.g. 36 PNG frames), you would track the drag offset, derive a frame index from it, and swap the src accordingly, the motion value chain stays the same, but the image source becomes reactive instead of static.
Why does the rotation feel stiff on my machine?
The stiffness/damping ratio in useSpring controls feel. Lowering stiffness (e.g. 120) makes it floatier; raising damping (e.g. 40) absorbs oscillation faster. The defaults (200/30) are tuned for a responsive feel with quick settling, adjust to match your product's weight metaphor.
Is the drag gesture accessible?
The drag is a progressive enhancement; the product image and all specs are fully readable without interacting. There is no keyboard equivalent for the rotation itself, so add an aria-label on the draggable div (e.g. 'Drag to rotate product view') and ensure the image has a meaningful alt attribute. Screen readers get the full content regardless.
Does the scroll parallax conflict with the drag rotation?
No, they apply to different elements. The drag-driven rotateY sits on the outer draggable div; the scroll-driven rotateY is on a nested motion.div inside the placeholder. When an imageSrc is provided, the scroll rotation is not applied at all, so there is no conflict.