Product Gallery with 360 Spin and Morphing Colour Variants

A product page gallery where the product is a drawing that really turns. The travel mug is an SVG cylinder worked out in code: the handle swings behind the body and back, the logo slides round the curve and narrows as it leaves, and the grip ridges and turntable ticks move with it. It turns slowly by itself, a drag spins it with momentum, three thumbnails spring it to set views and a slider scrubs it. Picking a colour whirls the mug while the body, lid, backdrop and a soft blob behind it morph to the new palette. On phones the stage sits above the details.

Code

import { memo, useEffect, useRef, useState } from "react";
import type { PointerEvent } from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";

type Variant = { name: string; body: string; band: string; lid: string; lidTop: string; ink: string; stage: string; blob: string };

const variants: Variant[] = [
  { name: "Moss", body: "#5f7a55", band: "#2e4030", lid: "#243226", lidTop: "#354a38", ink: "#f3f6ee", stage: "#e7eddd", blob: "#cddcbc" },
  { name: "Clay", body: "#c9694a", band: "#7c3622", lid: "#5f2817", lidTop: "#7c3622", ink: "#fff3ea", stage: "#f7e4d7", blob: "#f0c8b0" },
  { name: "Tide", body: "#3f72aa", band: "#1d3a60", lid: "#162d4b", lidTop: "#234672", ink: "#eef5ff", stage: "#dee9f4", blob: "#bcd3ec" },
  { name: "Oat", body: "#eadfc8", band: "#b89f74", lid: "#8f7850", lidTop: "#a88f63", ink: "#5b4a2c", stage: "#f5f0e5", blob: "#e4d8be" },
];

const product = {
  name: "Tarn insulated travel mug",
  price: "$38",
  blurb: "Double wall steel with a ceramic lining. Keeps a flat white hot for six hours and fits under a home espresso machine.",
  points: ["350 ml, leakproof twist lid", "Dishwasher safe, lid included", "Free returns for 60 days"],
};

/** Views the buttons jump to, in degrees. */
const views = [
  { name: "Logo", angle: 0 },
  { name: "Handle", angle: 300 },
  { name: "Back", angle: 180 },
];

const IDLE_SPEED = 26; // degrees per second while nobody is touching it
const CX = 200;
const radius = (y: number) => 86 - (16 * (y - 120)) / 252;
const mod = (a: number) => ((a % 360) + 360) % 360;

/** A closed, smooth blob. Every blob has the same number of curves, so one can morph into another. */
function blob(seed: number): string {
  const n = 8;
  const pts = Array.from({ length: n }, (_, k) => {
    const a = (k / n) * Math.PI * 2;
    const r = 158 + 34 * Math.sin(seed * 1.9 + k * (1.3 + seed * 0.37));
    return [200 + Math.cos(a) * r, 236 + Math.sin(a) * r * 1.08];
  });
  const at = (i: number) => pts[(i + n) % n];
  const f = (v: number) => v.toFixed(1);
  let d = `M${f(pts[0][0])},${f(pts[0][1])}`;
  for (let i = 0; i < n; i++) {
    const [p0, p1, p2, p3] = [at(i - 1), at(i), at(i + 1), at(i + 2)];
    d += `C${f(p1[0] + (p2[0] - p0[0]) / 6)},${f(p1[1] + (p2[1] - p0[1]) / 6)} ${f(p2[0] - (p3[0] - p1[0]) / 6)},${f(p2[1] - (p3[1] - p1[1]) / 6)} ${f(p2[0])},${f(p2[1])}`;
  }
  return d + "Z";
}
const blobs = variants.map((_, i) => blob(i + 1));

/**
 * The mug is a cylinder worked out by hand: anything printed on it sits at an angle around the
 * body, its x position is the sine of that angle and its width is squeezed by the cosine.
 */
const Mug = memo(function Mug({ angle, v, mini }: { angle: number; v: Variant; mini?: boolean }) {
  const t = (angle * Math.PI) / 180;
  const face = (phi: number, y: number) => {
    const a = t + phi;
    const c = Math.cos(a);
    return { x: CX + radius(y) * Math.sin(a), y: y + (20 - (4 * (y - 120)) / 252) * c, c };
  };
  const paint = { transition: "fill 0.7s ease, stroke 0.7s ease" };

  const ha = t + Math.PI / 2;
  const hs = Math.sin(ha);
  const handleInFront = Math.cos(ha) > 0;
  const handle = (
    <g transform={`translate(${CX} 0) scale(${(Math.abs(hs) < 0.03 ? 0.03 : hs).toFixed(3)} 1)`} fill="none" strokeLinecap="round">
      <path d="M78 176C160 162 158 314 73 322" stroke={v.band} strokeWidth={mini ? 3.4 : 20} vectorEffect="non-scaling-stroke" style={paint} />
      <path d="M78 176C160 162 158 314 73 322" stroke={handleInFront ? "#fff" : "#000"} strokeOpacity={handleInFront ? 0.22 : 0.28} strokeWidth={(handleInFront ? 4 : 20) * (mini ? 0.17 : 1)} vectorEffect="non-scaling-stroke" />
    </g>
  );

  const logo = face(0, 164);
  const back = face(Math.PI, 166);
  const sip = { x: CX + 46 * Math.sin(t), y: 100 + 10 * Math.cos(t) };
  const vent = { x: CX - 50 * Math.sin(t), y: 100 - 11 * Math.cos(t) };

  return (
    <svg viewBox={mini ? "30 70 340 330" : "0 0 400 460"} className="h-full w-full" aria-hidden="true">
      <defs>
        <linearGradient id="product-spin-shade" x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="#000" stopOpacity="0.34" />
          <stop offset="0.2" stopColor="#fff" stopOpacity="0.2" />
          <stop offset="0.36" stopColor="#fff" stopOpacity="0.02" />
          <stop offset="0.72" stopColor="#000" stopOpacity="0.06" />
          <stop offset="1" stopColor="#000" stopOpacity="0.42" />
        </linearGradient>
      </defs>
      {!mini && (
        <g>
          <ellipse cx={CX} cy="396" rx="158" ry="36" fill="#fff" fillOpacity="0.55" />
          <ellipse cx={CX} cy="396" rx="158" ry="36" fill="none" stroke="#fff" strokeWidth="2" />
          {Array.from({ length: 36 }, (_, k) => {
            const a = t + (k / 36) * Math.PI * 2;
            const inner = k % 9 === 0 ? 0.78 : 0.88;
            return <line key={k} x1={CX + 158 * inner * Math.sin(a)} y1={396 + 36 * inner * Math.cos(a)} x2={CX + 150 * Math.sin(a)} y2={396 + 34 * Math.cos(a)} stroke="#1c1917" strokeOpacity={k % 9 === 0 ? 0.55 : 0.2} strokeWidth={k % 9 === 0 ? 2.5 : 1.5} strokeLinecap="round" />;
          })}
          <ellipse cx={CX} cy="386" rx="84" ry="15" fill="#1c1917" fillOpacity="0.28" className="blur-md" />
        </g>
      )}
      {!handleInFront && handle}
      <path d="M114 120L130 372A70 16 0 0 0 270 372L286 120Z" fill={v.body} style={paint} />
      <path d="M119.7 210L125.4 300A74.6 17 0 0 0 274.6 300L280.3 210A80.3 18.5 0 0 1 119.7 210Z" fill={v.band} style={paint} />
      {Array.from({ length: 24 }, (_, k) => {
        const top = face((k / 24) * Math.PI * 2, 210);
        if (top.c < 0.06) return null;
        const bottom = face((k / 24) * Math.PI * 2, 300);
        return <line key={k} x1={top.x} y1={top.y + 5} x2={bottom.x} y2={bottom.y - 5} stroke="#fff" strokeOpacity={0.3 * top.c} strokeWidth={1 + 2.5 * top.c} strokeLinecap="round" />;
      })}
      {logo.c > 0.05 && (
        <g transform={`translate(${logo.x.toFixed(1)} ${logo.y.toFixed(1)}) scale(${logo.c.toFixed(3)} 1)`} opacity={Math.min(1, logo.c * 1.8)} fill={v.ink} style={paint}>
          <text textAnchor="middle" fontSize="25" fontWeight="700" letterSpacing="6" fontFamily="ui-sans-serif, system-ui, sans-serif">
            TARN
          </text>
          <rect x="-34" y="9" width="64" height="2.5" rx="1.25" />
        </g>
      )}
      {back.c > 0.05 && (
        <g transform={`translate(${back.x.toFixed(1)} ${back.y.toFixed(1)}) scale(${back.c.toFixed(3)} 1)`} opacity={Math.min(1, back.c * 1.8)} fill="none" stroke={v.ink} strokeWidth="2.2" strokeLinejoin="round" style={paint}>
          <circle cy="-8" r="15" />
          <path d="M-9 -3l6-9 4 5 3-3 5 7" />
          <text y="24" textAnchor="middle" fontSize="10" fontWeight="700" letterSpacing="2" stroke="none" fill={v.ink} fontFamily="ui-sans-serif, system-ui, sans-serif">
            350 ML
          </text>
        </g>
      )}
      <path d="M114 120L130 372A70 16 0 0 0 270 372L286 120Z" fill="url(#product-spin-shade)" />
      <path d="M110 122A90 21 0 0 0 290 122L284 100L116 100Z" fill={v.lid} style={paint} />
      <ellipse cx={CX} cy="100" rx="84" ry="19" fill={v.lidTop} style={paint} />
      <ellipse cx={CX} cy="100" rx="84" ry="19" fill="none" stroke="#fff" strokeOpacity="0.18" strokeWidth="1.5" />
      <ellipse cx={sip.x} cy={sip.y} rx="13" ry="4.2" fill="#000" fillOpacity="0.5" />
      <circle cx={vent.x} cy={vent.y} r="2.2" fill="#000" fillOpacity="0.45" />
      {handleInFront && handle}
    </svg>
  );
});

export default function ProductGalleryWith360SpinAndMorphingColourVariants() {
  const reduce = useReducedMotion();
  const [angle, setAngle] = useState(290);
  const [pick, setPick] = useState(0);
  const [added, setAdded] = useState(false);
  const stage = useRef<HTMLDivElement>(null);
  const visible = useInView(stage, { amount: 0.2 });
  const spin = useRef({ angle: 290, vel: 150, target: null as number | null, dragging: false, rest: 0, x: 0, t: 0 });
  const v = variants[pick];

  // One loop drives the idle turn, the throw after a drag and the spring to a chosen view.
  useEffect(() => {
    if (reduce || !visible) return;
    let raf = 0;
    let last = performance.now();
    const tick = (now: number) => {
      const s = spin.current;
      const dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      if (!s.dragging && !document.hidden) {
        if (s.target !== null) {
          const d = s.target - s.angle;
          s.vel += (d * 60 - s.vel * 14) * dt;
          if (Math.abs(d) < 0.3 && Math.abs(s.vel) < 3) {
            s.angle = s.target;
            s.target = null;
            s.vel = 0;
            s.rest = now + 2400;
          }
        } else {
          const goal = now < s.rest ? 0 : IDLE_SPEED;
          s.vel += (goal - s.vel) * Math.min(1, dt * 2.4);
        }
        s.angle += s.vel * dt;
        setAngle(s.angle);
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [reduce, visible]);

  const down = (e: PointerEvent<HTMLDivElement>) => {
    e.currentTarget.setPointerCapture(e.pointerId);
    Object.assign(spin.current, { dragging: true, target: null, x: e.clientX, t: performance.now(), vel: 0 });
  };
  const move = (e: PointerEvent<HTMLDivElement>) => {
    const s = spin.current;
    if (!s.dragging) return;
    const now = performance.now();
    const turn = (e.clientX - s.x) * 0.6;
    s.vel = s.vel * 0.6 + (turn / Math.max(0.008, (now - s.t) / 1000)) * 0.4;
    s.angle += turn;
    s.x = e.clientX;
    s.t = now;
    setAngle(s.angle);
  };
  const up = () => {
    const s = spin.current;
    if (!s.dragging) return;
    s.dragging = false;
    s.rest = performance.now() + 1600;
    if (reduce) s.vel = 0;
  };
  const jump = (to: number) => {
    const s = spin.current;
    const goal = s.angle + ((((to - s.angle) % 360) + 540) % 360) - 180;
    if (reduce) {
      s.angle = goal;
      setAngle(goal);
    } else s.target = goal;
  };
  const scrubTo = (to: number) => {
    Object.assign(spin.current, { angle: to, vel: 0, target: null, rest: performance.now() + 3000 });
    setAngle(to);
  };
  const choose = (i: number) => {
    setPick(i);
    setAdded(false);
    // A new colour gets a quick whirl so the change reads as one gesture.
    if (!reduce && i !== pick) Object.assign(spin.current, { target: null, rest: 0, vel: spin.current.vel + 520 });
  };

  const shown = Math.round(mod(angle));

  return (
    <section className="bg-white px-4 py-14 sm:px-6 sm:py-16">
      <div className="mx-auto grid max-w-5xl items-center gap-8 lg:grid-cols-[1.1fr_1fr] lg:gap-14">
        <div>
          <div
            ref={stage}
            onPointerDown={down}
            onPointerMove={move}
            onPointerUp={up}
            onPointerCancel={up}
            className="relative h-[380px] cursor-grab touch-pan-y select-none overflow-hidden rounded-[2rem] transition-colors duration-700 active:cursor-grabbing motion-reduce:transition-none sm:h-[500px]"
            style={{ backgroundColor: v.stage }}
          >
            <svg viewBox="0 0 400 460" className="absolute inset-0 h-full w-full" aria-hidden="true">
              <motion.path
                initial={false}
                animate={{ d: blobs[pick], fill: v.blob }}
                transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 70, damping: 13 }}
              />
            </svg>
            <motion.div
              className="absolute inset-0"
              initial={reduce ? false : { opacity: 0, scale: 0.86, y: 24 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              transition={{ type: "spring", stiffness: 150, damping: 17 }}
            >
              <Mug angle={angle} v={v} />
            </motion.div>
            <p className="absolute left-4 top-4 flex items-center gap-1.5 rounded-full bg-white/80 px-3 py-1.5 text-xs font-semibold tabular-nums text-stone-800 backdrop-blur">
              <svg viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="M16 10a6 6 0 1 1-2-4.5M14.5 2.5v3h-3" />
              </svg>
              {shown}°
            </p>
            <p className="absolute right-4 top-4 rounded-full bg-stone-900 px-3 py-1.5 text-xs font-semibold text-white">Drag to spin</p>
          </div>

          <div className="mt-4 flex items-center gap-3">
            <div role="group" aria-label="Jump to a view" className="flex gap-2">
              {views.map((view) => (
                <button
                  key={view.name}
                  type="button"
                  onClick={() => jump(view.angle)}
                  aria-label={`Show the ${view.name.toLowerCase()} side`}
                  className="h-16 w-14 rounded-2xl border border-stone-200 p-1 transition-colors duration-500 hover:border-stone-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 motion-reduce:transition-none"
                  style={{ backgroundColor: v.stage }}
                >
                  <Mug angle={view.angle} v={v} mini />
                </button>
              ))}
            </div>
            <div className="min-w-0 flex-1">
              <label htmlFor="product-spin-angle" className="text-xs font-semibold text-stone-700">
                Rotate
              </label>
              <input
                id="product-spin-angle"
                type="range"
                min={0}
                max={359}
                value={shown}
                onChange={(e) => scrubTo(Number(e.target.value))}
                className="mt-1 block h-10 w-full cursor-pointer accent-stone-900"
              />
            </div>
          </div>
        </div>

        <div>
          <p className="text-sm font-semibold text-stone-600">Hollin &amp; Reed · Drinkware</p>
          <h2 className="mt-2 text-3xl font-semibold tracking-tight text-stone-900 sm:text-4xl">{product.name}</h2>
          <p className="mt-3 text-2xl font-semibold text-stone-900">{product.price}</p>
          <p className="mt-4 text-base leading-relaxed text-stone-600">{product.blurb}</p>

          <p className="mt-7 text-sm font-semibold text-stone-900" id="product-spin-colour">
            Colour: <span className="font-normal text-stone-600">{v.name}</span>
          </p>
          <div role="radiogroup" aria-labelledby="product-spin-colour" className="mt-3 flex gap-3">
            {variants.map((o, i) => (
              <button
                key={o.name}
                type="button"
                role="radio"
                aria-checked={i === pick}
                aria-label={o.name}
                onClick={() => choose(i)}
                className="relative flex h-11 w-11 items-center justify-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2"
              >
                {i === pick && <motion.span layoutId="product-spin-ring" transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 26 }} className="absolute inset-0 rounded-full border-2 border-stone-900" />}
                <span className="h-8 w-8 rounded-full border border-black/10" style={{ background: `linear-gradient(135deg, ${o.body} 55%, ${o.band} 55%)` }} />
              </button>
            ))}
          </div>

          <ul className="mt-7 space-y-2 text-sm text-stone-700">
            {product.points.map((p) => (
              <li key={p} className="flex items-center gap-2">
                <svg viewBox="0 0 20 20" className="h-4 w-4 text-stone-900" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <path d="M4 10.5l4 4 8-9" />
                </svg>
                {p}
              </li>
            ))}
          </ul>

          <button
            type="button"
            onClick={() => setAdded((a) => !a)}
            aria-pressed={added}
            className="mt-8 h-12 w-full rounded-full bg-stone-900 px-6 text-base font-semibold text-white transition-transform duration-150 hover:bg-stone-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 active:scale-[0.98] motion-reduce:transition-none"
          >
            {added ? `In your bag · ${v.name}` : `Add to bag · ${product.price}`}
          </button>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/product-gallery-with-360-spin-and-morphing-colour-variants.json

When to use it

How to customize it

Accessibility

Questions

How is a 360 spin done without photos or 3D?

The mug is treated as a cylinder. A feature at angle a around the body is drawn at an x offset of radius times sin(a), squeezed horizontally by cos(a), and hidden when cos(a) is negative because it is on the far side. The handle uses the same sums and is drawn before or after the body depending on which side it is on.

How does the drag keep spinning after release?

While dragging, pointer movement is added to the angle and a smoothed speed is tracked. On release the animation loop keeps adding that speed and eases it down toward the idle speed.

How do the colour variants morph?

SVG fills carry a CSS transition, so body, band and lid fade to the new colours. The blob behind the product is a path whose `d` attribute is animated by a spring between shapes that share the same number of curves.

Can I use real product photos instead?

Yes. Replace `Mug` with an image whose source is picked from a frame list using the angle, for example frame number `Math.round(angle / 10) % 36`. The loop, drag, slider and buttons stay the same.

Is it heavy to run?

No. One `requestAnimationFrame` loop updates a single number and React redraws a small SVG. The loop stops when the stage is off screen or the tab is hidden.

Related: Product Card with Price, Rating, Colour Swatches and Add to Cart, Zoom Lens Magnifier on Product Image, Rotating 3D Cube Carousel with Drag and Autoplay.

More ecommerce or all free UI components. Start for free to have a full site built around it.

More in Ecommerce

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap