{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "curved-gallery",
  "type": "registry:block",
  "title": "Curved Gallery",
  "description": "An endless WebGL photo row you drag, flick, wheel or arrow-key through, riding a spring with inertia and snapping. The row bends as an arc, a wave or a drum in proportion to its speed, with rounded corners, soft shadows and in-frame parallax drawn in the shader.",
  "docs": "Curved Gallery was added to public/vividsites/curved-gallery.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/curved-gallery",
  "categories": [
    "3d",
    "3d-effects"
  ],
  "files": [
    {
      "path": "components/vividsites/curved-gallery.html",
      "type": "registry:file",
      "target": "public/vividsites/curved-gallery.html",
      "content": "<!--\n  vividsites · curved-gallery\n  An endless horizontal gallery drawn in WebGL. Drag it, flick it, wheel it or use\n  the arrow keys: the row rides a spring with real inertia, snaps to the nearest\n  picture, and bends in proportion to how fast it is moving (arc, wave or drum),\n  then relaxes flat as it settles. Rounded corners and soft shadows are drawn in\n  the shader, and each photo slides a little inside its frame (parallax).\n\n  Markup: one <li><img></li> per picture inside .vs-curved-gallery-track (any count, any\n  aspect: the frames are 3:4 and the photo covers them). The list is the no-WebGL fallback\n  (a native scroll-snap row) and stays readable by screen readers.\n    data-style      arc | wave | drum      how the row bends with speed\n    data-snap       \"false\" turns off snapping to the nearest picture\n    data-wheel      \"false\" lets the mouse wheel scroll the page instead of the gallery\n  Customise:\n    --vs-curved-gallery-bend     bend strength (0 = flat, 1 = default, 2 = a lot)\n    --vs-curved-gallery-gap      gap between frames, as a fraction of a frame's width\n    --vs-curved-gallery-size     frame height as a fraction of the component height\n    --vs-curved-gallery-radius   corner radius in px\n    --vs-curved-gallery-bg       the stage colour (also used for depth fog)\n    --vs-curved-gallery-drift    idle drift in frames per second (0 = still)\n    --vs-curved-gallery-height   component height\n  JS:\n    el.vsCurvedGallery.set({ bend, gap, style })   any subset\n    el.vsCurvedGallery.next() / .prev() / .goTo(i)\n  Needs three@0.170.0 (importmap below).\n-->\n<div class=\"vs-curved-gallery\" data-style=\"arc\" aria-roledescription=\"carousel\" aria-label=\"Photo gallery\">\n  <ul class=\"vs-curved-gallery-track\">\n    <li><img src=\"p1.webp\" alt=\"Ski tracks zig-zagging down a wind-carved snow ridge\"></li>\n    <li><img src=\"p2.webp\" alt=\"Inside a blue glacier cave, a meltwater stream runs toward the light\"></li>\n    <li><img src=\"p3.webp\" alt=\"Rippled dunes in red and gold light\"></li>\n    <li><img src=\"p4.webp\" alt=\"A Victorian glasshouse lit from inside at dusk\"></li>\n    <li><img src=\"p5.webp\" alt=\"Festoon lights over a crowd at a forest stage at night\"></li>\n    <li><img src=\"p6.webp\" alt=\"A weathered fishing boat stranded on the tidal flats\"></li>\n    <li><img src=\"p7.webp\" alt=\"Fresh lava glowing through the cracks of a cooling flow\"></li>\n    <li><img src=\"p8.webp\" alt=\"Alpine peaks under pink evening light\"></li>\n    <li><img src=\"p9.webp\" alt=\"Mossy lava field with the sun setting on the horizon\"></li>\n    <li><img src=\"p10.webp\" alt=\"Steam over a milky blue geothermal lagoon at dusk\"></li>\n  </ul>\n  <canvas class=\"vs-curved-gallery-canvas\" aria-hidden=\"true\"></canvas>\n</div>\n\n<style>\n  .vs-curved-gallery {\n    --vs-curved-gallery-bend: 1;\n    --vs-curved-gallery-gap: .14;\n    --vs-curved-gallery-size: .5;\n    --vs-curved-gallery-radius: 18;\n    --vs-curved-gallery-bg: #f2eee7;\n    --vs-curved-gallery-drift: .12;\n    --vs-curved-gallery-height: 100svh;\n    position: relative; width: 100%; height: var(--vs-curved-gallery-height); min-height: 380px; overflow: hidden; isolation: isolate;\n    background: var(--vs-curved-gallery-bg); touch-action: pan-y; -webkit-tap-highlight-color: transparent; cursor: grab; outline: none;\n    -webkit-user-select: none; user-select: none;\n  }\n  .vs-curved-gallery.is-dragging { cursor: grabbing; }\n  .vs-curved-gallery:focus-visible { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--vs-curved-gallery-bg) 40%, #000); }\n  .vs-curved-gallery-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity .9s cubic-bezier(.16,1,.3,1); }\n  .vs-curved-gallery[data-vs-state=\"ready\"] .vs-curved-gallery-canvas { opacity: 1; }\n  /* fallback: a native scroll-snap row (also what screen readers read) */\n  .vs-curved-gallery-track { position: absolute; inset: 0; margin: 0; padding: 0 8vw; list-style: none; display: flex; align-items: center; gap: 24px;\n    overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; }\n  .vs-curved-gallery-track::-webkit-scrollbar { display: none; }\n  .vs-curved-gallery-track li { flex: 0 0 auto; height: calc(var(--vs-curved-gallery-size) * 100%); aspect-ratio: 3 / 4; scroll-snap-align: center; }\n  .vs-curved-gallery-track img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: calc(var(--vs-curved-gallery-radius) * 1px); pointer-events: none; }\n  .vs-curved-gallery[data-vs-state=\"ready\"] .vs-curved-gallery-track { clip-path: inset(50%); pointer-events: none; }\n</style>\n\n<script type=\"importmap\">{ \"imports\": { \"three\": \"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js\" } }</script>\n<script type=\"module\">\nimport * as THREE from 'three';\n\nconst reduce = matchMedia('(prefers-reduced-motion: reduce)');\nconst STYLES = { arc: 0, wave: 1, drum: 2 };\n\n/* the bend, shared by the photos and their shadows */\nconst DEFORM = /* glsl */`\n  uniform float uX, uVel, uBend, uStyle, uTime, uViewW, uHover, uW, uH;\n  float gFade = 1.;\n  vec3 deform(vec3 p, out float nxOut){\n    p.xy *= vec2(uW, uH) * (1. + uHover * .035);\n    vec3 w = p; w.x += uX;\n    float nx = w.x / (uViewW * .5);\n    float v = uVel, av = abs(v);\n    if (uStyle < .5) {\n      /* arc: the row bows away from you, and sags against the direction of travel */\n      w.z -= nx * nx * uBend * (.5 + av * 2.3);\n      w.y -= cos(clamp(nx, -2., 2.) * 1.2) * v * uBend * .55;\n      w.z -= cos(clamp(nx, -2., 2.) * 1.2) * av * uBend * .6;\n    } else if (uStyle < 1.5) {\n      /* wave: a travelling ripple whose height is the speed */\n      float a = uBend * (.18 + av * 1.25);\n      w.z += sin(w.x * .55 - uTime * 1.6) * a;\n      w.y += sin(w.x * .38 + uTime * .9) * a * .35;\n    } else {\n      /* drum: frames wrap around a cylinder that tightens with speed */\n      float k = uBend * (.11 + av * .05) / max(uViewW * .1, .01);\n      float R = 1. / max(k, 1e-4);\n      float th = w.x / R;\n      gFade = smoothstep(1.62, 1.2, abs(th));\n      th = clamp(th, -1.7, 1.7);\n      w.x = sin(th) * R; w.z = -(1. - cos(th)) * R;\n    }\n    nxOut = nx;\n    return w;\n  }`;\n\nconst VERT = /* glsl */`\n  ${DEFORM}\n  varying vec2 vUv; varying float vNx; varying float vZ; varying float vFade;\n  void main(){\n    vUv = uv; float nx;\n    vec3 w = deform(position, nx);\n    vNx = nx; vZ = w.z; vFade = gFade;\n    gl_Position = projectionMatrix * viewMatrix * vec4(w, 1.);\n  }`;\n\nconst FRAG = /* glsl */`\n  uniform sampler2D uTex; uniform vec2 uImg; uniform float uW, uH, uR, uVel, uHover, uFade; uniform vec3 uBg;\n  varying vec2 vUv; varying float vNx; varying float vZ; varying float vFade;\n  float sdBox(vec2 p, vec2 b, float r){ vec2 q = abs(p) - b + r; return length(max(q, 0.)) + min(max(q.x, q.y), 0.) - r; }\n  void main(){\n    vec2 p = (vUv - .5) * vec2(uW, uH);\n    float d = sdBox(p, vec2(uW, uH) * .5, uR);\n    float aa = fwidth(d) * .8;\n    float a = 1. - smoothstep(-aa, aa, d);\n    if (a < .002) discard;\n    /* cover-fit with room for parallax, which slides the photo against its frame */\n    float fa = uW / uH, ia = uImg.x / uImg.y;\n    vec2 s = fa > ia ? vec2(1., ia / fa) : vec2(fa / ia, 1.);\n    float z = 1.16 - uHover * .05;\n    vec2 uv = (vUv - .5) * s / z + .5;\n    uv.x += clamp(vNx, -1.5, 1.5) * .05;\n    float cs = clamp(uVel, -1.5, 1.5) * .0045;\n    vec3 c = vec3(texture2D(uTex, uv + vec2(cs, 0.)).r, texture2D(uTex, uv).g, texture2D(uTex, uv - vec2(cs, 0.)).b);\n    /* depth fog toward the stage and a whisper of edge light */\n    c = mix(c, uBg, clamp(-vZ * .028, 0., .2));\n    c += smoothstep(-2.5 * aa - .02, 0., d) * .05;\n    gl_FragColor = vec4(c, a * uFade * vFade);\n    #include <colorspace_fragment>\n  }`;\n\nconst SHADOW_FRAG = /* glsl */`\n  #define PAD 1.3\n  uniform float uW, uH, uR, uFade, uHover; uniform vec3 uInk;\n  varying vec2 vUv; varying float vFade;\n  float sdBox(vec2 p, vec2 b, float r){ vec2 q = abs(p) - b + r; return length(max(q, 0.)) + min(max(q.x, q.y), 0.) - r; }\n  void main(){\n    vec2 p = (vUv - .5) * vec2(uW, uH);\n    vec2 b = vec2(uW, uH) * .5 - PAD * .5;                 /* the frame's own half-size */\n    float d = sdBox(p, b - vec2(.12, .02), uR);\n    float blur = .42 + uHover * .1;\n    float a = (1. - smoothstep(-blur, blur, d + .05)) * (.2 + uHover * .06);\n    a += (1. - smoothstep(-.03, .09, sdBox(p, b, uR))) * .07;  /* tight contact line */\n    gl_FragColor = vec4(uInk, a * uFade * vFade * vFade);\n  }`;\nconst SHADOW_VERT = /* glsl */`\n  ${DEFORM}\n  varying vec2 vUv; varying float vFade;\n  void main(){ vUv = uv; float nx; vec3 p = position; vec3 w = deform(p, nx); vFade = gFade; w.y -= .16 + uHover * .1; w.z -= .06; gl_Position = projectionMatrix * viewMatrix * vec4(w, 1.); }`;\n\nfunction mount(root) {\n  if (root.vsCurvedGallery) return;\n  const canvas = root.querySelector('.vs-curved-gallery-canvas');\n  const imgs = [...root.querySelectorAll('.vs-curved-gallery-track img')];\n  if (!imgs.length) return;\n  const cssv = (n, d) => getComputedStyle(root).getPropertyValue(n).trim() || d;\n  const numv = (n, d) => { const v = parseFloat(cssv(n, '')); return Number.isFinite(v) ? v : d; };\n\n  let renderer;\n  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' }); }\n  catch { root.dataset.vsState = 'nogl'; return; }\n  renderer.outputColorSpace = THREE.SRGBColorSpace;\n  renderer.setClearColor(0x000000, 0);\n\n  const scene = new THREE.Scene();\n  const camera = new THREE.PerspectiveCamera(30, 1, .1, 200);\n  const VIEW_H = 10; camera.position.z = (VIEW_H / 2) / Math.tan(THREE.MathUtils.degToRad(15));\n\n  const opt = {\n    style: STYLES[root.dataset.style] != null ? root.dataset.style : 'arc',\n    bend: numv('--vs-curved-gallery-bend', 1), gap: numv('--vs-curved-gallery-gap', .14),\n  };\n  const bg = new THREE.Color(cssv('--vs-curved-gallery-bg', '#f2eee7'));\n  const ink = bg.clone().multiplyScalar(.28).lerp(new THREE.Color('#2a1d12'), .5);\n  const geo = new THREE.PlaneGeometry(1, 1, 40, 12);\n  const shadowGeo = new THREE.PlaneGeometry(1, 1, 24, 6);\n  const loader = new THREE.TextureLoader();\n  const maxAniso = renderer.capabilities.getMaxAnisotropy();\n  const items = imgs.map((img, i) => {\n    const uniforms = {\n      uTex: { value: null }, uImg: { value: new THREE.Vector2(3, 4) },\n      uX: { value: 0 }, uVel: { value: 0 }, uBend: { value: 1 }, uStyle: { value: 0 }, uTime: { value: 0 }, uViewW: { value: 16 },\n      uHover: { value: 0 }, uW: { value: 3 }, uH: { value: 4 }, uR: { value: .3 }, uFade: { value: 0 }, uBg: { value: bg }, uInk: { value: ink },\n    };\n    const mat = new THREE.ShaderMaterial({ uniforms, vertexShader: VERT, fragmentShader: FRAG, transparent: true, depthWrite: false });\n    const sh = new THREE.ShaderMaterial({ uniforms, vertexShader: SHADOW_VERT, fragmentShader: SHADOW_FRAG, transparent: true, depthWrite: false });\n    const mesh = new THREE.Mesh(geo, mat), shadow = new THREE.Mesh(shadowGeo, sh);\n    mesh.frustumCulled = shadow.frustumCulled = false;\n    shadow.renderOrder = 0; mesh.renderOrder = 1;\n    scene.add(shadow, mesh);\n    const it = { i, mesh, shadow, uniforms, hover: 0, hv: 0, fade: 0 };\n    loader.load(img.currentSrc || img.src, t => {\n      t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = Math.min(8, maxAniso); t.generateMipmaps = true; t.minFilter = THREE.LinearMipmapLinearFilter;\n      uniforms.uTex.value = t; uniforms.uImg.value.set(t.image.width, t.image.height); it.loaded = performance.now(); kick();\n    });\n    return it;\n  });\n\n  /* ---- layout ---- */\n  let W = 1, H = 1, viewW = 16, fw = 3, fh = 4, spacing = 4, total = 40, pxToWorld = .01;\n  function layout() {\n    const size = THREE.MathUtils.clamp(numv('--vs-curved-gallery-size', .5), .2, .9);\n    fh = VIEW_H * size;\n    fw = fh * .75;\n    if (fw > viewW * .72) { fw = viewW * .72; fh = fw / .75; }       // narrow screens: one big frame\n    spacing = fw * (1 + THREE.MathUtils.clamp(opt.gap, 0, 1.5));\n    total = spacing * items.length;\n    const r = numv('--vs-curved-gallery-radius', 18) * pxToWorld;\n    for (const it of items) {\n      it.uniforms.uW.value = fw; it.uniforms.uH.value = fh; it.uniforms.uR.value = Math.min(r, fw * .2);\n      it.uniforms.uViewW.value = viewW;\n    }\n  }\n  function resize() {\n    const dpr = Math.min(2, devicePixelRatio || 1);\n    W = Math.max(1, root.clientWidth); H = Math.max(1, root.clientHeight);\n    renderer.setPixelRatio(dpr); renderer.setSize(W, H, false);\n    camera.aspect = W / H; camera.updateProjectionMatrix();\n    viewW = VIEW_H * camera.aspect; pxToWorld = VIEW_H / H;\n    layout(); kick();\n  }\n  /* shadow meshes are larger than the frame: the uniform W/H belong to the frame, so remap */\n  for (const it of items) {\n    it.shadow.onBeforeRender = () => { it.uniforms.uW.value = fw + 1.3; it.uniforms.uH.value = fh + 1.3; };\n    it.shadow.onAfterRender = () => { it.uniforms.uW.value = fw; it.uniforms.uH.value = fh; };\n  }\n\n  /* ---- motion: position rides a spring toward a target; speed drives the bend ---- */\n  const S = { x: 0, v: 0, target: 0 };\n  let bendV = 0, lastInput = performance.now(), dragging = false, snapTimer = 0;\n  const snapOn = () => root.dataset.snap !== 'false';\n  const snap = () => { if (snapOn()) S.target = Math.round(S.target / spacing) * spacing; kick(); };\n  const goTo = i => { const cur = Math.round(S.target / spacing); let d = ((i - cur) % items.length + items.length) % items.length; if (d > items.length / 2) d -= items.length; S.target = (cur + d) * spacing; lastInput = performance.now(); kick(); };\n\n  let drag = null;\n  root.addEventListener('pointerdown', e => {\n    if (e.button !== 0) return;\n    drag = { id: e.pointerId, x: e.clientX, y: e.clientY, t0: S.target, lx: e.clientX, lt: performance.now(), v: 0, axis: e.pointerType === 'mouse' ? 'x' : null };\n    lastInput = performance.now(); kick();\n  });\n  root.addEventListener('pointermove', e => {\n    hoverAt(e);\n    if (!drag || e.pointerId !== drag.id) return;\n    const dx = e.clientX - drag.x, dy = e.clientY - drag.y;\n    if (!drag.axis) { if (Math.hypot(dx, dy) < 6) return; drag.axis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y'; if (drag.axis === 'y') { drag = null; return; } }\n    if (!dragging) { dragging = true; root.classList.add('is-dragging'); try { root.setPointerCapture(e.pointerId); } catch {} }\n    const now = performance.now(), dt = Math.max(1, now - drag.lt);\n    drag.v = drag.v * .6 + ((e.clientX - drag.lx) / dt) * .4; drag.lx = e.clientX; drag.lt = now;\n    S.target = drag.t0 - dx * pxToWorld * 1.15;\n    lastInput = now; kick();\n  });\n  const release = e => {\n    if (!drag || (e && e.pointerId !== drag.id)) return;\n    if (dragging) {\n      const idle = performance.now() - drag.lt > 90;\n      S.target -= (idle ? 0 : drag.v) * pxToWorld * 1.15 * 380;           // throw: ~380 ms of the release speed\n      snap();\n    }\n    dragging = false; drag = null; root.classList.remove('is-dragging'); lastInput = performance.now(); kick();\n  };\n  root.addEventListener('pointerup', release); root.addEventListener('pointercancel', release);\n  root.addEventListener('pointerleave', () => { hovered = null; });\n  root.addEventListener('wheel', e => {\n    if (root.dataset.wheel === 'false' && Math.abs(e.deltaX) < Math.abs(e.deltaY)) return;\n    e.preventDefault();\n    const d = (Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY) * (e.deltaMode === 1 ? 16 : 1);\n    S.target += d * pxToWorld * .9; lastInput = performance.now();\n    clearTimeout(snapTimer); snapTimer = setTimeout(snap, 160); kick();\n  }, { passive: false });\n  root.tabIndex = 0;\n  root.addEventListener('keydown', e => {\n    if (e.key === 'ArrowRight') { e.preventDefault(); S.target = (Math.round(S.target / spacing) + 1) * spacing; lastInput = performance.now(); kick(); }\n    if (e.key === 'ArrowLeft') { e.preventDefault(); S.target = (Math.round(S.target / spacing) - 1) * spacing; lastInput = performance.now(); kick(); }\n  });\n\n  const ray = new THREE.Raycaster(), ndc = new THREE.Vector2();\n  let hovered = null;\n  function hoverAt(e) {\n    if (e.pointerType !== 'mouse') return;\n    const r = root.getBoundingClientRect();\n    ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);\n    /* cheap pick: the frame whose flat footprint is under the pointer (the bend is small at rest) */\n    ray.setFromCamera(ndc, camera);\n    const t = -camera.position.z / ray.ray.direction.z, px = ray.ray.origin.x + ray.ray.direction.x * t, py = ray.ray.origin.y + ray.ray.direction.y * t;\n    hovered = items.find(it => Math.abs(px - it.uniforms.uX.value) < fw / 2 && Math.abs(py) < fh / 2) || null;\n    kick();\n  }\n\n  /* ---- loop ---- */\n  let raf = 0, visible = true, lost = false, last = 0;\n  const t0 = performance.now();\n  const kick = () => { if (!raf && visible && !lost) { last = 0; raf = requestAnimationFrame(loop); } };\n  function loop(now) {\n    raf = 0; if (lost) return;\n    const dt = Math.min(1 / 30, (now - (last || now)) / 1000); last = now;\n    const still = reduce.matches;\n    const drift = numv('--vs-curved-gallery-drift', .12);\n    if (!dragging && !still && drift > 0 && now - lastInput > 3500) S.target += drift * spacing * dt;\n    if (still) { S.x = S.target; S.v = 0; }\n    else { S.v += ((S.target - S.x) * 60 - S.v * 14) * dt; S.x += S.v * dt; }       // tension 60, friction 14: a soft, weighted glide\n    const speed = THREE.MathUtils.clamp(S.v / (spacing * 3.2), -1.6, 1.6);\n    bendV += (speed - bendV) * (1 - Math.exp(-dt * 9));\n    const style = STYLES[opt.style] ?? 0, t = (now - t0) / 1000;\n    let busy = Math.abs(S.target - S.x) > 1e-3 || Math.abs(S.v) > 1e-3 || Math.abs(bendV) > 1e-3 || (drift > 0 && !still) || style === 1;\n    for (const it of items) {\n      let x = it.i * spacing - S.x;\n      x = ((x + total / 2) % total + total) % total - total / 2;\n      const u = it.uniforms;\n      u.uX.value = x; u.uVel.value = still ? 0 : bendV; u.uBend.value = opt.bend; u.uStyle.value = style; u.uTime.value = still ? 0 : t;\n      const hv = it === hovered && !dragging ? 1 : 0;\n      it.hv += ((hv - it.hover) * 300 - it.hv * 24) * dt; it.hover += it.hv * dt;\n      u.uHover.value = still ? hv : it.hover;\n      if (it.loaded) { it.fade = Math.min(1, (now - it.loaded) / 700); u.uFade.value = 1 - Math.pow(1 - it.fade, 3); if (it.fade < 1) busy = true; }\n      if (Math.abs(it.hv) > 1e-3) busy = true;\n      it.mesh.visible = it.shadow.visible = Math.abs(x) < viewW * .5 + fw * 1.5 && !!u.uTex.value;\n    }\n    renderer.render(scene, camera);\n    if (root.dataset.vsState !== 'ready' && items.some(i => i.loaded)) root.dataset.vsState = 'ready';\n    if (visible && busy) raf = requestAnimationFrame(loop);\n  }\n\n  canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); lost = true; cancelAnimationFrame(raf); raf = 0; root.dataset.vsState = 'lost'; });\n  canvas.addEventListener('webglcontextrestored', () => { lost = false; root.dataset.vsState = 'ready'; kick(); });\n  new IntersectionObserver(([en]) => { visible = en.isIntersecting; if (visible) kick(); }).observe(root);\n  new ResizeObserver(resize).observe(root);\n  reduce.addEventListener?.('change', kick);\n  resize();\n\n  root.vsCurvedGallery = {\n    set(o = {}) {\n      if (o.style && STYLES[o.style] != null) { opt.style = o.style; root.dataset.style = o.style; }\n      if (o.bend != null) { opt.bend = +o.bend; root.style.setProperty('--vs-curved-gallery-bend', opt.bend); }\n      if (o.gap != null) {                       /* keep the same picture centred while the spacing changes */\n        const it = S.target / spacing, ix = S.x / spacing; opt.gap = +o.gap; root.style.setProperty('--vs-curved-gallery-gap', opt.gap);\n        layout(); S.target = it * spacing; S.x = ix * spacing;\n      }\n      kick(); return { ...opt };\n    },\n    next: () => goTo(Math.round(S.target / spacing) + 1),\n    prev: () => goTo(Math.round(S.target / spacing) - 1),\n    goTo,\n  };\n}\nconst boot = () => document.querySelectorAll('.vs-curved-gallery').forEach(mount);\ndocument.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D effects",
    "tags": [
      "gallery",
      "carousel",
      "slider",
      "webgl",
      "drag",
      "inertia",
      "velocity",
      "images"
    ],
    "tech": [
      "three.js",
      "GLSL",
      "JS",
      "CSS"
    ],
    "deps": [
      "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js"
    ],
    "source": null,
    "free": true,
    "accent": "#c8643b",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/curved-gallery/index.html",
    "thumbnail": "https://vividsites.app/api/component/curved-gallery/thumb.jpg",
    "video": "https://vividsites.app/api/component/curved-gallery/thumb.mp4",
    "page": "https://vividsites.app/c/curved-gallery"
  }
}