{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "product-turntable",
  "type": "registry:block",
  "title": "Product Turntable",
  "description": "Any GLB on a turned plinth with a brushed, anisotropic platter, in a seamless paper sweep with soft studio shadows. Drag to turn it with inertia; auto-rotate eases back in after a moment, and the backdrop colour and studio, golden or noir lighting are live options.",
  "docs": "Product Turntable was added to public/vividsites/product-turntable.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/product-turntable",
  "categories": [
    "3d",
    "3d-objects"
  ],
  "files": [
    {
      "path": "components/vividsites/product-turntable.html",
      "type": "registry:file",
      "target": "public/vividsites/product-turntable.html",
      "content": "<!--\n  vividsites · product-turntable\n  A GLB product on a turned plinth in a seamless paper sweep, lit like a studio\n  set: a softbox room for reflections, a key light with soft shadows, a rim. The\n  product casts a real shadow on the platter and the sweep, plus a soft contact\n  shadow under the plinth. Drag to turn it: it coasts on a flywheel, comes to\n  rest, and after a moment of idle the slow auto-rotate eases back in.\n  The demo model is a pocket watch whose hands keep real time.\n\n  Customise (attributes, read at mount; all live through the JS API too):\n    data-src          the .glb (Draco and WebP are supported), relative to the page\n    data-height       the product's height in scene units (default 2.3; the plinth is 2.9 across)\n    data-light        studio | golden | noir                         (default studio)\n    data-speed        auto-rotate speed, radians per second, 0 = off (default .35)\n    data-cradle       add to stand the product in a small brass cradle (for things that can't stand up)\n    data-clock        add if the model has hour/minute/second hand nodes (names containing hour/min/sec)\n  CSS variables:\n    --vs-product-turntable-bg       the paper sweep; the plinth and shadows are toned from it (#ece8e1)\n    --vs-product-turntable-accent   the golden look's rim light and the loader ring       (#c8742e)\n    --vs-product-turntable-height   component height                                      (100svh)\n  JS:\n    el.vsProductTurntable.set({ backdrop: '#c9d2c3', light: 'golden', speed: .6 })\n    el.vsProductTurntable.spin(6)      // a flick, radians per second\n    el.vsProductTurntable.destroy()\n  Input: drag with mouse or touch (touch-action: pan-y, so a vertical swipe still\n  scrolls the page), Left / Right arrows to nudge.\n  Needs: three@0.170.0 + GLTFLoader + DRACOLoader + RoomEnvironment (importmap below).\n-->\n<div class=\"vs-product-turntable\" data-src=\"pocket-watch.glb\" data-height=\"2.3\" data-light=\"studio\" data-speed=\"0.35\" data-cradle data-clock tabindex=\"0\" role=\"img\" aria-label=\"A pocket watch on a studio turntable. Drag or use the arrow keys to turn it.\">\n  <canvas class=\"vs-product-turntable-canvas\"></canvas>\n  <div class=\"vs-product-turntable-load\" aria-hidden=\"true\"><svg viewBox=\"0 0 40 40\"><circle cx=\"20\" cy=\"20\" r=\"18\"/><circle class=\"p\" cx=\"20\" cy=\"20\" r=\"18\"/></svg></div>\n</div>\n\n<style>\n  .vs-product-turntable {\n    --vs-product-turntable-bg: #ece8e1;\n    --vs-product-turntable-accent: #c8742e;\n    --vs-product-turntable-height: 100svh;\n    position: relative; width: 100%; height: var(--vs-product-turntable-height); min-height: 440px; overflow: hidden; isolation: isolate;\n    /* the seamless sweep: a lit wall that rolls into the floor with no horizon, a pool of light behind the plinth */\n    background:\n      radial-gradient(46% 34% at 50% 36%, color-mix(in srgb, var(--vs-product-turntable-bg), #fff 30%), transparent 72%),\n      radial-gradient(70% 22% at 50% 76%, color-mix(in srgb, var(--vs-product-turntable-bg), #fff 12%), transparent 80%),\n      linear-gradient(to bottom, color-mix(in srgb, var(--vs-product-turntable-bg), #000 7%) 0%, var(--vs-product-turntable-bg) 44%, color-mix(in srgb, var(--vs-product-turntable-bg), #fff 6%) 64%, color-mix(in srgb, var(--vs-product-turntable-bg), #000 9%) 100%);\n    background-color: var(--vs-product-turntable-bg);\n    cursor: grab; touch-action: pan-y; outline: none; -webkit-tap-highlight-color: transparent; user-select: none; -webkit-user-select: none;\n    transition: background-color .8s cubic-bezier(.16,1,.3,1);\n  }\n  .vs-product-turntable.is-dragging { cursor: grabbing; }\n  .vs-product-turntable:focus-visible { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--vs-product-turntable-accent) 80%, transparent); }\n  .vs-product-turntable::after { /* paper tooth */\n    content: \"\"; position: absolute; inset: 0; pointer-events: none; mix-blend-mode: multiply; opacity: .35;\n    background: url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .5 0 0 0 0 .5 0 0 0 0 .5 0 0 0 .22 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\");\n  }\n  .vs-product-turntable-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transform: translateY(12px); transition: opacity 1.2s cubic-bezier(.16,1,.3,1), transform 1.6s cubic-bezier(.16,1,.3,1); }\n  .vs-product-turntable[data-vs-state=\"ready\"] .vs-product-turntable-canvas { opacity: 1; transform: none; }\n  .vs-product-turntable-load { position: absolute; left: 50%; top: 48%; width: 44px; height: 44px; transform: translate(-50%, -50%); transition: opacity .6s cubic-bezier(.16,1,.3,1); color: color-mix(in srgb, var(--vs-product-turntable-bg), #000 60%); }\n  .vs-product-turntable-load svg { width: 100%; height: 100%; transform: rotate(-90deg); display: block; }\n  .vs-product-turntable-load circle { fill: none; stroke: currentColor; stroke-width: 1.2; opacity: .18; }\n  .vs-product-turntable-load circle.p { opacity: 1; stroke: var(--vs-product-turntable-accent); stroke-dasharray: 113.1; stroke-dashoffset: 113.1; transition: stroke-dashoffset .3s cubic-bezier(.2,0,0,1); }\n  .vs-product-turntable[data-vs-state=\"ready\"] .vs-product-turntable-load, .vs-product-turntable[data-vs-state=\"nogl\"] .vs-product-turntable-load { opacity: 0; }\n</style>\n\n<script type=\"importmap\">{ \"imports\": { \"three\": \"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js\", \"three/addons/\": \"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/\" } }</script>\n<script type=\"module\">\nimport * as THREE from 'three';\nimport { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';\nimport { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';\nimport { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';\n\n// three lighting set-ups, eased between: key, rim, fill (colour, intensity), env, exposure, shadow strength\nconst LOOKS = {\n  studio: { key: ['#fff7ee', 2.8], rim: ['#dfe8ff', 1.6], fill: ['#ffffff', 0.6], env: 0.95, exp: 1.0, shadow: 0.62 },\n  golden: { key: ['#ffb468', 3.6], rim: ['accent', 3.4], fill: ['#8aa3c8', 0.45], env: 0.62, exp: 1.0, shadow: 0.62 },\n  noir: { key: ['#f6f1ea', 4.2], rim: ['#9db8ff', 2.2], fill: ['#ffffff', 0.04], env: 0.22, exp: 0.92, shadow: 0.8 },\n};\nconst css = (el, n, f) => getComputedStyle(el).getPropertyValue(n).trim() || f;\nconst lum = (c) => 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;\n\n// a soft radial blob, for the contact shadow under the plinth\nfunction blobTexture() {\n  const c = document.createElement('canvas'); c.width = c.height = 256; const g = c.getContext('2d');\n  const gr = g.createRadialGradient(128, 128, 0, 128, 128, 128);\n  gr.addColorStop(0, 'rgba(0,0,0,1)'); gr.addColorStop(0.55, 'rgba(0,0,0,.55)'); gr.addColorStop(0.8, 'rgba(0,0,0,.14)'); gr.addColorStop(1, 'rgba(0,0,0,0)');\n  g.fillStyle = gr; g.fillRect(0, 0, 256, 256); return new THREE.CanvasTexture(c);\n}\n// the platter: concentric lathe rings (roughness detail + a hairline index ring) and a tangential brushing\n// direction for the anisotropic highlight, so it catches light in an arc like a turned record-player platter\nfunction platterTextures() {\n  const N = 512, c = document.createElement('canvas'); c.width = c.height = N; const g = c.getContext('2d'), r = N / 2;\n  g.fillStyle = '#8c8c8c'; g.fillRect(0, 0, N, N);\n  for (let i = 0; i < 230; i++) { const v = 132 + ((i * 37) % 13) * 1.6; g.strokeStyle = `rgb(${v},${v},${v})`; g.lineWidth = 1; g.beginPath(); g.arc(r, r, 8 + i * 1.07, 0, Math.PI * 2); g.stroke(); }\n  g.save(); g.translate(r, r);\n  for (let d = 0; d < 360; d += 5) { g.rotate(Math.PI / 36); g.fillStyle = d % 45 === 0 ? '#e8e8e8' : '#b8b8b8'; g.fillRect(-0.6, -r + 5, 1.2, d % 45 === 0 ? 11 : 6); }\n  g.restore();\n  const rough = new THREE.CanvasTexture(c); rough.anisotropy = 8;\n  const a = new Uint8Array(N * N * 4);\n  for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {\n    const dx = x + 0.5 - r, dy = y + 0.5 - r, l = Math.hypot(dx, dy) || 1, i = (y * N + x) * 4;\n    a[i] = (-dy / l * 0.5 + 0.5) * 255; a[i + 1] = (dx / l * 0.5 + 0.5) * 255; a[i + 2] = 255; a[i + 3] = 255;\n  }\n  const dir = new THREE.DataTexture(a, N, N); dir.needsUpdate = true;\n  return { rough, dir };\n}\n\nfunction mount(el) {\n  const canvas = el.querySelector('.vs-product-turntable-canvas');\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  let renderer;\n  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' }); } catch (e) { el.dataset.vsState = 'nogl'; return null; }\n  if (!renderer.getContext()) { el.dataset.vsState = 'nogl'; return null; }\n  renderer.setClearColor(0, 0); renderer.toneMapping = THREE.ACESFilmicToneMapping;\n  renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.VSMShadowMap;\n  const accent = new THREE.Color(css(el, '--vs-product-turntable-accent', '#c8742e'));\n\n  const scene = new THREE.Scene();\n  const pmrem = new THREE.PMREMGenerator(renderer);\n  const envRT = pmrem.fromScene(new RoomEnvironment(), 0.03); scene.environment = envRT.texture;\n  const camera = new THREE.PerspectiveCamera(24, 1, 0.1, 100);\n\n  // ---- lights: a big soft key up-left (VSM shadows blur like a softbox), a rim behind right, a front fill\n  const key = new THREE.DirectionalLight('#fff7ee', 2.6); key.position.set(-4, 7.5, 4.5); key.castShadow = true;\n  key.shadow.mapSize.set(2048, 2048); key.shadow.radius = 18; key.shadow.blurSamples = 20; key.shadow.bias = -0.0006;\n  Object.assign(key.shadow.camera, { left: -5.5, right: 5.5, top: 5.5, bottom: -5.5, near: 2, far: 20 });\n  const rim = new THREE.DirectionalLight('#dfe8ff', 1.6); rim.position.set(4.5, 3.5, -5);\n  const fill = new THREE.DirectionalLight('#ffffff', 0.7); fill.position.set(3, 1.2, 6);\n  scene.add(key, rim, fill);\n\n  // ---- the floor: shadows only (the sweep itself is the element's CSS background, so its colour is exact)\n  const catcher = new THREE.Mesh(new THREE.PlaneGeometry(9, 9), new THREE.ShadowMaterial({ opacity: 0.5, color: new THREE.Color('#1a140e') }));\n  catcher.rotation.x = -Math.PI / 2; catcher.receiveShadow = true; scene.add(catcher);\n  const contact = new THREE.Mesh(new THREE.PlaneGeometry(3.9, 3.9), new THREE.MeshBasicMaterial({ map: blobTexture(), transparent: true, depthWrite: false, opacity: 0.4, color: new THREE.Color('#1a140e') }));\n  contact.rotation.x = -Math.PI / 2; contact.position.y = 0.002; scene.add(contact);\n\n  // ---- the plinth: turned, tone-on-tone with the sweep; a brushed platter turns on top of it\n  const PR = 1.45, PH = 0.34, TR = 1.18, TH = 0.035;\n  const plinthProfile = [[0, 0], [PR - 0.02, 0], [PR, 0.02], [PR, PH - 0.05], [PR - 0.012, PH - 0.016], [PR - 0.05, PH], [0, PH]].map(([x, y]) => new THREE.Vector2(x, y));\n  const plinthMat = new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.82, metalness: 0, envMapIntensity: 0.7 });\n  const plinth = new THREE.Mesh(new THREE.LatheGeometry(plinthProfile, 160), plinthMat); plinth.castShadow = true; plinth.receiveShadow = true; scene.add(plinth);\n  const table = new THREE.Group(); table.position.y = PH; scene.add(table);\n  const ptex = platterTextures();\n  const platMat = new THREE.MeshPhysicalMaterial({ color: '#a29e97', metalness: 1, roughness: 0.5, roughnessMap: ptex.rough, anisotropy: 0.85, anisotropyMap: ptex.dir, envMapIntensity: 1.1 }); // turned satin aluminium\n  const platter = new THREE.Mesh(new THREE.CylinderGeometry(TR, TR, TH, 160), [ // CylinderGeometry groups: side, top, bottom\n    new THREE.MeshStandardMaterial({ color: '#d7d4ce', metalness: 1, roughness: 0.2 }), platMat, platMat]); // a polished edge\n  platter.position.y = TH / 2; platter.castShadow = true; platter.receiveShadow = true; table.add(platter);\n  const holder = new THREE.Group(); holder.position.y = TH; table.add(holder);\n\n  const S = { yaw: -0.5, w: 0, speed: reduced ? 0 : parseFloat(el.dataset.speed || 0.35), drag: false, lx: 0, lt: 0, lastTouch: 0.4, visible: true, raf: 0, last: 0, t: 0, ready: false, hands: null };\n  const L = { key: new THREE.Color(), rim: new THREE.Color(), fill: new THREE.Color(), ki: 2.6, ri: 1.6, fi: 0.7, env: 1, exp: 1, sh: 0.5 };\n  const bg = new THREE.Color(), bgTo = new THREE.Color();\n  const toColor = (c) => (c === 'accent' ? accent.clone() : new THREE.Color(c));\n  function readBg() { const probe = document.createElement('i'); probe.style.color = css(el, '--vs-product-turntable-bg', '#ece8e1'); el.appendChild(probe); const c = getComputedStyle(probe).color; probe.remove(); return new THREE.Color().setStyle(c); }\n  function applyBg(c) {\n    bg.copy(c);\n    const dark = lum(bg) < 0.12; // sRGB-encoded colours are stored linear: 0.12 ~ a mid-dark ground\n    const shadowTint = bg.clone().multiplyScalar(dark ? 0 : 0.18); catcher.material.color.copy(shadowTint); contact.material.color.copy(shadowTint);\n    plinthMat.color.copy(bg).multiplyScalar(dark ? 1.6 : 0.36); // tone-on-tone: a shade darker than the paper (lighter on ink)\n    contact.material.opacity = dark ? 0.75 : 0.55;\n  }\n  function applyLook(name, instant) {\n    const lk = LOOKS[name]; if (!lk) return; el.dataset.light = name;\n    S.target = { key: toColor(lk.key[0]), rim: toColor(lk.rim[0]), fill: toColor(lk.fill[0]), ki: lk.key[1], ri: lk.rim[1], fi: lk.fill[1], env: lk.env, exp: lk.exp, sh: lk.shadow };\n    if (instant || reduced) { L.key.copy(S.target.key); L.rim.copy(S.target.rim); L.fill.copy(S.target.fill); Object.assign(L, { ki: S.target.ki, ri: S.target.ri, fi: S.target.fi, env: S.target.env, exp: S.target.exp, sh: S.target.sh }); }\n    kick();\n  }\n\n  // ---- the product\n  const draco = new DRACOLoader().setDecoderPath('https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/libs/draco/gltf/');\n  const loader = new GLTFLoader().setDRACOLoader(draco);\n  const ring = el.querySelector('.vs-product-turntable-load .p');\n  const progress = (p) => { if (ring) ring.style.strokeDashoffset = String(113.1 * (1 - p)); };\n  loader.load(new URL(el.dataset.src || 'model.glb', document.baseURI).href, (gltf) => {\n    const obj = gltf.scene;\n    obj.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; if (o.material) o.material.envMapIntensity = 1.25; } });\n    const box = new THREE.Box3().setFromObject(obj); const size = box.getSize(new THREE.Vector3()), ctr = box.getCenter(new THREE.Vector3());\n    const H = parseFloat(el.dataset.height || 2.3), k = H / size.y;\n    const wrap = new THREE.Group(); obj.position.sub(ctr); obj.position.y += size.y / 2; wrap.add(obj); wrap.scale.setScalar(k);\n    const lift = 'cradle' in el.dataset ? 0.1 : 0;\n    wrap.position.y = lift; holder.add(wrap);\n    if ('cradle' in el.dataset) {\n      // a turned brass cradle: two shallow arcs that hold the case, on a short post and foot\n      const brass = new THREE.MeshStandardMaterial({ color: '#b98a4a', metalness: 1, roughness: 0.28 });\n      const depth = size.z * k, cy = lift + (-box.min.y) * k, cr = size.x * k * 0.5;\n      const arc = new THREE.Mesh(new THREE.TorusGeometry(cr * 1.02, 0.02, 12, 64, Math.PI * 0.42), brass);\n      arc.rotation.z = -Math.PI / 2 - Math.PI * 0.21; arc.position.y = cy; arc.castShadow = true;\n      const arc2 = arc.clone(); arc.position.z = depth * 0.3; arc2.position.z = -depth * 0.3;\n      const foot = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.26, 0.04, 64), brass); foot.position.y = 0.02; foot.castShadow = true;\n      const post = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, cy - cr + 0.04, 16), brass); post.position.y = (cy - cr + 0.04) / 2;\n      holder.add(arc, arc2, foot, post);\n    }\n    if ('clock' in el.dataset) {\n      const hands = {};\n      obj.traverse((o) => { if (!o.isMesh) return; const n = o.name.toLowerCase(); const kind = n.includes('hour') ? 'h' : n.includes('min') ? 'm' : n.includes('sec') ? 's' : null; if (!kind) return;\n        // the hand's resting angle: toward its farthest vertex from the pivot\n        const P = o.geometry.attributes.position; let best = 0, ang = 0; for (let i = 0; i < P.count; i++) { const x = P.getX(i), y = P.getY(i), d = x * x + y * y; if (d > best) { best = d; ang = Math.atan2(y, x); } }\n        hands[kind] = { o, rest: ang, z0: o.rotation.z }; });\n      if (hands.h || hands.m || hands.s) S.hands = hands;\n    }\n    progress(1);\n    renderer.compile(scene, camera); S.ready = true; kick();\n  }, (e) => { if (e.total) progress(Math.min(0.95, e.loaded / e.total)); }, (err) => { console.warn('vs-product-turntable: model failed to load', err); el.dataset.vsState = 'ready'; });\n\n  function resize() {\n    const w = el.clientWidth, h = el.clientHeight; if (!w || !h) return;\n    renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setSize(w, h, false);\n    camera.aspect = w / h;\n    // the product fills ~58% of the height; on a phone the plinth bleeds off the sides so the product stays big\n    const H = parseFloat(el.dataset.height || 2.3), vh = Math.max(H / 0.58, 2.05 / camera.aspect);\n    const dist = vh / 2 / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));\n    const ty = PH + H * 0.5 - vh * 0.05;\n    camera.position.set(0, ty + dist * 0.2, dist); camera.lookAt(0, ty, 0); camera.updateProjectionMatrix();\n  }\n\n  function frame(now) {\n    S.raf = 0;\n    const dt = Math.min(0.05, (now - (S.last || now)) / 1000); S.last = now; S.t += dt;\n    if (!S.drag) {\n      // after a flick the flywheel coasts down; once it has been left alone ~2 s, auto-rotate eases back in\n      const auto = S.t - S.lastTouch > 2 ? S.speed : 0;\n      S.w += (auto - S.w) * (1 - Math.exp(-dt * (auto ? 0.7 : 1.1)));\n      S.yaw += S.w * dt;\n    }\n    table.rotation.y = S.yaw;\n    if (S.target) {\n      const k = 1 - Math.exp(-dt * 3.2);\n      L.key.lerp(S.target.key, k); L.rim.lerp(S.target.rim, k); L.fill.lerp(S.target.fill, k);\n      for (const p of ['ki', 'ri', 'fi', 'env', 'exp', 'sh']) L[p] += (S.target[p] - L[p]) * k;\n      key.color.copy(L.key); key.intensity = L.ki; rim.color.copy(L.rim); rim.intensity = L.ri; fill.color.copy(L.fill); fill.intensity = L.fi;\n      scene.environmentIntensity = L.env; renderer.toneMappingExposure = L.exp; catcher.material.opacity = L.sh * (lum(bg) < 0.12 ? 1.2 : 1);\n    }\n    if (bg.r !== bgTo.r || bg.g !== bgTo.g || bg.b !== bgTo.b) { const c = bg.clone().lerp(bgTo, reduced ? 1 : 1 - Math.exp(-dt * 5)); if (Math.abs(c.r - bgTo.r) + Math.abs(c.g - bgTo.g) + Math.abs(c.b - bgTo.b) < 0.002) c.copy(bgTo); applyBg(c); }\n    if (S.hands) {\n      const d = new Date(); const s = d.getSeconds() + (reduced ? 0 : d.getMilliseconds() / 1000), m = d.getMinutes() + s / 60, hr = (d.getHours() % 12) + m / 60;\n      const set = (hd, frac) => { if (hd) hd.o.rotation.z = hd.z0 + (Math.PI / 2 - frac * Math.PI * 2) - hd.rest; };\n      set(S.hands.h, hr / 12); set(S.hands.m, m / 60);\n      // the seconds hand ticks, with a small overshoot, like a real escapement\n      if (S.hands.s) { const i = Math.floor(s), f = s - i; const tick = reduced ? 0 : Math.min(1, f / 0.12); const e = 1 + Math.sin(tick * Math.PI) * 0.18 * (1 - tick); set(S.hands.s, (i + (tick >= 1 ? 1 : tick * e)) / 60); }\n    }\n    renderer.render(scene, camera);\n    if (S.ready && el.dataset.vsState !== 'ready') el.dataset.vsState = 'ready';\n    if (S.visible && !document.hidden) S.raf = requestAnimationFrame(frame);\n  }\n  const kick = () => { if (!S.raf && S.visible) { S.last = performance.now(); S.raf = requestAnimationFrame(frame); } };\n\n  const onDown = (e) => { if (e.button > 0) return; S.drag = true; S.lx = e.clientX; S.lt = performance.now(); S.w = 0; S.lastTouch = S.t; el.classList.add('is-dragging'); el.setPointerCapture(e.pointerId); kick(); };\n  const onMove = (e) => { if (!S.drag) return; const now = performance.now(), dt = Math.max(1, now - S.lt) / 1000; const dx = (e.clientX - S.lx) / Math.min(el.clientWidth, 900) * Math.PI * 1.4; S.yaw += dx; S.w = S.w * 0.4 + (dx / dt) * 0.6; S.lx = e.clientX; S.lt = now; S.lastTouch = S.t; };\n  const onUp = () => { if (!S.drag) return; S.drag = false; el.classList.remove('is-dragging'); if (performance.now() - S.lt > 80) S.w *= 0.2; S.w = THREE.MathUtils.clamp(S.w, -10, 10); S.lastTouch = S.t; };\n  const onKey = (e) => { if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); S.w += (e.key === 'ArrowLeft' ? -1 : 1) * 2.5; S.lastTouch = S.t; kick(); } };\n  el.addEventListener('pointerdown', onDown); el.addEventListener('pointermove', onMove); el.addEventListener('pointerup', onUp); el.addEventListener('pointercancel', onUp); el.addEventListener('keydown', onKey);\n\n  const ro = new ResizeObserver(() => { resize(); kick(); }); ro.observe(el);\n  const io = new IntersectionObserver(([en]) => { S.visible = en.isIntersecting; if (S.visible) kick(); }, { rootMargin: '100px' }); io.observe(el);\n  const onVis = () => { if (!document.hidden) kick(); }; document.addEventListener('visibilitychange', onVis);\n  const onLost = (e) => { e.preventDefault(); cancelAnimationFrame(S.raf); S.raf = 0; S.visible = false; };\n  const onRestored = () => { destroy(); el.__vsRemount(); };\n  canvas.addEventListener('webglcontextlost', onLost); canvas.addEventListener('webglcontextrestored', onRestored);\n  function destroy() {\n    cancelAnimationFrame(S.raf); ro.disconnect(); io.disconnect(); document.removeEventListener('visibilitychange', onVis);\n    ['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'keydown'].forEach((t, i) => el.removeEventListener(t, [onDown, onMove, onUp, onUp, onKey][i]));\n    canvas.removeEventListener('webglcontextlost', onLost); canvas.removeEventListener('webglcontextrestored', onRestored);\n    draco.dispose(); envRT.dispose(); pmrem.dispose(); ptex.rough.dispose(); ptex.dir.dispose(); renderer.dispose();\n  }\n  function set(o = {}) {\n    if (o.backdrop) { el.style.setProperty('--vs-product-turntable-bg', o.backdrop); bgTo.copy(readBg()); if (reduced) applyBg(bgTo); }\n    if (o.light) applyLook(o.light);\n    if (o.speed != null) { S.speed = reduced ? 0 : Math.max(0, +o.speed); el.dataset.speed = String(S.speed); }\n    kick();\n  }\n  bgTo.copy(readBg()); applyBg(bgTo);\n  applyLook(el.dataset.light in LOOKS ? el.dataset.light : 'studio', true);\n  resize(); kick();\n  return { set, light: (n) => applyLook(n), spin: (v = 6) => { S.w += v; S.lastTouch = S.t; kick(); }, destroy, get yaw() { return S.yaw; } };\n}\n\ndocument.querySelectorAll('.vs-product-turntable:not([data-vs-mounted])').forEach((el) => {\n  el.dataset.vsMounted = '';\n  const go = () => { const api = mount(el); if (api) el.vsProductTurntable = api; };\n  el.__vsRemount = go; go();\n});\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D objects",
    "tags": [
      "product",
      "glb",
      "turntable",
      "studio",
      "shadow",
      "viewer",
      "ecommerce",
      "drag",
      "inertia"
    ],
    "tech": [
      "three.js",
      "JS",
      "CSS"
    ],
    "deps": [
      "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
      "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/loaders/GLTFLoader.js",
      "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/loaders/DRACOLoader.js",
      "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/environments/RoomEnvironment.js"
    ],
    "source": null,
    "free": true,
    "accent": "#c8742e",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/product-turntable/index.html",
    "thumbnail": "https://vividsites.app/api/component/product-turntable/thumb.jpg",
    "video": "https://vividsites.app/api/component/product-turntable/thumb.mp4",
    "page": "https://vividsites.app/c/product-turntable"
  }
}